openvibers/openvibe.vip Commits
OpenVibe.VIP W10: plans with immutable versioned terms, perks and product bindings, Billing entitlement projection (events + inbox, valid_until, authoritative fallback), checkout and cancel through Billing, gated-resource policy that fails closed, consumer client, SSR pages, outbox events, Live offering import
Changes
nnew file mode 100644@@@ -0,0 +1,53 @@++# OpenVibe.VIP — environment (development: .env; production: /etc/openvibe/vip.env).++# Names only here; never commit a value.++++# ── Runtime ──────────────────────────────────────────────++NODE_ENV=development++PORT=4620++HOST=127.0.0.1++BASE_URL=http://localhost:4620++VIP_DB_PATH=./data/vip.db++# TRUST_PROXY=1++++# ── OpenVibe.Network (identity) ──────────────────────────++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# OV_NETWORK_ISSUER=https://openvibe.network++# OV_NETWORK_PUBLIC_KEY= # optional; otherwise fetched from /api/.well-known/jwks++# VIP_AUDIENCE=openvibe.vip++# Client `vip`: service tokens (Billing, Events, Network) and browser sign-in++OV_OAUTH_CLIENT_ID=vip++OV_OAUTH_CLIENT_SECRET=++# OV_OAUTH_REDIRECT_URI=https://openvibe.vip/auth/callback++# Signs the pages' anti-forgery tokens (required in production): openssl rand -hex 32++VIP_FORM_SECRET=++# Network roles that manage network plans and see any creator's members++VIP_STAFF_ROLES=admin++++# ── OpenVibe.Billing (entitlement truth, checkout, cancel) ──++BILLING_URL=http://127.0.0.1:4600++# BILLING_AUDIENCE=openvibe.billing++# VIP_BILLING_TIMEOUT_MS=5000++# Providers offered on plan pages (must be enabled on Billing); `credit` pays from Vibes credit++VIP_CHECKOUT_PROVIDERS=powerchat,credit++# Turns Billing's PowerChat checkout_ref into a link: {ref} and {cents}++VIP_POWERCHAT_LINK_TEMPLATE=++# VIP_BILLING_RATES_TTL_MS=300000++++# ── Entitlement projection ───────────────────────────────++# A projection answer is trusted at most this long after VIP last heard from Billing (and never++# past the paid period's end); within the grace a projection-only check still answers (stale),++# after it VIP answers "unknown" until Billing confirms.++VIP_PROJECTION_MAX_AGE_MS=900000++VIP_PROJECTION_GRACE_MS=60000++++# ── OpenVibe.Events ──────────────────────────────────────++# Outbox relay (vip.plan.published, vip.membership.changed); off when empty++EVENTS_URL=++# EVENTS_RELAY_INTERVAL_MS=2000++# Signing secret of VIP's Billing-event subscriptions (scripts/subscribe.js); comma-separate to rotate++VIP_EVENTS_SECRET=++++# ── Jobs ─────────────────────────────────────────────────++# VIP_JOBS=off++# VIP_REFRESH_INTERVAL_MS=60000nnew file mode 100644@@@ -0,0 +1,20 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1 # production runs Node 22++ cache: npm++ - run: npm ci --no-audit --no-fund++ - run: npm test++ # The vip.* capabilities and the alpha service manifest are proposals (docs/) until the lead++ # releases them in openvibe-contracts; until then the check reports the placeholder manifest.++ - run: npx openvibe-contracts-check --service vip++ continue-on-error: truennew file mode 100644@@@ -0,0 +1,12 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.key@@@ -2,69 +2,276 @@> Memberships: plans, perks and benefits recognised across every OpenVibe site.--**Status:** placeholder — planning only, no runnable code yet.--**Domain:** `openvibe.vip`--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.3.++**Status:** alpha — runtime built and tested (roadmap Wave 10), **not deployed**. `openvibe.vip` keeps++its [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) placeholder until the launch rule++below holds.++**Domain:** `openvibe.vip` (port 4620, service id `vip`)++**Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) —++Billing holds the entitlement truth; VIP holds plans and perks.++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.3; roadmap §15.11, §29, Wave 10.**License:** AGPL-3.0 (same as every OpenVibe service).## Purpose--Creator and network membership plans and perks with product bindings. Billing remains the subscription and entitlement truth; VIP owns plan versions, perk definitions, member/creator UX and gated-resource policy references.++Creator and network membership plans and perks with product bindings. A creator publishes a plan;++a member joins it through OpenVibe.Billing; every OpenVibe product can ask VIP whether a person is a++member and whether they may see a members-only resource. VIP owns plan versions, perk definitions,++member and creator UX and gated-resource policy; Billing remains the subscription and entitlement++truth.## Owns--- `vip_creators`, `vip_plans`, `vip_plan_versions`, `vip_perks`, `vip_plan_perks`, `vip_product_bindings`, `vip_member_preferences`, `vip_gated_resource_rules`, `vip_migration_maps`++The nine authority tables (VIP is their only writer):++++| Table | Holds |++|---|---|++| `vip_creators` | a creator (a Network `usr_` subject, with a cached handle for `/:username`) or the network itself |++| `vip_plans` | a plan: status (draft/published/archived), the Billing product it is sold as, its current version |++| `vip_plan_versions` | **immutable** plan terms, one row per edit (SQLite triggers refuse UPDATE/DELETE except the one-time publish stamp) |++| `vip_perks` | perk definitions (creator or network scope): badge, emote, gated content, room, role, other |++| `vip_plan_perks` | **immutable**: which perks a plan *version* includes, with the key and name it was published with |++| `vip_product_bindings` | how a perk shows up in a product (`live chat_badge`, `chat badge`, `blog gated_post`, …) |++| `vip_member_preferences` | per member and creator: show the badge, be listed |++| `vip_gated_resource_rules` | "resource `{service, type, id}` is for members of creator C (of plan P / with perk K)", optionally *sensitive* |++| `vip_migration_maps` | legacy source row → VIP target, with status (`imported`, `linked`, `held`, `excluded`) and reason |++++Operational tables, authority for nothing outside VIP: `vip_memberships` (which plan **version** a++membership was bought under — never whether it is active), `vip_entitlement_projection` (a cache of++Billing entitlements with `valid_until`), `vip_checkouts` (checkout hand-offs), and the openvibe-sdk++`event_outbox` / `idempotency_receipts`. `vip_memberships` is a tenth table the charter did not++list; it is needed so that "a membership always points at the version it was bought under" has a++home that is not a cache.## Does not own--- subscription contracts, charges, renewals, refunds, canonical entitlements (Billing)++- subscription contracts, prices, charges, renewals, cancellations, refunds, chargebacks and the++ canonical entitlements — **OpenVibe.Billing**. VIP never prices anything and never moves money;++ plan terms carry no price (`"price": "set and charged by OpenVibe.Billing"`).++- identities (OpenVibe.Network), the products' own resources (Blog posts, Chat rooms, …: VIP stores++ only an EntityRef), badges' rendering (each product).--## Planned surfaces++## Depends on--- plan/perk management, member status, checkout hand-off, badges/cards/widgets--- entitlement projection client with a direct authoritative check for sensitive actions++- **OpenVibe.Billing** — checkout (`billing.intent.create`), credit-paid periods and cancel++ (`billing.subscription.manage`), the authoritative check and subscription lists (`billing.entitlement.check`),++ and its events `billing.entitlement.changed`, `billing.subscription.canceled`, `billing.transaction.reversed`.++- **OpenVibe.Network** — RS256 service and user tokens (JWKS), OAuth sign-in for the pages, identity++ resolve (importer only).++- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.++- **openvibe-contracts** v0.14.0, **openvibe-sdk** v0.2.2 (outbox, inbox, delivery signatures),++ **openvibe-shared** v1.3.0 (chrome, legal pages, release, metrics, readiness).++- Consumers (not wired yet): Live, Chat, Community, Blog, Wiki — through the client below.++- **Not** OpenVibe.Live: VIP works with Live unavailable (nothing here calls Live).--## Data (authority tables / families)++## Run it--- see above++```bash++fnm exec --using=22.22.1 npm install++cp .env.example .env # OV_OAUTH_CLIENT_SECRET, VIP_FORM_SECRET, VIP_EVENTS_SECRET, BILLING_URL …++fnm exec --using=22.22.1 npm run dev # http://localhost:4620++fnm exec --using=22.22.1 npm test # 5 test files: stub Network + Billing, temp DBs, random ports, injected clock++node scripts/subscribe.js # create the three Events subscriptions for Billing's events++node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]++```--## Capabilities and events++Production (at launch): `/opt/openvibe.vip`, env `/etc/openvibe/vip.env`, unit++[deploy/systemd/openvibe-vip.service](deploy/systemd/openvibe-vip.service) (state in `/var/lib/openvibe-vip`),++vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf).--- `vip.plan.*`, `vip.perk.*`, `vip.membership.checkout|status`, `vip.entitlement.check`, `vip.resource.policy.get|evaluate`, `vip.creator.members.list`++## Design--Events: ``vip.plan.published``, ``vip.membership.changed` (projection of Billing events)`++### Versioned plans--## Depends on++- Creating a plan writes version 1. **Every edit writes version N+1**; nothing ever updates an earlier++ version (triggers enforce it). An edit of a published plan is published at once and becomes what++ new members buy (`vip.plan.published`); a draft's edits stay drafts until published.++- A membership records the version it was bought under: the member's own checkout hand-off, or —++ for a subscription bought elsewhere (e.g. on Live before its cutover) — the creator's current++ purchasable version at the moment Billing granted it. **Renewals keep that version**, however++ often the plan changes; a new purchase after the membership lapsed takes the current version.++- Perk rules evaluate the perks of the member's version, not the plan's latest.++- Renaming a perk never changes published terms (each version keeps its snapshot).++- Billing sells one channel subscription per (member, creator) today, so a creator has at most **one++ published plan sold through Billing** (a unique index; a second is refused with++ `409 vip.plan.billing_product_taken`). Tiers and network memberships need a Billing product first:++ a network plan (staff) can be published as terms, but checkout answers `409 vip.checkout.unavailable`.++- Archiving stops new members; existing members keep their terms until Billing ends the membership.++++### The entitlement projection (Billing is the truth)++++`vip_entitlement_projection` is a cache of Billing's entitlements, fed by Billing's events++(signed Events webhook + openvibe-sdk `createInbox`, exactly once) and by direct checks.++++- `valid_until = min(paid period end, last word from Billing + VIP_PROJECTION_MAX_AGE_MS)`; a "no"++ is kept for the max age.++- `now ≤ valid_until`: the projection answers. Past it, mode `auto` asks Billing++ (`GET /api/v1/entitlements/:subject?streamer=`) and re-projects; only if Billing cannot answer++ and we are within `VIP_PROJECTION_GRACE_MS` does it return the stale answer (flagged `stale: true`).++- After the grace, **VIP answers `"unknown"`, which never authorizes** — a stale cache cannot++ authorize indefinitely, whatever the paid period says.++- Mode `authoritative` (sensitive actions: checkout, cancel, rules marked *sensitive*) always asks++ Billing and answers `unknown` when it cannot.++- Ordering: each row remembers the Billing time it reflects. An older event that would grant is++ ignored; an older event that would revoke puts the row in doubt (`valid_until = 0`), so the next++ check goes to Billing — out-of-order delivery can deny briefly, never grant wrongly.++- `billing.transaction.reversed` that revoked periods puts the pair in doubt at once, even before (or++ without) the matching `billing.entitlement.changed`.++- Convergence window: seconds when events flow; when every event for a change is lost, at most++ `VIP_PROJECTION_MAX_AGE_MS + VIP_PROJECTION_GRACE_MS` (15 min + 60 s by default), and never past++ the paid period's end for renewals and cancellations.++- A background job re-confirms, with Billing, projections that products used in the last hour++ before they go stale.++++### Checkout and cancellation (Billing does both)++++- `POST /api/v1/checkout` asks Billing first (a member cannot buy twice), records the plan version++ chosen, and hands off: `provider: credit` → Billing `POST /subscriptions` (paid from Vibes credit,++ membership starts at once); any other provider → Billing `POST /intents` (kind subscription),++ returning Billing's `checkout_url` or PowerChat `checkout_ref`. The membership starts when Billing's++ settlement event arrives. Idempotency keys come from VIP's ids (`vip:checkout:<id>`).++- Cancel is the member's own action: VIP confirms with Billing that the subscription is theirs, then++ Billing `POST /subscriptions/:id/cancel` (at period end).++++### Events (openvibe-sdk outbox, same transaction as the change)++++| Event | When | Payload |++|---|---|---|++| `vip.plan.published` (public) | a plan version becomes what new members buy | plan id, slug, version, version id, creator, billing_kind, full terms |++| `vip.membership.changed` (internal) | a member's standing changed as projected from Billing (granted, renewed, cancel_scheduled, canceled, expired, refund, …) | member, creator, active, expires_at, cancel_at_period_end, previous, reason, plan id, plan version, source, Billing event id |++++## API (`/api/v1`)++++Service tokens: Network client-credentials, audience `openvibe.vip`, one capability per route (the++`vip.*` ids are proposals in [docs/capabilities-proposal/](docs/capabilities-proposal/); until they ship++in an openvibe-contracts release, grants are matched with contracts' `capabilities.grants()` — exact id++or a `.*` family). User tokens: a Network user JWT; people act on their own things (their plans,++perks, rules, members; their own memberships and checks); staff roles (`VIP_STAFF_ROLES`) manage++network plans. Errors are RFC 9457 problem+json. Creators are named by SubjectRef, `usr_` id, VIP++handle or `network`.++++| Method & path | Capability (services) | Users | Does |++|---|---|---|---|++| `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` | — | — | liveness; truthful readiness (db required; Network key and Billing optional → degraded); release; loopback metrics |++| `GET /creators/:ref` | — (public) | owner sees drafts | creator, published plans, own perks |++| `GET /plans?creator=&include=drafts,archived` | `vip.plan.list` for drafts | owner | plans with current/draft versions |++| `GET /plans/:id`, `GET /plans/:id/versions` | `vip.plan.list` for drafts | owner | a plan; its version history (published versions are public) |++| `POST /plans` | `vip.plan.create` (body `creator`) | self / staff for `network` | create (v1), optionally `publish` |++| `PATCH /plans/:id` | `vip.plan.update` | owner | edit → new version |++| `POST /plans/:id/publish` | `vip.plan.update` | owner | publish the latest version |++| `POST /plans/:id/archive` | `vip.plan.archive` | owner | archive |++| `GET /perks?creator=&network=&retired=` | `vip.perk.list` for retired | owner | perks with product bindings |++| `POST /perks`, `PATCH /perks/:id` | `vip.perk.create` / `vip.perk.update` | owner | define / edit a perk and its bindings |++| `POST /checkout` | `vip.membership.checkout` (body `subject`) | self | hand-off to Billing: `{ checkout, membership_started, checkout_url, checkout_ref }` |++| `GET /memberships/:subject[?mode=]` | `vip.membership.status` | self | memberships, version bought under, entitlement, preferences |++| `POST /memberships/:creator/cancel` | — | self only | cancel at period end through Billing |++| `PUT /memberships/:creator/preferences` | — | self only | `{ show_badge, listed }` |++| `GET\|POST /entitlements/check` `{subject, creator, mode}` | `vip.entitlement.check` | self | `{ status: active\|inactive\|unknown, active, expires_at, cancel_at_period_end, source, stale, valid_until, membership }` |++| `GET /policies?service=&type=&id=` / `?creator=` | `vip.resource.policy.get` | owner | the rule for a resource / a creator's rules |++| `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` *(proposed addition)* | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |++| `POST /policies/evaluate` `{subject, resource, rule_id?, mode?}` | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement }` — **fails closed** |++| `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |--- OpenVibe.Billing--- OpenVibe.Network--- OpenVibe.Events--- consumers: Live, Chat, Community, Blog, Wiki++Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,++`creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`, `plan_required`,++`perk_missing`. Only `allow: true` allows.--## Acceptance (must be true before "done")++## Consumer seam: `openvibe-vip/client`--- cancel/renew/refund converges across all consuming products within the defined window--- plan edits are versioned and never rewrite historical terms--- a stale cache cannot authorise indefinitely--- VIP works with Live unavailable++A module with no dependencies for Blog, Wiki, Chat, Live and Community++([client/vip-client.js](client/vip-client.js)). Every failure — VIP unreachable, a timeout, a refused++token, a malformed answer — is a denial, never an exception and never a "yes".--## Bootstrap / extraction source++```js++const { createVipClient } = require('openvibe-vip/client');++const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl: `${NETWORK}/oauth/token`, clientId, clientSecret,++ audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });++const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens, timeoutMs: 2000 });--Live's channel-subscription behaviour and perks, reconciled into Billing first.++const d = await vip.evaluate({ subject: viewer ? viewer.subject : null, resource: { service: 'blog', type: 'post', id: '42' } });++if (!d.allow) return renderMembersOnlyTeaser(d.reason);++++await vip.isMember(viewerSubject, creatorSubject); // boolean; false on any doubt++await vip.checkEntitlement({ subject, creator, mode: 'authoritative' }); // sensitive content++```++++Until the SDK grows a `vip` module, consumers pin this repository's tag tarball and require only++`openvibe-vip/client` (it loads nothing else); moving it into openvibe-sdk is the intended home.++Live's badge mapping (`live chat_badge`, `live powerchat_overlay`, `live ai_context`) and Chat's++(`chat badge`) are declared as product bindings of the network perk `subscriber-badge`.++++## Pages (server-rendered, useful without JavaScript, shared chrome)++++`/` directory of creators with published plans · `/:username` a creator's plans (terms, perks,++Billing's price and period from `GET /api/v1/rates`, join form) · `/:username/plans/:slug` public terms++history · `/me` your memberships, the version you joined under, cancel, badge preference ·++`/dashboard` plans, versions, perks and bindings, members, members-only resources++(`?as=network` for staff) · `/robots.txt`, `/sitemap.xml` · `/terms`, `/privacy`, `/dmca`++(openvibe-shared legal). Navbar from `https://openvibe.network/shared/navbar.js`, SSR footer and++`<noscript>` navigation from openvibe-shared. Forms carry HMAC anti-forgery tokens++(`VIP_FORM_SECRET`); cookies are SameSite=Lax. No feed: plans are not a publication stream.++++## Import from Live++++`scripts/import-live.js` reads a **read-only snapshot** of Live's database and reflects Live's++current offering — every channel can be subscribed to monthly at the site-wide price, and a++subscriber gets a star "Subscriber" badge (Live chat, the PowerChat overlay relay, the stream AI's++context flag):++++1. the network perk `subscriber-badge` with those product bindings plus Chat's badge;++2. a creator for every Live streamer (role streamer / global_mod / admin) or subscribed-to user,++ resolved to a Network subject (unmapped users are **held**, never dropped);++3. per creator, plan `channel-subscription` v1 "Channel subscription", published and sold as Billing's++ `channel_subscription`, with the badge perk — or a link to the creator's own published plan;++4. **memberships from Billing** (its subscriptions per creator via the API, or `--billing-db`++ snapshot): each active one is filed under that v1 with a projection row; inactive ones are++ recorded as excluded with the reason;++5. every row of Live's `subscriptions` is reconciled: linked to the VIP membership or Billing++ subscription for the pair, or held with the reason.++++Idempotent (keys in `vip_migration_maps`), never overwrites a creator's later edits, releases holds++on a later run; `--dry-run` rolls everything back; exit 1 when Billing could not be read. Live's++`sub_price_usd`/share/fee are reported for reference only — Billing prices.++++## Security notes++++- Every API route checks one capability (services) or ownership (users); a presented token is judged++ on that token alone. The API never reads cookies.++- Billing events: HMAC-SHA256 signature over the raw body (`VIP_EVENTS_SECRET`, rotation by list),++ `source` must be `billing`, exactly-once inbox, ordering guard.++- Fail closed everywhere a membership is decided; "unknown" is never "yes".++- Outbound calls go only to configured service URLs (Network, Billing, Events); no user-supplied++ URLs are fetched. Checkout redirects only to http(s) URLs Billing returned.++- Secrets appear as environment variable names only.++++## Acceptance (what the tests prove)++++| Criterion | Test |++|---|---|++| plan edits create versions and never rewrite historical terms | `test/plans.test.js` (edit → v2, v1 row byte-identical, triggers refuse UPDATE/DELETE, perk rename keeps terms) |++| old members keep old terms; renewals keep them; a new purchase after a lapse takes the current version | `test/plans.test.js`, `test/api.test.js` (checkout version survives a later edit) |++| cancel / renew / refund converge (events, and with events lost) | `test/entitlements.test.js` (injected clock) |++| a stale projection cannot authorize past `valid_until` (+ grace), even with Billing down for 100 days | `test/entitlements.test.js` |++| authoritative fallback and sensitive checks | `test/entitlements.test.js`, `test/policies.test.js` |++| gated-resource evaluation fails closed (no rule, unknown, perk missing, guest, VIP down in the client) | `test/policies.test.js` |++| import idempotent, holds released, dry run keeps nothing, Billing outage reported | `test/import.test.js` |++| VIP works with Live unavailable | no code path calls Live; every test runs without a Live stub |++++Not yet demonstrated (needs the other services): convergence **across consuming products** (Live,++Chat, Community do not call VIP yet), and a real Billing/Events round trip on the host.## Launch ruleThis repository does not make the product real, and the domain keeps its placeholder page on[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the--following exist here (plan §12.12):----1. an owning runtime with health/readiness endpoints and observability;--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);--3. server-rendered or static public routes that are useful without JavaScript;--4. real persistence and end-to-end workflows;--5. capability and event registration against `OpenVibe.Contracts`;--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;--7. acceptance tests proving the advertised functionality.++following exist (plan §12.12):++++1. an owning runtime with health/readiness endpoints and observability — ✔ (`/api/health`, truthful `/api/ready`, `/metrics`);++2. canonical identity/auth integration (Network subjects, scoped service principals) — ✔ in code; the `vip` principal and grants are not provisioned yet;++3. server-rendered public routes useful without JavaScript — ✔;++4. real persistence and end-to-end workflows — ✔ against stubs; not yet against the running Billing and Events;++5. capability and event registration against `OpenVibe.Contracts` — proposed ([docs/](docs/)), not released;++6. a migration/seed strategy ✔, a security/threat review (notes above; a review by someone else is still due), sitemap/robots ✔ (no feed by design);++7. acceptance tests proving the advertised functionality — ✔ for VIP itself; cross-product convergence waits for the consumers.The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing andregisters maturity in the ecosystem registry atomically. A placeholder is never counted as an@@@ -1,7 +1,24 @@{"repository": "OpenVibers/OpenVibe.VIP",-- "stage": "placeholder",-- "code": false,++ "stage": "alpha",++ "code": true,++ "deployed": false,"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "updated": "2026-09-21"++ "roadmap": "Wave 10",++ "works": [++ "plans, immutable plan versions, perks, product bindings, member preferences and gated-resource rules in VIP's own SQLite",++ "entitlement projection fed by Billing events (signed webhook + openvibe-sdk inbox) with valid_until, grace and a direct Billing fallback; 'unknown' never authorizes",++ "checkout hand-off and cancellation through OpenVibe.Billing; the membership is filed under the plan version chosen",++ "API /api/v1 for service and user tokens; consumer client openvibe-vip/client that fails closed",++ "server-rendered creator plan pages, terms history, member status and creator dashboard with the shared chrome",++ "outbox events vip.plan.published and vip.membership.changed (openvibe-sdk)",++ "import of Live's subscription offering, memberships from Billing entitlements"++ ],++ "not_yet": [++ "not deployed; openvibe.vip is still an OpenVibe.Sites placeholder",++ "vip.* capabilities and the service manifest are proposals, not in an openvibe-contracts release",++ "Live, Chat and Community do not call VIP yet (consumer integration is the next step)",++ "Billing sells one channel subscription per member and creator: one purchasable plan per creator, no tiers, no network membership product"++ ],++ "updated": "2026-09-22"}nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++++/**++ * openvibe-vip/client — the consumer seam for products that honour VIP memberships (Blog, Wiki,++ * Chat, Live, Community, …). No dependencies; Node 18+ (global fetch) or any fetch you pass.++ *++ * const { createVipClient } = require('openvibe-vip/client');++ * const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl, clientId, clientSecret,++ * audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });++ * const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });++ *++ * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' } });++ * if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED++ *++ * const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…' }); // e.active++ * await vip.isMember('usr_…', 'usr_…'); // boolean, false on any doubt++ *++ * Every failure — VIP unreachable, a timeout, a refused token, a malformed answer — is a denial++ * ({ allow: false, reason: 'vip_unavailable' } / { status: 'unknown', active: false }), never an++ * exception and never a "yes". Pass mode: 'authoritative' for sensitive content: VIP then asks++ * OpenVibe.Billing directly instead of its projection.++ */++++function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null, getToken = null, fetch: fetchImpl = globalThis.fetch, timeoutMs = 2000, log = null } = {}) {++ if (typeof fetchImpl !== 'function') throw new TypeError('createVipClient: a fetch implementation is required');++ const base = String(baseUrl).replace(/\/+$/, '');++++ async function headers() {++ if (tokenClient && typeof tokenClient.authHeaders === 'function') return tokenClient.authHeaders();++ if (typeof getToken === 'function') return { Authorization: `Bearer ${await getToken()}` };++ throw new Error('createVipClient: tokenClient or getToken is required');++ }++++ async function post(path, body, retried = false) {++ const res = await fetchImpl(`${base}${path}`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await headers()) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(timeoutMs),++ });++ if (res.status === 401 && !retried && tokenClient && typeof tokenClient.invalidate === 'function') { tokenClient.invalidate(); return post(path, body, true); }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || typeof data !== 'object') throw new Error(`VIP ${res.status}: ${(data && (data.code || data.detail)) || 'bad response'}`);++ return data;++ }++ const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };++++ /** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */++ async function evaluate({ subject = null, resource, ruleId, mode } = {}) {++ try {++ const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, rule_id: ruleId, mode });++ return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };++ } catch (err) {++ note('evaluate', err);++ return { allow: false, reason: 'vip_unavailable', error: err.message };++ }++ }++++ /** The entitlement of `subject` to `creator` (usr_ ids or SubjectRefs). */++ async function checkEntitlement({ subject, creator, mode } = {}) {++ try {++ const e = await post('/api/v1/entitlements/check', { subject, creator, mode });++ return { ...e, active: e.active === true && e.status === 'active' };++ } catch (err) {++ note('checkEntitlement', err);++ return { status: 'unknown', active: false, reason: 'vip_unavailable', error: err.message };++ }++ }++++ const isMember = async (subject, creator, opts = {}) => (await checkEntitlement({ subject, creator, mode: opts.mode })).active;++++ return { evaluate, checkEntitlement, isMember };++}++++module.exports = { createVipClient };nnew file mode 100644@@@ -0,0 +1,108 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.vip — Nginx Configuration (REFERENCE, not deployed)++##++## Deploy to /etc/nginx/sites-available/openvibe.vip.conf and reload nginx — only at launch: until++## the launch rule in README.md holds, openvibe.vip keeps its OpenVibe.Sites placeholder.++##++## The Node app (port 4620, systemd unit openvibe-vip) renders every page and serves /api/v1.++## Other OpenVibe services call it on http://127.0.0.1:4620 directly. Not published here:++## /internal/ OpenVibe.Events delivers Billing events to 127.0.0.1:4620/internal/events++## /metrics host-local scrapers only (Node also refuses forwarded requests)++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=ovvip_api:10m rate=10r/s;++limit_req_zone $binary_remote_addr zone=ovvip_auth:10m rate=10r/m;++# Form posts only (join, cancel, dashboard): an empty key is not limited.++map $request_method $ovvip_form_key { POST $binary_remote_addr; default ""; }++limit_req_zone $ovvip_form_key zone=ovvip_forms:10m rate=2r/s;++++# ── HTTP → HTTPS redirect ──────────────────────────────────++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.vip www.openvibe.vip;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.vip$request_uri; }++}++++# ── www → apex ─────────────────────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.vip;++++ ssl_certificate /etc/letsencrypt/live/openvibe.vip/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.vip/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.vip$request_uri;++}++++# ── HTTPS server ───────────────────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.vip;++++ ssl_certificate /etc/letsencrypt/live/openvibe.vip/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.vip/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.vip.access.log;++ error_log /var/log/nginx/openvibe.vip.error.log;++++ client_max_body_size 256k;++++ add_header X-Content-Type-Options nosniff always;++ add_header X-Frame-Options SAMEORIGIN always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml text/xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── Never public ────────────────────────────────────────++ location /internal/ { return 404; }++ location = /metrics { return 404; }++++ # ── OAuth session endpoints (strict rate limit) ─────────++ location /auth/ {++ limit_req zone=ovvip_auth burst=10 nodelay;++ proxy_pass http://127.0.0.1:4620;++ }++++ # ── API (service and user tokens; moderate rate limit) ──++ location /api/ {++ limit_req zone=ovvip_api burst=30 nodelay;++ proxy_pass http://127.0.0.1:4620;++ }++++ # ── Static assets: Node marks hashed ?v= URLs immutable ──++ location ~ ^/(?:css|js)/[^/]+\.(?:css|js)$ {++ proxy_pass http://127.0.0.1:4620;++ }++++ # ── Pages; form posts (join, cancel, dashboard) are rate limited ──++ location / {++ limit_req zone=ovvip_forms burst=20 nodelay;++ proxy_pass http://127.0.0.1:4620;++ proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,48 @@++[Unit]++Description=OpenVibe.VIP — memberships: plans, versioned terms, perks and gated-resource policy++# Network signs the tokens VIP verifies (its key is fetched at boot and retried); Billing holds++# the entitlement truth; Events relays the outbox when EVENTS_URL is set. VIP starts without any.++After=network.target++Wants=openvibe-network.service openvibe-billing.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.vip++EnvironmentFile=/etc/openvibe/vip.env++Environment=NODE_ENV=production++Environment=PORT=4620++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.vip/server/index.js++Restart=always++RestartSec=5++# The VIP database lives in /var/lib/openvibe-vip (ADR-007: one database per service);++# the code tree stays read-only.++StateDirectory=openvibe-vip++StateDirectoryMode=0700++Environment=VIP_DB_PATH=/var/lib/openvibe-vip/vip.db++UMask=0077++NoNewPrivileges=true++PrivateTmp=true++PrivateDevices=true++ProtectSystem=strict++ProtectHome=true++ProtectKernelTunables=true++ProtectKernelModules=true++ProtectKernelLogs=true++ProtectControlGroups=true++ProtectClock=true++ProtectHostname=true++RestrictSUIDSGID=true++RestrictRealtime=true++RestrictNamespaces=true++LockPersonality=true++SystemCallArchitectures=native++RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX++CapabilityBoundingSet=++AmbientCapabilities=++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "vip.creator.members.list",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "A creator's active members with the plan version each joined under: from Billing's subscriptions, or VIP's fresh projection (labelled) when Billing does not answer.",++ "permissions": [++ "vip:members"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "vip-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/creators/:ref/members"++ ]++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "vip.entitlement.check",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "Is a subject a member of a creator right now? Answered from VIP's short-lived projection of Billing entitlements, with a direct Billing check when the projection is stale (mode auto) or always (mode authoritative). Past valid_until plus grace the answer is \"unknown\", which never authorizes.",++ "permissions": [++ "vip:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "vip-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/entitlements/check",++ "POST /api/v1/entitlements/check"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "vip.membership.checkout",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "Start joining a plan on behalf of a member: VIP records the plan version chosen and hands off to OpenVibe.Billing (payment intent, or a period paid from the member's credit). Billing charges; VIP never does.",++ "permissions": [++ "vip:checkout"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "vip-write",++ "events": [++ "vip.membership.changed"++ ],++ "implementedBy": [++ "POST /api/v1/checkout"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "vip.membership.status",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "A member's memberships: the plan version each was bought under, preferences, and the entitlement status (projection, confirmed with Billing when stale).",++ "permissions": [++ "vip:read"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "vip-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/memberships/:subject"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "vip.perk.create",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "Define a perk (badge, emote, gated content, room, role) for a creator or the network, with its product bindings.",++ "permissions": [++ "vip:perks"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "vip-write",++ "events": [],++ "implementedBy": [++ "POST /api/v1/perks"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "vip.perk.list",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "public",++ "description": "List a creator's perks (and the network perks) with product bindings. Active perks are public; the grant adds retired ones.",++ "permissions": [++ "vip:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "vip-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/perks"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "vip.perk.update",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "Rename, describe, retire a perk or replace its product bindings. Published plan versions keep the perk snapshot they were published with.",++ "permissions": [++ "vip:perks"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "vip-write",++ "events": [],++ "implementedBy": [++ "PATCH /api/v1/perks/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "vip.plan.archive",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "Archive a plan: no new members; existing members keep their terms until Billing ends their membership.",++ "permissions": [++ "vip:plans"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "vip-write",++ "events": [],++ "implementedBy": [++ "POST /api/v1/plans/:id/archive"++ ]++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "vip.plan.create",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "Create a membership plan (version 1) for a creator, or for the network (staff). A published plan is sold through OpenVibe.Billing as the creator's channel subscription; Billing sets the price.",++ "permissions": [++ "vip:plans"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "vip-write",++ "events": [++ "vip.plan.published"++ ],++ "implementedBy": [++ "POST /api/v1/plans"++ ]++}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "id": "vip.plan.list",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "public",++ "description": "List a creator's plans with their current and draft versions and the full version history. Published plans are public without a grant; the grant adds drafts and archived plans.",++ "permissions": [++ "vip:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "vip-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/plans",++ "GET /api/v1/plans/:id",++ "GET /api/v1/plans/:id/versions",++ "GET /api/v1/creators/:ref"++ ]++}nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "id": "vip.plan.update",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "Edit a plan by creating a new immutable version (published at once when the plan is published), or publish a draft. Earlier versions and the terms members joined under are never rewritten.",++ "permissions": [++ "vip:plans"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "vip-write",++ "events": [++ "vip.plan.published"++ ],++ "implementedBy": [++ "PATCH /api/v1/plans/:id",++ "POST /api/v1/plans/:id/publish"++ ]++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "id": "vip.resource.policy.evaluate",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "May subject S see resource R (under rule X)? Fails closed: no rule, a disabled rule, an entitlement VIP cannot confirm, a missing perk or any error is allow: false with the reason.",++ "permissions": [++ "vip:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "vip-read",++ "events": [],++ "implementedBy": [++ "POST /api/v1/policies/evaluate"++ ]++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "vip.resource.policy.get",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "Read the gated-resource rule VIP holds for a resource (EntityRef), or a creator's rules.",++ "permissions": [++ "vip:read"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "vip-read",++ "events": [],++ "implementedBy": [++ "GET /api/v1/policies",++ "GET /api/v1/policies/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,20 @@++{++ "id": "vip.resource.policy.set",++ "version": "1.0",++ "owner": "vip",++ "status": "active",++ "visibility": "first-party",++ "description": "Create, replace or remove the rule gating a resource (EntityRef) for a creator: any member, members of one plan, or members whose plan version includes a perk; optionally always checked with Billing (sensitive).",++ "permissions": [++ "vip:policies"++ ],++ "resourceConstraints": [++ "owner"++ ],++ "quotaClass": "vip-write",++ "events": [],++ "implementedBy": [++ "POST /api/v1/policies",++ "DELETE /api/v1/policies/:id"++ ]++}nnew file mode 100644@@@ -0,0 +1,45 @@++{++ "version": "0.2.0",++ "contractRanges": {++ "openvibe-contracts": ">=0.14.0 <1.0.0"++ },++ "id": "vip",++ "name": "OpenVibe.VIP",++ "status": "alpha",++ "repository": "OpenVibers/OpenVibe.VIP",++ "domains": [++ "openvibe.vip"++ ],++ "publicOrigin": "https://openvibe.vip",++ "health": "/api/health",++ "ready": "/api/ready",++ "capabilities": [++ "vip.plan.create",++ "vip.plan.update",++ "vip.plan.archive",++ "vip.plan.list",++ "vip.perk.create",++ "vip.perk.update",++ "vip.perk.list",++ "vip.membership.checkout",++ "vip.membership.status",++ "vip.entitlement.check",++ "vip.resource.policy.get",++ "vip.resource.policy.set",++ "vip.resource.policy.evaluate",++ "vip.creator.members.list"++ ],++ "eventsProduced": [++ "vip.plan.published",++ "vip.membership.changed"++ ],++ "eventsConsumed": [++ "billing.entitlement.changed",++ "billing.subscription.canceled",++ "billing.transaction.reversed"++ ],++ "namespacesOwned": [++ "vip.*"++ ],++ "notes": "Memberships (ADR-012): plans, immutable versioned plan terms, perks, product bindings, member preferences and gated-resource policy. OpenVibe.Billing holds subscriptions, charges, renewals, refunds and the canonical entitlements; VIP keeps a short-lived projection of them (valid_until, authoritative fallback, 'unknown' never authorizes). Alpha: not deployed; openvibe.vip stays on its OpenVibe.Sites placeholder until the launch rule holds."++}nnew file mode 100644@@@ -0,0 +1,1501 @@++{++ "name": "openvibe-vip",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-vip",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "jsonwebtoken": "^9.0.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.14.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/base64-js": {++ "version": "1.5.1",++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/better-sqlite3": {++ "version": "11.10.0",++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",++ "hasInstallScript": true,++ "license": "MIT",++ "dependencies": {++ "bindings": "^1.5.0",++ "prebuild-install": "^7.1.1"++ }++ },++ "node_modules/bindings": {++ "version": "1.5.0",++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",++ "license": "MIT",++ "dependencies": {++ "file-uri-to-path": "1.0.0"++ }++ },++ "node_modules/bl": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",++ "license": "MIT",++ "dependencies": {++ "buffer": "^5.5.0",++ "inherits": "^2.0.4",++ "readable-stream": "^3.4.0"++ }++ },++ "node_modules/body-parser": {++ "version": "1.20.8",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/buffer": {++ "version": "5.7.1",++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "base64-js": "^1.3.1",++ "ieee754": "^1.1.13"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/chownr": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",++ "license": "ISC"++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },++ "node_modules/decompress-response": {++ "version": "6.0.0",++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",++ "license": "MIT",++ "dependencies": {++ "mimic-response": "^3.1.0"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/deep-extend": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",++ "license": "MIT",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/detect-libc": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=8"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/end-of-stream": {++ "version": "1.4.5",++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",++ "license": "MIT",++ "dependencies": {++ "once": "^1.4.0"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/expand-template": {++ "version": "2.0.3",++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",++ "license": "(MIT OR WTFPL)",++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/file-uri-to-path": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",++ "license": "MIT"++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fs-constants": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",++ "license": "MIT"++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/github-from-package": {++ "version": "0.0.0",++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",++ "license": "MIT"++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/ieee754": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/ini": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",++ "license": "ISC"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mimic-response": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",++ "license": "MIT",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/minimist": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/mkdirp-classic": {++ "version": "0.5.3",++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",++ "license": "MIT"++ },++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },++ "node_modules/napi-build-utils": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",++ "license": "MIT"++ },++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/node-abi": {++ "version": "3.96.0",++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",++ "license": "MIT",++ "dependencies": {++ "semver": "^7.3.5"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.1",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/once": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",++ "license": "ISC",++ "dependencies": {++ "wrappy": "1"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.14.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.14.0",++ "integrity": "sha512-U4m1Vj/gJHbRWZhiH1QT2qke/YSPH2rXPOuScguGJ3Tgy9OyEsRvjB4ebYW4UVf9qNNtfgwS2mL8kZijXH0+GA==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.2.2",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-shared": {++ "version": "1.3.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",++ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/prebuild-install": {++ "version": "7.1.3",++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",++ "license": "MIT",++ "dependencies": {++ "detect-libc": "^2.0.0",++ "expand-template": "^2.0.3",++ "github-from-package": "0.0.0",++ "minimist": "^1.2.3",++ "mkdirp-classic": "^0.5.3",++ "napi-build-utils": "^2.0.0",++ "node-abi": "^3.3.0",++ "pump": "^3.0.0",++ "rc": "^1.2.7",++ "simple-get": "^4.0.0",++ "tar-fs": "^2.0.0",++ "tunnel-agent": "^0.6.0"++ },++ "bin": {++ "prebuild-install": "bin.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/pump": {++ "version": "3.0.4",++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",++ "license": "MIT",++ "dependencies": {++ "end-of-stream": "^1.1.0",++ "once": "^1.3.1"++ }++ },++ "node_modules/qs": {++ "version": "6.16.0",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/rc": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",++ "dependencies": {++ "deep-extend": "^0.6.0",++ "ini": "~1.3.0",++ "minimist": "^1.2.0",++ "strip-json-comments": "~2.0.1"++ },++ "bin": {++ "rc": "cli.js"++ }++ },++ "node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/simple-concat": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/simple-get": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "decompress-response": "^6.0.0",++ "once": "^1.3.1",++ "simple-concat": "^1.0.0"++ }++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/string_decoder": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "~5.2.0"++ }++ },++ "node_modules/strip-json-comments": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/tar-fs": {++ "version": "2.1.5",++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",++ "license": "MIT",++ "dependencies": {++ "chownr": "^1.1.1",++ "mkdirp-classic": "^0.5.2",++ "pump": "^3.0.0",++ "tar-stream": "^2.1.4"++ }++ },++ "node_modules/tar-stream": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",++ "license": "MIT",++ "dependencies": {++ "bl": "^4.0.3",++ "end-of-stream": "^1.4.1",++ "fs-constants": "^1.0.0",++ "inherits": "^2.0.3",++ "readable-stream": "^3.1.1"++ },++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/tunnel-agent": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ },++ "engines": {++ "node": "*"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/util-deprecate": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",++ "license": "MIT"++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/wrappy": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",++ "license": "ISC"++ }++ }++}nnew file mode 100644@@@ -0,0 +1,36 @@++{++ "name": "openvibe-vip",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.VIP — memberships on the OpenVibe network: plans, versioned plan terms, perks and gated-resource policy. Billing holds the entitlement truth; VIP holds plans and perks.",++ "main": "server/index.js",++ "exports": {++ ".": "./server/index.js",++ "./client": "./client/vip-client.js",++ "./package.json": "./package.json"++ },++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js",++ "subscribe": "node scripts/subscribe.js",++ "import-live": "node scripts/import-live.js"++ },++ "keywords": ["openvibe", "vip", "memberships", "entitlements"],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenVibe.VIP",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "jsonwebtoken": "^9.0.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.14.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"++ },++ "engines": {++ "node": ">=22.0.0"++ }++}nnew file mode 100644@@@ -0,0 +1,54 @@++/* OpenVibe.VIP — page styles. Colours follow the shared theme variables (theme-loader.js). */++:root {++ --vip-bg: var(--bg-primary, #0a0f1c);++ --vip-card: var(--bg-secondary, #121a2b);++ --vip-text: var(--text-primary, #e6edf7);++ --vip-muted: var(--text-secondary, #9aa7bd);++ --vip-accent: var(--accent, #3b82f6);++ --vip-on-accent: var(--on-accent, #ffffff);++ --vip-border: var(--border, rgba(255, 255, 255, .1));++ --vip-bad: #f87171;++ --vip-good: #34d399;++}++* { box-sizing: border-box; }++body { margin: 0; background: var(--vip-bg); color: var(--vip-text); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }++a { color: var(--vip-accent); }++.site-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; padding: 12px 16px; border-bottom: 1px solid var(--vip-border); }++.ov-has-navbar .site-head { display: none; }++.site-head .brand { font-weight: 700; color: inherit; text-decoration: none; }++.site-head nav { display: flex; gap: 14px; flex: 1; }++.site-head nav a { color: var(--vip-muted); text-decoration: none; }++.site-head nav a[aria-current] { color: var(--vip-text); }++.page { max-width: 880px; margin: 0 auto; padding: 24px 16px 48px; overflow-wrap: anywhere; }++h1 { font-size: 1.8rem; margin: .2em 0 .5em; }++h2 { font-size: 1.3rem; margin: .4em 0; }++h3 { font-size: 1.05rem; margin: .8em 0 .3em; }++.eyebrow { text-transform: uppercase; letter-spacing: .08em; font-size: .78rem; color: var(--vip-muted); margin: 0; }++.muted { color: var(--vip-muted); }++.small { font-size: .88rem; }++.hero p { font-size: 1.08rem; }++.card { background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 12px; padding: 16px 18px; margin: 16px 0; }++.card.narrow { max-width: 560px; margin: 32px auto; }++.button, button { display: inline-block; background: var(--vip-accent); color: var(--vip-on-accent); border: 0; border-radius: 8px; padding: 9px 16px; font: inherit; font-weight: 600; cursor: pointer; text-decoration: none; }++.button-quiet { background: transparent; color: var(--vip-accent); border: 1px solid var(--vip-accent); }++form label { display: block; margin: 10px 0; }++form input:not([type=checkbox]):not([type=hidden]), form textarea, form select {++ display: block; width: 100%; margin-top: 4px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--vip-border);++ background: var(--vip-bg); color: var(--vip-text); font: inherit; }++form label.check { display: flex; align-items: center; gap: 8px; }++form.inline { display: inline-block; margin: 4px 6px 4px 0; }++form.join { display: grid; gap: 4px; max-width: 420px; }++fieldset { border: 1px solid var(--vip-border); border-radius: 8px; margin: 12px 0; }++details > summary { cursor: pointer; font-weight: 600; margin: 6px 0; }++.notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }++.notice-error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .5); }++.pill { display: inline-block; border: 1px solid var(--vip-border); border-radius: 999px; padding: 0 10px; font-size: .85rem; color: var(--vip-muted); }++.pill-on { border-color: var(--vip-good); color: var(--vip-good); }++.price b { font-size: 1.15rem; }++.creator-list { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }++.creator-list a { display: flex; flex-direction: column; background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 10px; padding: 12px 14px; color: inherit; text-decoration: none; }++.perks, .benefits, .versions, .rules, .perk-admin { padding-left: 1.2em; }++.perk-admin { list-style: none; padding: 0; }++table.members { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }++table.members th, table.members td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--vip-border); }++code { background: rgba(255, 255, 255, .06); padding: 1px 5px; border-radius: 4px; }nnew file mode 100644@@@ -0,0 +1,18 @@++// OpenVibe.VIP — progressive touches only; every page and form works without this file.++(function () {++ 'use strict';++ document.documentElement.classList.add('js');++ document.addEventListener('DOMContentLoaded', function () {++ // Dashboard rule form: show the plan/perk pickers only for the requirement that uses them.++ var form = document.querySelector('form[action$="/rules"]');++ if (!form) return;++ var req = form.querySelector('select[name=requirement]');++ var plan = form.querySelector('select[name=plan_id]');++ var perk = form.querySelector('select[name=perk_key]');++ function sync() {++ if (plan) plan.closest('label').hidden = req.value !== 'plan';++ if (perk) perk.closest('label').hidden = req.value !== 'perk';++ }++ if (req) { req.addEventListener('change', sync); sync(); }++ });++})();nnew file mode 100644@@@ -0,0 +1,62 @@++#!/usr/bin/env node++'use strict';++/**++ * Import OpenVibe.Live's subscription offering into VIP (creators, the network subscriber-badge perk,++ * one "Channel subscription" plan per creator) and file OpenVibe.Billing's active subscriptions as++ * memberships under it. Live's own subscription rows are reconciled, never used as membership truth.++ *++ * node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]++ *++ * The Live database must be a COPY (`sqlite3 live.db ".backup live-snapshot.db"`); it is opened++ * read-only. Run it AFTER Billing's Live import. Memberships come from Billing's API with VIP's++ * client credentials (billing.entitlement.check), or from a read-only Billing snapshot with++ * --billing-db. Live user ids are resolved through the Network (identity.subject.resolve).++ * Exit code 1 when Billing could not be read for some creator (the report lists them). Safe to re-run.++ */++const path = require('path');++const Database = require('better-sqlite3');++const { loadConfig } = require('../server/config');++const { openDb } = require('../server/db');++const { createIdentity } = require('../server/network');++const { createBillingClient } = require('../server/billing-client');++const { createVipOutbox } = require('../server/events/outbox');++const { createDomain } = require('../server/domain');++const { importLive, billingApiSource, billingSnapshotSource } = require('../server/importer/live');++++const args = process.argv.slice(2);++const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };++const flag = (name) => args.includes(`--${name}`);++++async function main() {++ const livePath = opt('live-db');++ if (!livePath) { console.error('usage: import-live.js --live-db <snapshot> [--billing-db <snapshot>] [--dry-run] [--json]'); process.exit(2); }++ const config = loadConfig();++ for (const p of [livePath, opt('billing-db')].filter(Boolean)) if (path.resolve(p) === path.resolve(config.dbPath)) throw new Error(`${p} is the VIP database`);++ const live = new Database(livePath, { readonly: true, fileMustExist: true });++ const bdb = opt('billing-db') ? new Database(opt('billing-db'), { readonly: true, fileMustExist: true }) : null;++ const db = openDb(config.dbPath);++ const billing = createBillingClient(config);++ const outbox = createVipOutbox({ db, config });++ const domain = createDomain({ db, config, outbox, billing });++ const identity = createIdentity(config);++ const report = await importLive(domain, {++ live, billingSource: bdb ? billingSnapshotSource(bdb) : billingApiSource(billing), 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)' : ''} · billing source: ${report.billing_source}`);++ console.log(` live snapshot: ${JSON.stringify(c.live)}; offering ${JSON.stringify(report.live_offering)}`);++ console.log(` creators ${JSON.stringify(c.creators)}; plans ${JSON.stringify(c.plans)}`);++ console.log(` memberships ${JSON.stringify(c.memberships)}; live subscriptions ${JSON.stringify(c.live_subscriptions)}`);++ for (const h of report.holds) console.log(` held: ${JSON.stringify(h)}`);++ for (const x of report.excluded) console.log(` excluded: ${JSON.stringify(x)}`);++ for (const e of report.billing_errors) console.log(` billing error: ${JSON.stringify(e)}`);++ }++ live.close();++ if (bdb) bdb.close();++ db.close();++ if (!report.ok) process.exit(1);++}++++main().catch((err) => { console.error(`import failed: ${err.message}`); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,49 @@++#!/usr/bin/env node++'use strict';++/**++ * Create VIP's OpenVibe.Events subscriptions for Billing's membership-relevant events:++ *++ * billing.entitlement.* (billing.entitlement.changed)++ * billing.subscription.* (billing.subscription.canceled)++ * billing.transaction.reversed (refunds and chargebacks that revoke periods)++ *++ * node scripts/subscribe.js [--endpoint http://127.0.0.1:4620/internal/events]++ *++ * Reads the environment (.env or /etc/openvibe/vip.env): EVENTS_URL, OV_NETWORK_INTERNAL_URL,++ * OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the vip principal needs events.subscription.manage for++ * audience openvibe.events) and VIP_EVENTS_SECRET — the delivery signing secret handed to Events here,++ * so generate it first (`openssl rand -hex 32`). Nothing secret is printed. Existing identical++ * subscriptions are reported, not duplicated.++ */++require('dotenv').config();++const { serviceAuth } = require('openvibe-contracts');++const { loadConfig } = require('../server/config');++++const PATTERNS = ['billing.entitlement.*', 'billing.subscription.*', 'billing.transaction.reversed'];++const args = process.argv.slice(2);++const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : d; };++++(async () => {++ const config = loadConfig();++ const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);++ const secret = config.events.webhookSecrets[0];++ if (!config.events.url) throw new Error('EVENTS_URL is not set');++ if (!secret || secret.length < 32) throw new Error('VIP_EVENTS_SECRET must be set (32+ characters) before subscribing');++ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.events', scope: 'events.subscription.manage',++ });++ for (const pattern of PATTERNS) {++ const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),++ });++ const body = await res.json().catch(() => ({}));++ if (res.status === 409 && body.subscription_id) { console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`); continue; }++ if (!res.ok) throw new Error(`Events answered ${res.status} for ${pattern}: ${body.code || ''} ${body.detail || ''}`.trim());++ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint})`);++ }++ console.log('Replay history per subscription with POST /api/v1/deliveries/replay { subscription_id, from_seq }.');++})().catch((err) => { console.error(`subscribe failed: ${err.message}`); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++++/**++ * Who is calling /api/v1 — resolved into req.principal:++ *++ * { kind: 'service', sub: 'svc:live', cap: [...] }++ * A Network client-credentials token for audience openvibe.vip. Each route checks ONE++ * capability against the token's `cap` claim. The vip.* capabilities are proposals (not yet in++ * an openvibe-contracts release), so the grant is matched with contracts' capabilities.grants()++ * (exact id or a `.*` family) — the same interim rule Billing used before v0.8.0.++ * { kind: 'user', subject: 'usr_…', username, name, avatar, role }++ * A browser's Network user JWT, presented as `Authorization: Bearer`. Users act only on their++ * own things (their plans, perks, rules and members as a creator; their own memberships and++ * entitlement checks as a member). Staff roles (VIP_STAFF_ROLES) manage network plans. The API never++ * reads cookies, so a cross-site form cannot drive it; the SSR pages carry their own++ * anti-forgery tokens.++ * { kind: 'anonymous' }++ *++ * A request that presents a token is judged on that token alone: a bad one is refused, never++ * downgraded to anonymous.++ */++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod):/;++const ANON = Object.freeze({ kind: 'anonymous' });++++function decodePayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }++}++++/** A Network user token's claims → the user principal (null when it names no subject). */++function userPrincipal(claims) {++ if (!claims) return null;++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ if (!subject) return null;++ return {++ kind: 'user', subject, username: claims.username || null, name: claims.display_name || claims.username || null,++ avatar: claims.avatar_url || null, role: claims.role || 'user',++ };++}++++function createApiAuth({ config, keys, userAuth }) {++ function resolve(req) {++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return { principal: ANON };++ const token = header.slice(7).trim();++ const publicKey = keys.get();++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };++ const payload = decodePayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });++ if (!r.ok) return { error: [401, r.code, r.reason] };++ return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti } };++ }++ const claims = userAuth.verify(token);++ if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };++ const p = userPrincipal(claims);++ if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };++ return { principal: p };++ }++++ function middleware(req, res, next) {++ const r = resolve(req);++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });++ req.principal = r.principal;++ return next();++ }++++ const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);++ const isStaff = (p) => p.kind === 'user' && (config.staffRoles || []).includes(p.role);++++ return { middleware, resolve, granted, isStaff };++}++++module.exports = { createApiAuth, userPrincipal, decodePayload, PRINCIPAL_SUB };nnew file mode 100644@@@ -0,0 +1,344 @@++'use strict';++++/**++ * /api/v1 — VIP's API. Service tokens (audience openvibe.vip, one capability per route) and Network++ * user tokens (people acting on their own things). Errors are RFC 9457 problem+json.++ *++ * People are SubjectRefs ({ type: 'user', id: 'usr_…' }); a creator can also be named by its VIP++ * handle (username) or `network`. VIP never prices, charges, renews or refunds: checkout and++ * cancellation are handed to OpenVibe.Billing, and entitlement truth is Billing's.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { VipError, fail, userSubject, isUserSubject, entityRef, bool } = require('../util');++++const CAP = {++ planCreate: 'vip.plan.create',++ planUpdate: 'vip.plan.update',++ planArchive: 'vip.plan.archive',++ planList: 'vip.plan.list',++ perkCreate: 'vip.perk.create',++ perkUpdate: 'vip.perk.update',++ perkList: 'vip.perk.list',++ checkout: 'vip.membership.checkout',++ status: 'vip.membership.status',++ entitlement: 'vip.entitlement.check',++ policyGet: 'vip.resource.policy.get',++ policySet: 'vip.resource.policy.set',++ policyEvaluate: 'vip.resource.policy.evaluate',++ members: 'vip.creator.members.list',++};++++function v1Router({ domain, apiAuth }) {++ const r = express.Router();++ const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;++ const granted = apiAuth.granted;++ const isStaff = apiAuth.isStaff;++++ const denied = (cap) => fail(403, 'capability.denied', cap ? `${cap} not granted` : 'not allowed');++ const needAuth = (p) => { if (p.kind === 'anonymous') fail(401, 'auth.required', 'a Network service or user token is required'); };++++ /** A creator named by SubjectRef, usr_ id, username or 'network'. */++ function findCreator(ref) {++ if (ref === 'network') return creators.network();++ if (ref && typeof ref === 'object') return creators.bySubject(userSubject(ref, 'creator'));++ const s = String(ref || '');++ if (isUserSubject(s)) return creators.bySubject(s);++ return creators.byUsername(s);++ }++++ /**++ * The creator a write acts for. Users: themselves (created on first use), or `network` for staff.++ * Services (holding `cap`): the creator named in the body.++ */++ function actingCreator(req, ref, cap) {++ const p = req.principal;++ needAuth(p);++ if (p.kind === 'service') {++ if (!granted(p, cap)) denied(cap);++ if (ref === 'network') return creators.network();++ if (!ref) fail(422, 'vip.invalid_input', 'creator is required ({ type: "user", id } or "network")');++ return creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'service' });++ }++ if (ref === 'network') {++ if (!isStaff(p)) denied();++ return creators.network();++ }++ if (ref && (typeof ref === 'object' ? ref.id : ref) !== p.subject) {++ if (!isStaff(p)) fail(403, 'vip.not_yours', 'you can only manage your own plans');++ return creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'staff' });++ }++ return creators.ensure({ subject: p.subject, username: p.username, displayName: p.name, origin: 'self' });++ }++++ /** May the principal manage things owned by creator row `c`? */++ function mayManage(p, c, cap) {++ if (p.kind === 'service') return granted(p, cap);++ if (p.kind !== 'user' || !c) return false;++ if (c.kind === 'network') return isStaff(p);++ return c.subject === p.subject || isStaff(p);++ }++ const requireManage = (req, c, cap) => { needAuth(req.principal); if (!mayManage(req.principal, c, cap)) denied(req.principal.kind === 'service' ? cap : null); };++++ /** The subject a member-side call is about: users → themselves; services (with cap) → body/query. */++ function memberSubject(req, value, cap) {++ const p = req.principal;++ needAuth(p);++ if (p.kind === 'user') {++ if (value && (typeof value === 'object' ? value.id : value) !== p.subject) fail(403, 'vip.not_yours', 'you can only ask about yourself');++ return p.subject;++ }++ if (!granted(p, cap)) denied(cap);++ return userSubject(value, 'subject');++ }++++ const presentPlanFull = (plan) => ({ ...plans.present(plan), creator: creators.present(creators.byId(plan.creator_id)) });++ const trace = (req) => req.ov && req.ov.traceparent;++++ // ── Creators (public) ────────────────────────────────────++ r.get('/creators/:ref', wrap((req, res) => {++ const c = findCreator(req.params.ref);++ if (!c) fail(404, 'vip.creator_not_found', 'no such creator');++ const own = mayManage(req.principal, c, CAP.planList);++ res.json({++ creator: creators.present(c),++ plans: plans.list({ creatorId: c.id, includeDrafts: own }).map((p) => plans.present(p)),++ perks: perks.list({ creatorId: c.id, includeNetwork: false }).map((p) => perks.present(p)),++ });++ }));++++ // ── Plans ────────────────────────────────────────────────++ r.get('/plans', wrap((req, res) => {++ const c = findCreator(req.query.creator);++ if (!c) fail(404, 'vip.creator_not_found', 'creator must name a creator (usr_ id, username or network)');++ const include = String(req.query.include || '').split(',');++ const own = mayManage(req.principal, c, CAP.planList);++ const list = plans.list({ creatorId: c.id, includeDrafts: own && include.includes('drafts'), includeArchived: own && include.includes('archived') });++ res.json({ creator: creators.present(c), plans: list.map((p) => plans.present(p)) });++ }));++ const visiblePlan = (req, id) => {++ const plan = plans.byId(id);++ if (!plan) fail(404, 'vip.plan_not_found', `no plan ${id}`);++ if (plan.status !== 'published' && !mayManage(req.principal, creators.byId(plan.creator_id), CAP.planList)) fail(404, 'vip.plan_not_found', `no plan ${id}`);++ return plan;++ };++ r.get('/plans/:id', wrap((req, res) => res.json({ plan: presentPlanFull(visiblePlan(req, req.params.id)) })));++ r.get('/plans/:id/versions', wrap((req, res) => {++ const plan = visiblePlan(req, req.params.id);++ const own = mayManage(req.principal, creators.byId(plan.creator_id), CAP.planList);++ const all = plans.versions(plan.id).filter((v) => own || v.published_at).map(plans.presentVersion);++ res.json({ plan_id: plan.id, versions: all });++ }));++ r.post('/plans', wrap((req, res) => {++ const b = req.body || {};++ const c = actingCreator(req, b.creator, CAP.planCreate);++ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ const plan = plans.create({++ creatorId: c.id, slug: b.slug, name: b.name, description: b.description, benefits: b.benefits, perks: b.perks,++ billingKind: b.billing_kind, publish: bool(b.publish), changeNote: b.change_note, actor, traceparent: trace(req),++ });++ res.status(201).json({ plan: presentPlanFull(plan) });++ }));++ const managedPlan = (req, cap) => {++ const plan = plans.byId(req.params.id);++ if (!plan) fail(404, 'vip.plan_not_found', `no plan ${req.params.id}`);++ requireManage(req, creators.byId(plan.creator_id), cap);++ return plan;++ };++ r.patch('/plans/:id', wrap((req, res) => {++ const plan = managedPlan(req, CAP.planUpdate);++ const b = req.body || {};++ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ const out = plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });++ res.json({ plan: presentPlanFull(out.plan), version: plans.presentVersion(out.version), unchanged: out.unchanged });++ }));++ r.post('/plans/:id/publish', wrap((req, res) => {++ const plan = managedPlan(req, CAP.planUpdate);++ const out = plans.publish(plan.id, { traceparent: trace(req) });++ res.json({ plan: presentPlanFull(out.plan), replay: out.replay });++ }));++ r.post('/plans/:id/archive', wrap((req, res) => {++ const plan = managedPlan(req, CAP.planArchive);++ res.json({ plan: presentPlanFull(plans.archive(plan.id)) });++ }));++++ // ── Perks ────────────────────────────────────────────────++ r.get('/perks', wrap((req, res) => {++ const c = findCreator(req.query.creator);++ if (!c) fail(404, 'vip.creator_not_found', 'creator must name a creator (usr_ id, username or network)');++ const own = mayManage(req.principal, c, CAP.perkList);++ const list = perks.list({ creatorId: c.id, includeNetwork: bool(req.query.network, true), includeRetired: own && bool(req.query.retired) });++ res.json({ perks: list.map((p) => perks.present(p)) });++ }));++ r.post('/perks', wrap((req, res) => {++ const b = req.body || {};++ const c = actingCreator(req, b.creator, CAP.perkCreate);++ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ const p = perks.create({ creatorId: c.id, key: b.key, name: b.name, description: b.description, kind: b.kind, bindings: b.bindings, actor });++ res.status(201).json({ perk: perks.present(p) });++ }));++ r.patch('/perks/:id', wrap((req, res) => {++ const p = perks.byId(req.params.id);++ if (!p) fail(404, 'vip.perk_not_found', `no perk ${req.params.id}`);++ requireManage(req, creators.byId(p.creator_id), CAP.perkUpdate);++ const b = req.body || {};++ res.json({ perk: perks.present(perks.update(p.id, { name: b.name, description: b.description, kind: b.kind, status: b.status, bindings: b.bindings })) });++ }));++++ // ── Checkout hand-off (Billing) ──────────────────────────++ r.post('/checkout', wrap(async (req, res) => {++ const b = req.body || {};++ const member = memberSubject(req, b.subject, CAP.checkout);++ const out = await checkout.start({++ member, planId: b.plan_id, provider: b.provider, successUrl: b.success_url, cancelUrl: b.cancel_url,++ autoRenew: b.auto_renew === undefined ? true : bool(b.auto_renew), traceparent: trace(req),++ });++ res.status(201).json(out);++ }));++++ // ── Membership status ────────────────────────────────────++ r.get('/memberships/:subject', wrap(async (req, res) => {++ const member = memberSubject(req, req.params.subject, CAP.status);++ const mode = ['projection', 'authoritative'].includes(req.query.mode) ? req.query.mode : 'auto';++ const list = [];++ for (const m of memberships.forMember(member)) {++ const c = creators.byId(m.creator_id);++ const ent = c && c.subject ? await entitlements.check(member, c.subject, { mode, traceparent: trace(req) }) : null;++ list.push({ ...memberships.present(m), creator: creators.present(c), entitlement: ent, preferences: memberships.preferences(member, m.creator_id) });++ }++ res.json({ member: { type: 'user', id: member }, memberships: list });++ }));++ const creatorParam = (req) => {++ const c = findCreator(req.params.creator);++ if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');++ return c;++ };++ r.post('/memberships/:creator/cancel', wrap(async (req, res) => {++ if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');++ const c = creatorParam(req);++ res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req) }));++ }));++ r.put('/memberships/:creator/preferences', wrap((req, res) => {++ if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');++ const c = creatorParam(req);++ const b = req.body || {};++ res.json({ preferences: memberships.setPreferences(req.principal.subject, c.id, { showBadge: b.show_badge === undefined ? undefined : bool(b.show_badge), listed: b.listed === undefined ? undefined : bool(b.listed) }) });++ }));++++ // ── Entitlement check (projection + authoritative fallback) ──++ const entitlementCheck = wrap(async (req, res) => {++ const src = req.method === 'GET' ? req.query : (req.body || {});++ const member = memberSubject(req, src.subject, CAP.entitlement);++ // A creator VIP has never seen can still have a Billing entitlement: a usr_ id is asked as is.++ const raw = src.creator && typeof src.creator === 'object' ? src.creator.id : src.creator;++ let creatorSubject = isUserSubject(raw) ? raw : null;++ if (!creatorSubject) {++ const c = findCreator(raw);++ if (!c || !c.subject) fail(422, 'vip.invalid_input', 'creator must be a user SubjectRef, usr_ id or VIP username');++ creatorSubject = c.subject;++ }++ const mode = ['projection', 'authoritative'].includes(src.mode) ? src.mode : 'auto';++ res.json(await entitlements.check(member, creatorSubject, { mode, traceparent: trace(req) }));++ });++ r.get('/entitlements/check', entitlementCheck);++ r.post('/entitlements/check', entitlementCheck);++++ // ── Gated-resource policy ────────────────────────────────++ r.get('/policies', wrap((req, res) => {++ const p = req.principal;++ needAuth(p);++ if (req.query.service || req.query.type || req.query.id) {++ const resource = entityRef({ service: req.query.service, type: req.query.type, id: req.query.id });++ const rule = policies.forResource(resource);++ if (p.kind === 'service' && !granted(p, CAP.policyGet)) denied(CAP.policyGet);++ if (!rule || (p.kind === 'user' && !mayManage(p, creators.byId(rule.creator_id), CAP.policyGet))) fail(404, 'vip.rule_not_found', 'no rule for that resource');++ return res.json({ rule: policies.present(rule) });++ }++ const c = findCreator(req.query.creator || (p.kind === 'user' ? p.subject : null));++ if (!c) fail(404, 'vip.creator_not_found', 'no such creator');++ requireManage(req, c, CAP.policyGet);++ return res.json({ rules: policies.list(c.id).map(policies.present) });++ }));++ r.get('/policies/:id', wrap((req, res) => {++ const rule = policies.byId(req.params.id);++ if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);++ requireManage(req, creators.byId(rule.creator_id), CAP.policyGet);++ res.json({ rule: policies.present(rule) });++ }));++ r.post('/policies', wrap((req, res) => {++ const b = req.body || {};++ const c = actingCreator(req, b.creator, CAP.policySet);++ if (c.kind !== 'creator') fail(422, 'vip.invalid_input', 'gated resources belong to a creator');++ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ const rule = policies.set({ creatorId: c.id, resource: b.resource, requirement: b.requirement, planId: b.plan_id, perkKey: b.perk_key, sensitive: bool(b.sensitive), actor });++ res.status(201).json({ rule: policies.present(rule) });++ }));++ r.delete('/policies/:id', wrap((req, res) => {++ const rule = policies.byId(req.params.id);++ if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);++ requireManage(req, creators.byId(rule.creator_id), CAP.policySet);++ res.json({ rule: policies.present(policies.disable(rule.id)) });++ }));++ r.post('/policies/evaluate', wrap(async (req, res) => {++ const b = req.body || {};++ const p = req.principal;++ needAuth(p);++ let subject = null;++ if (p.kind === 'user') {++ if (b.subject && (typeof b.subject === 'object' ? b.subject.id : b.subject) !== p.subject) fail(403, 'vip.not_yours', 'you can only ask about yourself');++ subject = p.subject;++ } else {++ if (!granted(p, CAP.policyEvaluate)) denied(CAP.policyEvaluate);++ // No subject (a signed-out viewer) or a guest is a valid question; the answer is no.++ const s = b.subject && typeof b.subject === 'object' ? b.subject.id : b.subject;++ subject = s ? String(s) : null;++ }++ const out = await policies.evaluate({ subject, resource: b.resource, ruleId: b.rule_id, mode: b.mode === 'authoritative' ? 'authoritative' : 'auto', traceparent: trace(req) });++ res.json(out);++ }));++++ // ── Creator members ──────────────────────────────────────++ r.get('/creators/:ref/members', wrap(async (req, res) => {++ const c = findCreator(req.params.ref);++ if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');++ requireManage(req, c, CAP.members);++ const byMember = new Map(memberships.forCreator(c.id).map((m) => [m.member_subject, m]));++ const row = (subject, extra) => ({ member: { type: 'user', id: subject }, ...extra, membership: memberships.present(byMember.get(subject)) });++ try {++ // Authoritative: Billing's active subscriptions to this creator.++ const out = await billing.listSubscriptions({ streamer: c.subject, status: 'active' });++ const members = (out.subscriptions || []).map((s) => row(s.subscriber.id, {++ current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, subscription_id: s.id,++ }));++ return res.json({ creator: creators.present(c), source: 'billing', members });++ } catch {++ const members = entitlements.projectedMembers(c.subject).map((pr) => row(pr.member_subject, {++ current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, subscription_id: pr.subscription_id,++ }));++ return res.json({ creator: creators.present(c), source: 'projection', note: 'Billing did not answer; this list is VIP\'s projection (fresh rows only) and may be incomplete', members });++ }++ }));++++ return r;++}++++/** Async-safe handler: VipError → problem+json; anything else → 500. */++function wrap(fn) {++ return (req, res, next) => {++ try {++ const p = fn(req, res, next);++ if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));++ } catch (e) { sendError(req, res, e); }++ };++}++++function sendError(req, res, e) {++ if (res.headersSent) return;++ if (e instanceof VipError) {++ return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });++ }++ console.error('[VIP] unexpected error:', e);++ return http.sendProblem(res, 500, 'vip.internal', { detail: 'internal error', ctx: req.ov });++}++++module.exports = { v1Router, wrap, sendError, CAP };nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++++/**++ * OpenVibe.VIP — memberships: plans, versioned plan terms, perks, product bindings and gated-resource++ * policy. OpenVibe.Billing holds subscriptions, charges, renewals, refunds and the canonical++ * entitlements (ADR-012); VIP projects them and never authorizes past the projection's validity.++ * Express app factory; server/index.js listens, tests build their own instance.++ *++ * GET /api/health, /api/ready, /release.json, /metrics (loopback only)++ * /api/v1/* API (service tokens + user tokens, see api/v1.js)++ * POST /internal/events Billing events from OpenVibe.Events (signed webhook + inbox)++ * /auth/* Network SSO session for the pages++ * /, /me, /dashboard, /:username … server-rendered pages++ *++ * createApp({ config, db, keys, billing, outbox, now, fetchImpl, eventsFetch, log }) — all injectable.++ */++const path = require('path');++const express = require('express');++const cookieParser = require('cookie-parser');++const { http } = require('openvibe-contracts');++const { loadConfig } = require('./config');++const { openDb } = require('./db');++const { createKeyProvider, createUserAuth } = require('./network');++const { createBillingClient } = require('./billing-client');++const { createVipOutbox } = require('./events/outbox');++const { consumerRouter } = require('./events/consumer');++const { createDomain } = require('./domain');++const { createApiAuth } = require('./api/auth');++const { v1Router } = require('./api/v1');++const { createSessionRoutes } = require('./web/session');++const { createWebRoutes } = require('./web/routes');++const { createLayout, assetVersion } = require('./web/layout');++const { createVipReadiness } = require('./observability');++const pages = require('./web/pages');++++const VERSION = require('../package.json').version;++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++++function createApp(opts = {}) {++ const config = opts.config || loadConfig();++ const db = opts.db || openDb(config.dbPath);++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const log = opts.log || console;++ const now = opts.now || (() => Date.now());++ const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });++ const userAuth = createUserAuth(config, keys);++ const billing = opts.billing || createBillingClient(config, { fetchImpl });++ const outbox = opts.outbox || createVipOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });++ const domain = createDomain({ db, config, outbox, billing, now, log });++ const apiAuth = createApiAuth({ config, keys, userAuth });++ const release = require('openvibe-shared/release').createRelease({ service: 'vip', root: path.join(__dirname, '..') });++ const layout = createLayout({ config, release });++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ // HTTP golden signals by route template, process metrics, release_info; GET /metrics answers++ // direct loopback callers only (Track O). Gauges: outbox backlog and projection staleness.++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'vip', release: release.release });++ metrics.registry.gauge({ name: 'vip_outbox_pending', help: 'Events waiting in the outbox', collect: () => outbox.outbox.pending() });++ metrics.registry.gauge({++ name: 'vip_projection_rows', help: 'Entitlement projection rows by freshness', labelNames: ['state'],++ collect: () => {++ const t = now();++ const r = db.prepare('SELECT SUM(CASE WHEN valid_until >= ? THEN 1 ELSE 0 END) AS fresh, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t, t);++ return [{ labels: { state: 'fresh' }, value: r.fresh || 0 }, { labels: { state: 'stale' }, value: r.stale || 0 }];++ },++ });++ app.use(http.middleware());++ app.use((req, res, next) => {++ res.setHeader('X-Content-Type-Options', 'nosniff');++ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');++ res.setHeader('Content-Security-Policy', [++ "default-src 'self'", "script-src 'self' 'unsafe-inline' https://openvibe.network", "style-src 'self' 'unsafe-inline' https://openvibe.network",++ "img-src 'self' data: https:", "connect-src 'self' https://openvibe.network", "frame-src 'self' https://openvibe.network",++ "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self' https://openvibe.network https:",++ ].join('; '));++ next();++ });++ app.use(cookieParser());++++ app.get('/api/health', (req, res) => res.json({ ok: true, service: 'vip', version: VERSION, release: release.release, events: outbox.status() }));++ const readiness = createVipReadiness({ db, keys, config, outbox, now, release: release.release, fetchImpl });++ app.get('/api/ready', readiness.handler);++ app.get('/release.json', release.handler);++++ const consumer = consumerRouter({ domain, config, log });++ app.use('/internal', consumer.router);++ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth }));++ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }++++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'no-cache');++ },++ }));++ app.use(createWebRoutes({ domain, config, layout, userAuth }));++++ app.use((req, res) => {++ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });++ return res.status(404).type('html').send(layout.page({ title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) }));++ });++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, next) => {++ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });++ log.error('[VIP] unhandled error:', err);++ if (res.headersSent) return undefined;++ if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'vip.internal', { ctx: req.ov });++ return res.status(500).type('html').send(layout.page({ title: 'Error', robots: 'noindex', body: pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }) }));++ });++++ Object.assign(app.locals, { config, db, domain, keys, outbox, billing, consumer, metrics });++ return app;++}++++module.exports = { createApp, VERSION };nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++++/**++ * OpenVibe.Billing client — Billing holds subscriptions, charges, renewals, cancellations, refunds++ * and the canonical entitlements (ADR-012). VIP holds a client-credentials token for audience++ * openvibe.billing, one token per capability so a missing grant only disables what needs it:++ *++ * billing.intent.create POST /api/v1/intents checkout hand-off (kind subscription)++ * billing.subscription.manage POST /api/v1/subscriptions pay a period from the member's credit++ * POST /api/v1/subscriptions/:id/cancel cancel at period end++ * billing.entitlement.check GET /api/v1/entitlements/:subject?streamer= the authoritative check++ * GET /api/v1/subscriptions[?streamer&subscriber&status], /:id++ *++ * Every POST carries an Idempotency-Key derived from VIP's own record, so a retry after a lost++ * response returns Billing's original result instead of acting twice.++ *++ * Errors: a Billing problem+json becomes a BillingCallError with .status/.code; a network failure or++ * timeout has no status (retryable, and for an entitlement check it means "unknown", never "yes").++ */++const { serviceAuth } = require('openvibe-contracts');++++class BillingCallError extends Error {++ constructor(message, { status = null, code = null, body = null } = {}) {++ super(message);++ this.name = 'BillingCallError';++ this.status = status;++ this.code = code;++ this.body = body;++ }++ get retryable() { return this.status == null || this.status >= 500 || this.status === 429 || this.status === 401 || this.code === 'billing.frozen'; }++}++++const CAPS = {++ intent: 'billing.intent.create',++ subscription: 'billing.subscription.manage',++ entitlement: 'billing.entitlement.check',++};++++function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClients = null } = {}) {++ const base = config.billing.url;++ const clients = new Map();++ const tokensFor = (cap) => {++ if (tokenClients && tokenClients[cap]) return tokenClients[cap];++ if (!clients.has(cap)) {++ clients.set(cap, serviceAuth.createTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: config.billing.audience,++ scope: cap,++ fetchImpl,++ }));++ }++ return clients.get(cap);++ };++++ async function call(cap, method, path, { body, key, traceparent, retried = false } = {}) {++ let auth;++ try { auth = await tokensFor(cap).authHeaders(); } catch (e) { throw new BillingCallError(`token (${cap}): ${e.message}`); }++ const headers = { Accept: 'application/json', ...auth };++ if (body !== undefined) headers['Content-Type'] = 'application/json';++ if (key) headers['Idempotency-Key'] = key;++ if (traceparent) headers.traceparent = traceparent;++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, {++ method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(config.billing.timeoutMs),++ });++ } catch (e) {++ throw new BillingCallError(`Billing unreachable: ${e.message}`);++ }++ if (res.status === 401 && !retried) { tokensFor(cap).invalidate(); return call(cap, method, path, { body, key, traceparent, retried: true }); }++ const data = await res.json().catch(() => null);++ if (!res.ok) {++ const code = (data && (data.code || data.error)) || `http_${res.status}`;++ throw new BillingCallError(`Billing ${res.status} ${code}: ${(data && data.detail) || ''}`.trim(), { status: res.status, code, body: data });++ }++ return data;++ }++ const user = (id) => ({ type: 'user', id });++ const qs = (o) => { const p = new URLSearchParams(); for (const [k, v] of Object.entries(o)) if (v != null && v !== '') p.set(k, v); const s = p.toString(); return s ? `?${s}` : ''; };++++ return {++ /** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */++ createIntent: ({ provider, subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => call(CAPS.intent, 'POST', '/api/v1/intents', {++ body: { provider, kind: 'subscription', subject: user(subject), streamer: user(creator), auto_renew: !!autoRenew, success_url: successUrl, cancel_url: cancelUrl },++ key, traceparent,++ }),++ /** { subscription, entitlement, transaction } — one period paid from the member's credit. */++ subscribeWithCredit: ({ subscriber, creator, autoRenew, key, traceparent }) => call(CAPS.subscription, 'POST', '/api/v1/subscriptions', {++ body: { subscriber: user(subscriber), streamer: user(creator), source: 'credit', auto_renew: autoRenew !== false },++ key, traceparent,++ }),++ cancelSubscription: ({ id, key, traceparent }) => call(CAPS.subscription, 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent }),++ getSubscription: (id) => call(CAPS.entitlement, 'GET', `/api/v1/subscriptions/${encodeURIComponent(id)}`),++ listSubscriptions: ({ streamer, subscriber, status } = {}) => call(CAPS.entitlement, 'GET', `/api/v1/subscriptions${qs({ streamer, subscriber, status })}`),++ /** { active, expires_at, subscription } — the authoritative answer. */++ entitlement: (subject, creator) => call(CAPS.entitlement, 'GET', `/api/v1/entitlements/${encodeURIComponent(subject)}${qs({ streamer: creator })}`),++ rates: () => call(CAPS.entitlement, 'GET', '/api/v1/rates'),++ baseUrl: () => base,++ };++}++++module.exports = { createBillingClient, BillingCallError, CAPS };nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++++/**++ * OpenVibe.VIP configuration. Everything comes from the environment (.env in development,++ * /etc/openvibe/vip.env in production). loadConfig(env) is pure so tests build their own.++ *++ * VIP never prices anything and never moves money (ADR-012): prices, periods, charges, renewals,++ * cancellations and refunds are OpenVibe.Billing's. VIP holds plans, versioned plan terms, perks++ * and gated-resource policy.++ */++require('dotenv').config();++++const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };++const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));++const trim = (u) => String(u || '').replace(/\/+$/, '');++const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);++++function loadConfig(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 4620);++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.vip' : `http://localhost:${port}`));++ return {++ nodeEnv,++ isProduction,++ port,++ host: env.HOST || '127.0.0.1',++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,++ dbPath: env.VIP_DB_PATH || './data/vip.db',++++ // Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.++ network: {++ url: networkUrl,++ internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,++ },++ audience: env.VIP_AUDIENCE || 'openvibe.vip',++ // VIP's own client (client `vip` in the Network): service tokens for Billing, Events and the++ // Network (identity resolve, importer only), and the OAuth code flow for browser sign-in.++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'vip',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: env.OV_OAUTH_SCOPE || 'profile',++ },++ cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },++ // Signs the anti-forgery tokens of the server-rendered forms (required in production).++ formSecret: env.VIP_FORM_SECRET || (isProduction ? '' : 'dev-form-secret'),++ // Network roles that may manage network-wide plans and see any creator's members.++ staffRoles: list(env.VIP_STAFF_ROLES || 'admin'),++++ billing: {++ url: trim(env.BILLING_URL || 'http://127.0.0.1:4600'),++ audience: env.BILLING_AUDIENCE || 'openvibe.billing',++ timeoutMs: int(env.VIP_BILLING_TIMEOUT_MS, 5000),++ // Checkout providers offered on the plan pages (each must be enabled on Billing too).++ // `credit` pays from the member's Vibes credit through Billing's subscription API.++ providers: list(env.VIP_CHECKOUT_PROVIDERS || 'powerchat,credit'),++ // Billing answers a PowerChat intent with a checkout_ref (pcsub:…) instead of a URL. When++ // set, this template turns it into a link: {ref} and {cents} are filled in.++ powerchatLinkTemplate: env.VIP_POWERCHAT_LINK_TEMPLATE || '',++ ratesTtlMs: int(env.VIP_BILLING_RATES_TTL_MS, 5 * 60 * 1000),++ },++++ // The entitlement projection (a cache of Billing, never the truth). An answer is trusted for++ // at most maxAgeMs after VIP last heard from Billing about it, and never past the paid++ // period's end; up to graceMs later a projection-only check still answers (flagged stale);++ // after that VIP answers "unknown" until Billing confirms again.++ projection: {++ maxAgeMs: int(env.VIP_PROJECTION_MAX_AGE_MS, 15 * 60 * 1000),++ graceMs: int(env.VIP_PROJECTION_GRACE_MS, 60 * 1000),++ },++++ events: {++ url: trim(env.EVENTS_URL || ''),++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),++ // Signing secret(s) of VIP's OpenVibe.Events subscriptions (billing.entitlement.*,++ // billing.subscription.*, billing.transaction.reversed). Comma-separated for rotation.++ webhookSecrets: list(env.VIP_EVENTS_SECRET),++ },++++ jobs: {++ enabled: env.VIP_JOBS !== 'off',++ // Re-confirm, with Billing, projections that were used recently and are about to go stale.++ refreshIntervalMs: int(env.VIP_REFRESH_INTERVAL_MS, 60 * 1000),++ },++ liveUrl: trim(env.LIVE_URL || 'https://openvibe.live'),++ };++}++++module.exports = { loadConfig };nnew file mode 100644@@@ -0,0 +1,250 @@++'use strict';++++/**++ * VIP's own SQLite database (ADR-007: one database per service), WAL, created on boot, idempotent.++ *++ * Authority tables (the nine the charter names; VIP is their only writer):++ * vip_creators a creator (or the network) that offers memberships++ * vip_plans a plan: status, which Billing product it is sold as, current version++ * vip_plan_versions IMMUTABLE plan terms, one row per edit (triggers refuse UPDATE/DELETE++ * of anything but the one-time publish stamp)++ * vip_perks a perk definition (creator or network scope)++ * vip_plan_perks IMMUTABLE: which perks a plan VERSION includes (snapshot of key/name)++ * vip_product_bindings how a perk shows up in a product (live chat badge, chat room, …)++ * vip_member_preferences a member's preferences per creator (badge display, listing)++ * vip_gated_resource_rules "resource R in product P needs membership / perk K of creator C"++ * vip_migration_maps legacy source row → VIP target, with status and reason++ *++ * Operational tables (not authority for anything outside VIP):++ * vip_memberships which plan VERSION a membership was bought under (VIP's record of++ * terms; active/expired is never stored here — that is Billing's)++ * vip_entitlement_projection a cache of Billing's entitlements, fed by Billing events and direct++ * checks, with valid_until; never authorizes past it++ * vip_checkouts checkout hand-offs to Billing (which version the member chose)++ * event_outbox, idempotency_receipts openvibe-sdk outbox and inbox++ */++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++++const SCHEMA = `++CREATE TABLE IF NOT EXISTS settings (++ id INTEGER PRIMARY KEY CHECK (id = 1),++ created_at TEXT NOT NULL++);++++CREATE TABLE IF NOT EXISTS vip_creators (++ id TEXT PRIMARY KEY,++ kind TEXT NOT NULL CHECK (kind IN ('creator', 'network')),++ subject TEXT UNIQUE,++ username TEXT,++ username_lc TEXT UNIQUE,++ display_name TEXT,++ bio TEXT,++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')),++ origin TEXT NOT NULL DEFAULT 'self' CHECK (origin IN ('self', 'service', 'import', 'staff', 'system')),++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL,++ CHECK ((kind = 'network' AND subject IS NULL) OR (kind = 'creator' AND subject IS NOT NULL))++);++++CREATE TABLE IF NOT EXISTS vip_plans (++ id TEXT PRIMARY KEY,++ creator_id TEXT NOT NULL REFERENCES vip_creators(id),++ slug TEXT NOT NULL,++ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),++ billing_kind TEXT CHECK (billing_kind IS NULL OR billing_kind IN ('channel_subscription')),++ current_version_id TEXT,++ latest_version INTEGER NOT NULL DEFAULT 0,++ sort INTEGER NOT NULL DEFAULT 0,++ created_by TEXT,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL,++ archived_at TEXT,++ UNIQUE (creator_id, slug)++);++-- Billing sells one channel subscription per (subscriber, creator) today, so a creator can have at++-- most one published plan bound to it.++CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_plans_one_billing_product++ ON vip_plans (creator_id, billing_kind) WHERE status = 'published' AND billing_kind IS NOT NULL;++++CREATE TABLE IF NOT EXISTS vip_plan_versions (++ id TEXT PRIMARY KEY,++ plan_id TEXT NOT NULL REFERENCES vip_plans(id),++ version INTEGER NOT NULL,++ name TEXT NOT NULL,++ description TEXT,++ benefits TEXT NOT NULL DEFAULT '[]',++ terms TEXT NOT NULL,++ change_note TEXT,++ created_by TEXT,++ created_at TEXT NOT NULL,++ published_at TEXT,++ UNIQUE (plan_id, version)++);++CREATE TRIGGER IF NOT EXISTS vip_plan_versions_no_rewrite BEFORE UPDATE ON vip_plan_versions++WHEN NEW.id IS NOT OLD.id OR NEW.plan_id IS NOT OLD.plan_id OR NEW.version IS NOT OLD.version++ OR NEW.name IS NOT OLD.name OR NEW.description IS NOT OLD.description OR NEW.benefits IS NOT OLD.benefits++ OR NEW.terms IS NOT OLD.terms OR NEW.change_note IS NOT OLD.change_note OR NEW.created_by IS NOT OLD.created_by++ OR NEW.created_at IS NOT OLD.created_at OR (OLD.published_at IS NOT NULL AND NEW.published_at IS NOT OLD.published_at)++BEGIN SELECT RAISE(ABORT, 'vip_plan_versions are immutable: edit a plan by creating a new version'); END;++CREATE TRIGGER IF NOT EXISTS vip_plan_versions_no_delete BEFORE DELETE ON vip_plan_versions++BEGIN SELECT RAISE(ABORT, 'vip_plan_versions are immutable: edit a plan by creating a new version'); END;++++CREATE TABLE IF NOT EXISTS vip_perks (++ id TEXT PRIMARY KEY,++ creator_id TEXT NOT NULL REFERENCES vip_creators(id),++ key TEXT NOT NULL,++ name TEXT NOT NULL,++ description TEXT,++ kind TEXT NOT NULL DEFAULT 'other' CHECK (kind IN ('badge', 'emote', 'gated_content', 'room', 'role', 'other')),++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),++ created_by TEXT,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL,++ UNIQUE (creator_id, key)++);++++CREATE TABLE IF NOT EXISTS vip_plan_perks (++ plan_version_id TEXT NOT NULL REFERENCES vip_plan_versions(id),++ perk_id TEXT NOT NULL REFERENCES vip_perks(id),++ perk_key TEXT NOT NULL,++ perk_name TEXT NOT NULL,++ position INTEGER NOT NULL DEFAULT 0,++ PRIMARY KEY (plan_version_id, perk_id)++);++CREATE INDEX IF NOT EXISTS idx_vip_plan_perks_key ON vip_plan_perks (plan_version_id, perk_key);++CREATE TRIGGER IF NOT EXISTS vip_plan_perks_no_update BEFORE UPDATE ON vip_plan_perks++BEGIN SELECT RAISE(ABORT, 'vip_plan_perks belong to an immutable plan version'); END;++CREATE TRIGGER IF NOT EXISTS vip_plan_perks_no_delete BEFORE DELETE ON vip_plan_perks++BEGIN SELECT RAISE(ABORT, 'vip_plan_perks belong to an immutable plan version'); END;++++CREATE TABLE IF NOT EXISTS vip_product_bindings (++ id TEXT PRIMARY KEY,++ perk_id TEXT NOT NULL REFERENCES vip_perks(id),++ product TEXT NOT NULL,++ binding TEXT NOT NULL,++ config TEXT NOT NULL DEFAULT '{}',++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'removed')),++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL,++ UNIQUE (perk_id, product, binding)++);++++CREATE TABLE IF NOT EXISTS vip_member_preferences (++ member_subject TEXT NOT NULL,++ creator_id TEXT NOT NULL REFERENCES vip_creators(id),++ show_badge INTEGER NOT NULL DEFAULT 1,++ listed INTEGER NOT NULL DEFAULT 0,++ updated_at TEXT NOT NULL,++ PRIMARY KEY (member_subject, creator_id)++);++++CREATE TABLE IF NOT EXISTS vip_gated_resource_rules (++ id TEXT PRIMARY KEY,++ creator_id TEXT NOT NULL REFERENCES vip_creators(id),++ resource_service TEXT NOT NULL,++ resource_type TEXT NOT NULL,++ resource_id TEXT NOT NULL,++ requirement TEXT NOT NULL CHECK (requirement IN ('member', 'plan', 'perk')),++ plan_id TEXT REFERENCES vip_plans(id),++ perk_key TEXT,++ sensitive INTEGER NOT NULL DEFAULT 0,++ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),++ created_by TEXT,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL,++ CHECK (requirement <> 'plan' OR plan_id IS NOT NULL),++ CHECK (requirement <> 'perk' OR perk_key IS NOT NULL)++);++CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_rules_resource++ ON vip_gated_resource_rules (resource_service, resource_type, resource_id) WHERE status = 'active';++++CREATE TABLE IF NOT EXISTS vip_migration_maps (++ source TEXT NOT NULL,++ source_table TEXT NOT NULL,++ source_id TEXT NOT NULL,++ target_type TEXT,++ target_id TEXT,++ status TEXT NOT NULL CHECK (status IN ('imported', 'linked', 'held', 'excluded')),++ reason TEXT,++ run_id TEXT,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL,++ PRIMARY KEY (source, source_table, source_id)++);++++CREATE TABLE IF NOT EXISTS vip_memberships (++ id TEXT PRIMARY KEY,++ member_subject TEXT NOT NULL,++ creator_id TEXT NOT NULL REFERENCES vip_creators(id),++ plan_id TEXT REFERENCES vip_plans(id),++ plan_version_id TEXT REFERENCES vip_plan_versions(id),++ billing_subscription_id TEXT,++ origin TEXT NOT NULL CHECK (origin IN ('checkout', 'billing', 'import')),++ joined_at TEXT NOT NULL,++ terms_since TEXT NOT NULL,++ updated_at TEXT NOT NULL,++ UNIQUE (member_subject, creator_id)++);++CREATE INDEX IF NOT EXISTS idx_vip_memberships_creator ON vip_memberships (creator_id);++++CREATE TABLE IF NOT EXISTS vip_entitlement_projection (++ member_subject TEXT NOT NULL,++ creator_subject TEXT NOT NULL,++ kind TEXT NOT NULL,++ active INTEGER NOT NULL,++ expires_at TEXT,++ cancel_at_period_end INTEGER NOT NULL DEFAULT 0,++ subscription_id TEXT,++ subscription_status TEXT,++ source TEXT NOT NULL CHECK (source IN ('event', 'billing_check', 'import')),++ last_reason TEXT,++ billing_event_id TEXT,++ billing_as_of INTEGER NOT NULL,++ synced_at INTEGER NOT NULL,++ valid_until INTEGER NOT NULL,++ last_used_at INTEGER,++ PRIMARY KEY (member_subject, creator_subject, kind)++);++CREATE INDEX IF NOT EXISTS idx_vip_projection_creator ON vip_entitlement_projection (creator_subject, kind, active);++++CREATE TABLE IF NOT EXISTS vip_checkouts (++ id TEXT PRIMARY KEY,++ member_subject TEXT NOT NULL,++ creator_id TEXT NOT NULL REFERENCES vip_creators(id),++ plan_id TEXT NOT NULL REFERENCES vip_plans(id),++ plan_version_id TEXT NOT NULL REFERENCES vip_plan_versions(id),++ provider TEXT NOT NULL,++ status TEXT NOT NULL CHECK (status IN ('created', 'handed_off', 'paid', 'failed', 'used')),++ billing_intent_id TEXT,++ billing_subscription_id TEXT,++ checkout_url TEXT,++ checkout_ref TEXT,++ error TEXT,++ created_at TEXT NOT NULL,++ updated_at TEXT NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_vip_checkouts_member ON vip_checkouts (member_subject, creator_id, created_at);++`;++++function openDb(dbPath) {++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });++ const db = new Database(dbPath);++ db.pragma('journal_mode = WAL');++ db.pragma('foreign_keys = ON');++ db.pragma('busy_timeout = 5000');++ migrate(db);++ return db;++}++++function migrate(db) {++ db.exec(SCHEMA);++ const now = new Date().toISOString();++ db.prepare('INSERT OR IGNORE INTO settings (id, created_at) VALUES (1, ?)').run(now);++ // The network itself offers network-wide plans and owns network perks.++ db.prepare(`INSERT OR IGNORE INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)++ VALUES ('network', 'network', NULL, NULL, NULL, 'OpenVibe', 'system', ?, ?)`).run(now, now);++}++++module.exports = { openDb, migrate };nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++++/**++ * Checkout hand-off and cancellation — both done BY Billing (ADR-012); VIP only records which plan++ * version the member chose so the membership Billing grants is filed under those terms.++ *++ * start() provider 'credit' Billing POST /subscriptions (source credit): the period is paid++ * from the member's Vibes credit at once++ * any other provider Billing POST /intents (kind subscription): Billing returns the++ * provider checkout (a URL, or a PowerChat checkout_ref); the++ * membership starts when Billing's settlement event arrives++ * cancel() Billing POST /subscriptions/:id/cancel (at period end)++ *++ * Both first ask Billing directly (a sensitive action never trusts the projection). Idempotency++ * keys come from VIP's own ids, so a retried call returns Billing's first answer.++ */++const { fail, prefixedId, iso } = require('../util');++const { BillingCallError } = require('../billing-client');++++function billingProblem(err) {++ if (!(err instanceof BillingCallError)) throw err;++ const code = err.code || '';++ if (code === 'billing.frozen') fail(503, 'vip.billing_frozen', 'payments are paused right now; please try again later');++ if (code === 'billing.insufficient_funds') fail(409, 'vip.checkout.insufficient_credit', 'your Vibes credit does not cover this membership');++ if (code === 'billing.provider_disabled' || code === 'billing.invalid_input') fail(409, 'vip.checkout.provider_unavailable', 'that payment method is not available right now');++ if (code === 'billing.self_dealing') fail(422, 'vip.checkout.self', 'you cannot join your own plan');++ if (err.status === 403 || err.status === 401) fail(503, 'vip.billing_unavailable', 'VIP is not allowed to reach Billing for this yet');++ fail(503, 'vip.billing_unavailable', 'Billing is not answering right now; nothing was charged');++}++++function createCheckout({ db, now, config, billing, creators, plans, entitlements }) {++ const byId = (id) => db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;++++ async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent }) {++ const plan = plans.byId(String(planId || ''));++ if (!plan || plan.status !== 'published') fail(404, 'vip.plan_not_found', 'no published plan with that id');++ if (!plan.billing_kind) fail(409, 'vip.checkout.unavailable', 'this plan is not sold through Billing yet');++ const creator = creators.byId(plan.creator_id);++ if (!creator || creator.status !== 'active' || !creator.subject) fail(409, 'vip.checkout.unavailable', 'this creator is not taking new members');++ if (creator.subject === member) fail(422, 'vip.checkout.self', 'you cannot join your own plan');++ const p = String(provider || '').toLowerCase();++ if (!config.billing.providers.includes(p)) fail(422, 'vip.checkout.provider_unavailable', `provider must be one of ${config.billing.providers.join(', ') || '(none configured)'}`);++++ // Sensitive: ask Billing, not the projection. An active member renews through Billing, not here.++ let current;++ try { current = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }++ if (current && current.active && current.expires_at && Date.parse(current.expires_at) > now()) {++ fail(409, 'vip.already_member', 'you are already a member; your membership renews through Billing');++ }++++ const at = iso(now());++ const id = prefixedId('vco', now());++ db.prepare(`INSERT INTO vip_checkouts (id, member_subject, creator_id, plan_id, plan_version_id, provider, status, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, 'created', ?, ?)`).run(id, member, creator.id, plan.id, plan.current_version_id, p, at, at);++ const mark = (fields) => {++ const cols = Object.keys(fields);++ db.prepare(`UPDATE vip_checkouts SET ${cols.map((c) => `${c} = @${c}`).join(', ')}, updated_at = @updated_at WHERE id = @id`).run({ ...fields, updated_at: iso(now()), id });++ };++++ if (p === 'credit') {++ let out;++ try {++ out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, autoRenew, key: `vip:checkout:${id}`, traceparent });++ } catch (e) { mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }++ const sub = out.subscription || {};++ const ent = out.entitlement || {};++ mark({ status: 'paid', billing_subscription_id: sub.id || null });++ entitlements.apply({++ member, creator: creator.subject, active: !!ent.active, expiresAt: ent.expires_at || sub.current_period_end || null,++ cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id || null, subscriptionStatus: sub.status || null,++ reason: 'granted', source: 'billing_check', asOf: now(), checkoutId: id, traceparent,++ });++ return { checkout: present(byId(id)), membership_started: !!ent.active, checkout_url: null, checkout_ref: null };++ }++++ let out;++ try {++ out = await billing.createIntent({++ provider: p, subject: member, creator: creator.subject, autoRenew,++ successUrl: successUrl || `${config.baseUrl}/me?joined=${encodeURIComponent(creator.username || '')}`,++ cancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,++ key: `vip:checkout:${id}`, traceparent,++ });++ } catch (e) { mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }++ const intent = out.intent || {};++ let url = out.checkout_url || null;++ if (!url && intent.checkout_ref && config.billing.powerchatLinkTemplate) {++ url = config.billing.powerchatLinkTemplate.replace('{ref}', encodeURIComponent(intent.checkout_ref)).replace('{cents}', String(intent.amount_cents || ''));++ }++ // Only ever send a member to an http(s) page (never javascript:/data: from a bad answer).++ if (url && !/^https?:\/\//i.test(url)) url = null;++ mark({ status: 'handed_off', billing_intent_id: intent.id || null, checkout_url: url, checkout_ref: intent.checkout_ref || null });++ return { checkout: present(byId(id)), membership_started: false, checkout_url: url, checkout_ref: intent.checkout_ref || null, amount_cents: intent.amount_cents || null };++ }++++ /** Cancel at period end, through Billing. Only the member themself. */++ async function cancel({ member, creatorId, traceparent }) {++ const creator = creators.byId(creatorId);++ if (!creator || !creator.subject) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);++ let row;++ try { row = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }++ if (!row || !row.active || !row.subscription_id) fail(404, 'vip.membership_not_found', 'there is no active membership to cancel');++ if (row.cancel_at_period_end) return { already: true, expires_at: row.expires_at };++ let sub;++ try {++ const s = await billing.getSubscription(row.subscription_id);++ sub = s.subscription;++ } catch (e) { billingProblem(e); }++ if (!sub || !sub.subscriber || sub.subscriber.id !== member) fail(403, 'vip.not_yours', 'that membership is not yours');++ let out;++ try {++ out = await billing.cancelSubscription({ id: sub.id, key: `vip:cancel:${sub.id}:${sub.current_period_end || 'open'}`, traceparent });++ } catch (e) { billingProblem(e); }++ const s = (out && out.subscription) || {};++ entitlements.applyCanceled({++ member, creator: creator.subject, subscriptionId: s.id || sub.id, subscriptionStatus: s.status || 'active',++ currentPeriodEnd: s.current_period_end || null, asOf: now(), traceparent, source: 'billing_check',++ });++ return { already: false, expires_at: row.expires_at, provider_sync: out && out.provider_sync };++ }++++ function present(c) {++ if (!c) return null;++ return {++ id: c.id, member: { type: 'user', id: c.member_subject }, creator_id: c.creator_id, plan_id: c.plan_id, plan_version_id: c.plan_version_id,++ provider: c.provider, status: c.status, billing_intent_id: c.billing_intent_id, billing_subscription_id: c.billing_subscription_id,++ checkout_url: c.checkout_url, checkout_ref: c.checkout_ref, created_at: c.created_at,++ };++ }++++ return { start, cancel, byId, present };++}++++module.exports = { createCheckout, billingProblem };nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++++/**++ * vip_creators: who offers memberships. A creator is a canonical Network user subject (never a++ * product's integer id); the row's username is a cached handle for the public page /:username,++ * refreshed whenever the creator signs in. The network itself is the creator `network` (staff++ * manage its plans and network-wide perks).++ */++const { fail, prefixedId, iso, text } = require('../util');++++const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;++++function createCreators({ db, now }) {++ const byId = (id) => db.prepare('SELECT * FROM vip_creators WHERE id = ?').get(id) || null;++ const bySubject = (s) => db.prepare('SELECT * FROM vip_creators WHERE subject = ?').get(s) || null;++ const byUsername = (u) => (u ? db.prepare('SELECT * FROM vip_creators WHERE username_lc = ?').get(String(u).toLowerCase()) || null : null);++ const network = () => byId('network');++++ /**++ * The creator row for a subject, created on first use. username/displayName (from the creator's++ * own Network token, the importer or the identity service) refresh the cached handle; a handle++ * another row still holds (a rename) is taken over, the stale row keeps no handle.++ */++ function ensure({ subject, username = null, displayName = null, origin = 'self' }) {++ if (!subject) fail(422, 'vip.invalid_subject', 'a creator needs a subject');++ const at = iso(now());++ const handle = username && USERNAME_RE.test(String(username)) ? String(username) : null;++ let row = bySubject(subject);++ if (handle) {++ const holder = byUsername(handle);++ if (holder && holder.subject !== subject) db.prepare('UPDATE vip_creators SET username = NULL, username_lc = NULL, updated_at = ? WHERE id = ?').run(at, holder.id);++ }++ if (!row) {++ const id = prefixedId('vcr', now());++ db.prepare(`INSERT INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)++ VALUES (?, 'creator', ?, ?, ?, ?, ?, ?, ?)`).run(id, subject, handle, handle ? handle.toLowerCase() : null, displayName ? String(displayName).slice(0, 80) : null, origin, at, at);++ return byId(id);++ }++ const changes = {};++ if (handle && handle !== row.username) changes.username = handle;++ if (displayName && String(displayName).slice(0, 80) !== row.display_name) changes.display_name = String(displayName).slice(0, 80);++ if (Object.keys(changes).length) {++ db.prepare(`UPDATE vip_creators SET username = COALESCE(?, username), username_lc = COALESCE(?, username_lc), display_name = COALESCE(?, display_name), updated_at = ? WHERE id = ?`)++ .run(changes.username || null, changes.username ? changes.username.toLowerCase() : null, changes.display_name || null, at, row.id);++ row = byId(row.id);++ }++ return row;++ }++++ function update(id, { displayName, bio }) {++ const row = byId(id);++ if (!row) fail(404, 'vip.creator_not_found', `no creator ${id}`);++ const name = displayName !== undefined ? text(displayName, 'display_name', 80) : row.display_name;++ const about = bio !== undefined ? text(bio, 'bio', 2000) : row.bio;++ db.prepare('UPDATE vip_creators SET display_name = ?, bio = ?, updated_at = ? WHERE id = ?').run(name, about, iso(now()), id);++ return byId(id);++ }++++ /** Creators with at least one published plan, for the directory and the sitemap. */++ function listPublic({ limit = 200 } = {}) {++ return db.prepare(`SELECT c.* FROM vip_creators c WHERE c.status = 'active' AND c.kind = 'creator' AND c.username IS NOT NULL++ AND EXISTS (SELECT 1 FROM vip_plans p WHERE p.creator_id = c.id AND p.status = 'published')++ ORDER BY COALESCE(c.display_name, c.username) COLLATE NOCASE LIMIT ?`).all(limit);++ }++++ function present(c) {++ if (!c) return null;++ return {++ id: c.id, kind: c.kind, subject: c.subject ? { type: 'user', id: c.subject } : null,++ username: c.username, display_name: c.display_name || c.username || (c.kind === 'network' ? 'OpenVibe' : null),++ bio: c.bio || null, status: c.status,++ };++ }++++ return { byId, bySubject, byUsername, network, ensure, update, listPublic, present };++}++++module.exports = { createCreators };nnew file mode 100644@@@ -0,0 +1,201 @@++'use strict';++++/**++ * The entitlement projection: a short-lived cache of OpenVibe.Billing's entitlements (Billing holds++ * the truth, ADR-012), fed by Billing's events (billing.entitlement.changed,++ * billing.subscription.canceled, billing.transaction.reversed) and by direct authoritative checks.++ *++ * A stale projection can never authorize indefinitely:++ * valid_until = min(paid period end, last word from Billing + maxAge) (a "no" is kept maxAge)++ * now <= valid_until the projection answers++ * valid_until < now <= valid_until+grace mode 'projection' answers, flagged stale; mode 'auto' asks++ * Billing first and only falls back to the stale answer when++ * Billing cannot be reached++ * later "unknown" (active: false) until Billing confirms again++ * Mode 'authoritative' (sensitive actions) always asks Billing and answers "unknown" when it cannot.++ *++ * Ordering: every row remembers the Billing time it reflects (billing_as_of). An older event that++ * would grant is ignored; an older event that would revoke puts the row in doubt (valid_until = 0),++ * so the next check goes to Billing — out-of-order delivery can deny briefly, never grant wrongly.++ */++const { iso, userRef } = require('../util');++++const KIND = 'channel_subscription';++++function createEntitlements({ db, now, config, billing, outbox, memberships, plans, log = console }) {++ const { maxAgeMs, graceMs } = config.projection;++ const getRow = (member, creator, kind = KIND) => db.prepare('SELECT * FROM vip_entitlement_projection WHERE member_subject = ? AND creator_subject = ? AND kind = ?').get(member, creator, kind) || null;++++ function validUntil(at, active, expiresAt) {++ const cap = at + maxAgeMs;++ if (!active) return cap;++ const end = expiresAt ? Date.parse(expiresAt) : NaN;++ return Number.isFinite(end) ? Math.min(cap, end) : at;++ }++++ function emitChanged(row, prev, { reason, membership }, traceparent) {++ const v = membership && membership.plan_version_id ? plans.version(membership.plan_version_id) : null;++ outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {++ member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,++ active: !!row.active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,++ previous: prev ? { active: !!prev.active, expires_at: prev.expires_at, cancel_at_period_end: !!prev.cancel_at_period_end } : null,++ reason: reason || null, source: row.source, billing_event_id: row.billing_event_id || null,++ membership_id: membership ? membership.id : null, plan_id: membership ? membership.plan_id : null,++ plan_version_id: membership ? membership.plan_version_id : null, plan_version: v ? v.version : null,++ subscription_id: row.subscription_id || null,++ }, { traceparent });++ }++++ /**++ * Apply what Billing said about (member, creator). Runs in a transaction (joins the caller's).++ * input: { member, creator, kind?, active, expiresAt, cancelAtPeriodEnd?, subscriptionId?,++ * subscriptionStatus?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),++ * origin?, checkoutId?, traceparent? }++ */++ function apply(input) {++ return db.transaction(() => {++ const kind = input.kind || KIND;++ const prev = getRow(input.member, input.creator, kind);++ const at = now();++ if (prev && input.asOf < prev.billing_as_of) {++ if (!input.active && prev.active) {++ db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(input.member, input.creator, kind);++ return { outcome: 'doubt', changed: false };++ }++ return { outcome: 'stale_ignored', changed: false };++ }++ const next = {++ member_subject: input.member, creator_subject: input.creator, kind,++ active: input.active ? 1 : 0, expires_at: input.expiresAt || null,++ cancel_at_period_end: input.cancelAtPeriodEnd !== undefined ? (input.cancelAtPeriodEnd ? 1 : 0) : (prev ? prev.cancel_at_period_end : 0),++ subscription_id: input.subscriptionId !== undefined ? input.subscriptionId : (prev ? prev.subscription_id : null),++ subscription_status: input.subscriptionStatus !== undefined ? input.subscriptionStatus : (prev ? prev.subscription_status : null),++ source: input.source, last_reason: input.reason || null, billing_event_id: input.eventId || null,++ billing_as_of: input.asOf, synced_at: at, valid_until: validUntil(at, input.active, input.expiresAt),++ last_used_at: prev ? prev.last_used_at : null,++ };++ db.prepare(`INSERT INTO vip_entitlement_projection (member_subject, creator_subject, kind, active, expires_at, cancel_at_period_end, subscription_id,++ subscription_status, source, last_reason, billing_event_id, billing_as_of, synced_at, valid_until, last_used_at)++ VALUES (@member_subject, @creator_subject, @kind, @active, @expires_at, @cancel_at_period_end, @subscription_id, @subscription_status,++ @source, @last_reason, @billing_event_id, @billing_as_of, @synced_at, @valid_until, @last_used_at)++ ON CONFLICT (member_subject, creator_subject, kind) DO UPDATE SET active = excluded.active, expires_at = excluded.expires_at,++ cancel_at_period_end = excluded.cancel_at_period_end, subscription_id = excluded.subscription_id,++ subscription_status = excluded.subscription_status, source = excluded.source, last_reason = excluded.last_reason,++ billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,++ valid_until = excluded.valid_until`).run(next);++ const { membership } = memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });++ // A first look that finds no membership is not a change anyone needs to hear about.++ const changed = prev++ ? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end)++ : !!next.active;++ if (changed) emitChanged(next, prev, { reason: input.reason, membership }, input.traceparent);++ return { outcome: changed ? 'changed' : 'unchanged', changed, row: getRow(input.member, input.creator, kind), membership };++ })();++ }++++ /** A cancellation scheduled at Billing: the period still runs; only the flag changes. */++ function applyCanceled({ member, creator, subscriptionId, subscriptionStatus, currentPeriodEnd, eventId, asOf, traceparent, source = 'event' }) {++ return db.transaction(() => {++ const prev = getRow(member, creator);++ if (!prev) return { outcome: 'no_projection', changed: false };++ if (asOf < prev.billing_as_of) return { outcome: 'stale_ignored', changed: false };++ const active = subscriptionStatus === 'active' ? !!prev.active : false;++ return apply({++ member, creator, active, expiresAt: active ? (prev.expires_at || currentPeriodEnd) : prev.expires_at, cancelAtPeriodEnd: true,++ subscriptionId, subscriptionStatus, reason: 'cancel_scheduled', source, eventId, asOf, traceparent,++ });++ })();++ }++++ /** Something happened that Billing will describe in detail (a refund): stop trusting the row. */++ function doubt(member, creator, kind = KIND) {++ return db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(member, creator, kind).changes > 0;++ }++++ function answer(row, source, { stale = false, membership } = {}) {++ const t = now();++ const active = !!row.active && !!row.expires_at && Date.parse(row.expires_at) > t;++ return {++ member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,++ status: active ? 'active' : 'inactive', active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,++ source, stale, valid_until: iso(Math.max(0, row.valid_until)), checked_at: iso(t),++ membership: membership ? memberships.present(membership) : null,++ };++ }++ function unknown(member, creator, reason) {++ return {++ member: userRef(member), creator: userRef(creator), kind: KIND, status: 'unknown', active: false, expires_at: null,++ cancel_at_period_end: false, source: 'none', stale: false, valid_until: null, checked_at: iso(now()), reason, membership: null,++ };++ }++++ const membershipOf = (member, creatorSubject) => {++ const row = db.prepare('SELECT m.* FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?').get(member, creatorSubject);++ return row || null;++ };++++ /** Ask Billing and project the answer. Throws BillingCallError when Billing cannot answer. */++ async function authoritative(member, creator, { traceparent } = {}) {++ const e = await billing.entitlement(member, creator);++ const sub = e && e.subscription;++ const out = apply({++ member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,++ cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,++ subscriptionStatus: sub ? sub.status : undefined, reason: 'checked', source: 'billing_check', asOf: now(), traceparent,++ });++ return out.row || getRow(member, creator);++ }++++ /**++ * check(member, creator, { mode: 'auto' | 'projection' | 'authoritative' }) → answer.++ * `active` is true only for status 'active'; 'unknown' never authorizes.++ */++ async function check(member, creator, { mode = 'auto', traceparent } = {}) {++ if (member === creator) return { ...unknown(member, creator, 'self'), status: 'inactive', reason: 'a creator is not a member of their own plans' };++ const t = now();++ let row = getRow(member, creator);++ if (row) db.prepare('UPDATE vip_entitlement_projection SET last_used_at = ? WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(t, member, creator, KIND);++ const fresh = row && t <= row.valid_until;++ const withinGrace = row && t <= row.valid_until + graceMs;++ if (mode === 'projection') {++ if (fresh) return answer(row, 'projection', { membership: membershipOf(member, creator) });++ if (withinGrace) return answer(row, 'projection', { stale: true, membership: membershipOf(member, creator) });++ return unknown(member, creator, row ? 'projection_expired' : 'no_projection');++ }++ if (mode === 'auto' && fresh) return answer(row, 'projection', { membership: membershipOf(member, creator) });++ try {++ row = await authoritative(member, creator, { traceparent });++ return answer(row, 'billing', { membership: membershipOf(member, creator) });++ } catch (err) {++ log.warn(`[VIP] authoritative entitlement check failed (${member} → ${creator}): ${err.message}`);++ if (mode === 'auto' && withinGrace) return { ...answer(row, 'projection', { stale: true, membership: membershipOf(member, creator) }), reason: 'billing_unavailable' };++ return unknown(member, creator, 'billing_unavailable');++ }++ }++++ /**++ * Keep projections that products are actually using confirmed before they go stale (job).++ * Rows unused for an hour simply expire into "unknown" and are re-checked on demand.++ */++ async function refreshDue({ limit = 50 } = {}) {++ const t = now();++ const rows = db.prepare(`SELECT member_subject, creator_subject FROM vip_entitlement_projection++ WHERE kind = ? AND last_used_at >= ? AND valid_until <= ? ORDER BY valid_until LIMIT ?`).all(KIND, t - 3600_000, t + 120_000, limit);++ let ok = 0; let failed = 0;++ for (const r of rows) {++ try { await authoritative(r.member_subject, r.creator_subject); ok++; } catch { failed++; }++ }++ return { checked: rows.length, ok, failed };++ }++++ /** Members of a creator according to the projection (fresh rows only; never past valid_until). */++ function projectedMembers(creator) {++ const t = now();++ return db.prepare(`SELECT * FROM vip_entitlement_projection WHERE creator_subject = ? AND kind = ? AND active = 1 AND valid_until >= ? AND expires_at > ?++ ORDER BY expires_at DESC`).all(creator, KIND, t, iso(t));++ }++++ return { apply, applyCanceled, doubt, check, authoritative, refreshDue, projectedMembers, getRow, KIND };++}++++module.exports = { createEntitlements, KIND };nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++++/** The VIP domain: every module shares one database handle, one clock and one outbox. */++const { createCreators } = require('./creators');++const { createPerks } = require('./perks');++const { createPlans } = require('./plans');++const { createMemberships } = require('./memberships');++const { createEntitlements } = require('./entitlements');++const { createPolicies } = require('./policies');++const { createCheckout } = require('./checkout');++++function createDomain({ db, config, outbox, billing, now = () => Date.now(), log = console }) {++ const creators = createCreators({ db, now });++ const perks = createPerks({ db, now });++ const plans = createPlans({ db, now, outbox, creators, perks });++ const memberships = createMemberships({ db, now, creators, plans });++ const entitlements = createEntitlements({ db, now, config, billing, outbox, memberships, plans, log });++ const policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });++ const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements });++ const tx = (fn) => db.transaction(fn)();++ return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, entitlements, policies, checkout };++}++++module.exports = { createDomain };nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++++/**++ * vip_memberships: VIP's record of WHICH PLAN VERSION a membership was bought under, plus member++ * preferences. Whether the membership is active is never stored here — that is Billing's++ * entitlement (projected in vip_entitlement_projection, re-checked with Billing when stale).++ *++ * Version rules (plan terms are never rewritten):++ * - a new membership (first grant, or a grant after the previous one lapsed) takes the version of++ * the member's own checkout hand-off when there is one, else the creator's current purchasable++ * version (a subscription bought elsewhere, e.g. on Live before its cutover);++ * - a renewal keeps the version the membership already has, however often the plan was edited;++ * - a membership Billing grants for a creator with no VIP plan is recorded with no plan.++ */++const { prefixedId, iso, json } = require('../util');++++const CHECKOUT_WINDOW_MS = 14 * 86_400_000;++++function createMemberships({ db, now, creators, plans }) {++ const get = (member, creatorId) => db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? AND creator_id = ?').get(member, creatorId) || null;++ const byId = (id) => db.prepare('SELECT * FROM vip_memberships WHERE id = ?').get(id) || null;++++ function pendingCheckout(member, creatorId) {++ const since = iso(now() - CHECKOUT_WINDOW_MS);++ return db.prepare(`SELECT * FROM vip_checkouts WHERE member_subject = ? AND creator_id = ? AND status IN ('handed_off', 'paid')++ AND created_at >= ? ORDER BY created_at DESC LIMIT 1`).get(member, creatorId, since) || null;++ }++++ /** Record the version a membership is under. Inside the projection's transaction. */++ function onProjection({ prev, next, reason, origin = null, checkoutId = null }) {++ const creator = creators.bySubject(next.creator_subject) || creators.ensure({ subject: next.creator_subject, origin: 'system' });++ const member = next.member_subject;++ let m = get(member, creator.id);++ const at = iso(now());++ if (!next.active) return { membership: m, creator };++ // A membership that exists keeps its version unless this is a NEW purchase after a lapse:++ // Billing says 'granted' after we saw it inactive, or the member's own checkout settled.++ const pending = checkoutId ? null : pendingCheckout(member, creator.id);++ const lapsed = prev && !prev.active;++ const newPurchase = !!checkoutId || (lapsed && (reason === 'granted' || (reason !== 'renewed' && !!pending)));++ if (m && !newPurchase) {++ if (next.subscription_id && next.subscription_id !== m.billing_subscription_id) {++ db.prepare('UPDATE vip_memberships SET billing_subscription_id = ?, updated_at = ? WHERE id = ?').run(next.subscription_id, at, m.id);++ m = get(member, creator.id);++ }++ return { membership: m, creator };++ }++ // A new membership, or a new purchase after a lapse: which terms did the member buy?++ let planId = null;++ let versionId = null;++ let how = origin || 'billing';++ const checkout = checkoutId ? db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(checkoutId) : pending;++ if (checkout) {++ planId = checkout.plan_id; versionId = checkout.plan_version_id; how = 'checkout';++ db.prepare("UPDATE vip_checkouts SET status = 'used', billing_subscription_id = COALESCE(billing_subscription_id, ?), updated_at = ? WHERE id = ?").run(next.subscription_id || null, at, checkout.id);++ } else {++ const plan = plans.purchasable(creator.id, next.kind === 'channel_subscription' ? 'channel_subscription' : next.kind);++ if (plan) { planId = plan.id; versionId = plan.current_version_id; }++ }++ if (m) {++ db.prepare(`UPDATE vip_memberships SET plan_id = ?, plan_version_id = ?, billing_subscription_id = COALESCE(?, billing_subscription_id), origin = ?,++ terms_since = ?, updated_at = ? WHERE id = ?`).run(planId, versionId, next.subscription_id || null, how, at, at, m.id);++ } else {++ db.prepare(`INSERT INTO vip_memberships (id, member_subject, creator_id, plan_id, plan_version_id, billing_subscription_id, origin, joined_at, terms_since, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(prefixedId('vms', now()), member, creator.id, planId, versionId, next.subscription_id || null, how, at, at, at);++ }++ return { membership: get(member, creator.id), creator };++ }++++ function preferences(member, creatorId) {++ const p = db.prepare('SELECT * FROM vip_member_preferences WHERE member_subject = ? AND creator_id = ?').get(member, creatorId);++ return { show_badge: p ? !!p.show_badge : true, listed: p ? !!p.listed : false };++ }++ function setPreferences(member, creatorId, { showBadge, listed }) {++ const cur = preferences(member, creatorId);++ const sb = showBadge === undefined ? cur.show_badge : !!showBadge;++ const li = listed === undefined ? cur.listed : !!listed;++ db.prepare(`INSERT INTO vip_member_preferences (member_subject, creator_id, show_badge, listed, updated_at) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT (member_subject, creator_id) DO UPDATE SET show_badge = excluded.show_badge, listed = excluded.listed, updated_at = excluded.updated_at`)++ .run(member, creatorId, sb ? 1 : 0, li ? 1 : 0, iso(now()));++ return preferences(member, creatorId);++ }++++ const forMember = (member) => db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? ORDER BY joined_at DESC').all(member);++ const forCreator = (creatorId) => db.prepare('SELECT * FROM vip_memberships WHERE creator_id = ? ORDER BY joined_at DESC').all(creatorId);++++ /** The perk keys a membership's version grants (its own snapshot, never the plan's latest). */++ function perkKeys(m) {++ if (!m || !m.plan_version_id) return [];++ return plans.versionPerks(m.plan_version_id).map((p) => p.perk_key);++ }++++ function present(m) {++ if (!m) return null;++ const v = m.plan_version_id ? plans.version(m.plan_version_id) : null;++ return {++ id: m.id, member: { type: 'user', id: m.member_subject }, creator_id: m.creator_id,++ plan_id: m.plan_id, plan_version: v ? { id: v.id, version: v.version, name: v.name, terms: json(v.terms, {}) } : null,++ perks: perkKeys(m), origin: m.origin, joined_at: m.joined_at, terms_since: m.terms_since,++ };++ }++++ return { get, byId, onProjection, pendingCheckout, preferences, setPreferences, forMember, forCreator, perkKeys, present };++}++++module.exports = { createMemberships };nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++++/**++ * vip_perks and vip_product_bindings. A perk is a benefit a creator (or the network) defines once++ * and includes in plan versions; a product binding says how a product recognises it (Live's chat++ * subscriber badge, a Chat room, a Community role, a gated Blog post, …).++ *++ * Editing a perk's name or bindings never rewrites a plan version: each version keeps its own++ * snapshot of the perk keys and names it was published with (vip_plan_perks).++ */++const { fail, prefixedId, iso, text, slug, json } = require('../util');++++const KINDS = ['badge', 'emote', 'gated_content', 'room', 'role', 'other'];++const PRODUCT_RE = /^[a-z][a-z0-9-]{1,39}$/;++const BINDING_RE = /^[a-z][a-z0-9_.]{1,63}$/;++++function createPerks({ db, now }) {++ const byId = (id) => db.prepare('SELECT * FROM vip_perks WHERE id = ?').get(id) || null;++ const byKey = (creatorId, key) => db.prepare('SELECT * FROM vip_perks WHERE creator_id = ? AND key = ?').get(creatorId, key) || null;++++ function cleanBindings(list) {++ if (list == null) return null;++ if (!Array.isArray(list) || list.length > 20) fail(422, 'vip.invalid_input', 'bindings must be a list of at most 20 { product, binding, config? }');++ const seen = new Set();++ return list.map((b, i) => {++ if (!b || !PRODUCT_RE.test(String(b.product || ''))) fail(422, 'vip.invalid_input', `bindings[${i}].product must be a service id such as live, chat or community`);++ if (!BINDING_RE.test(String(b.binding || ''))) fail(422, 'vip.invalid_input', `bindings[${i}].binding must be an id such as chat_badge or room_access`);++ const config = b.config == null ? {} : b.config;++ if (typeof config !== 'object' || Array.isArray(config) || JSON.stringify(config).length > 2000) fail(422, 'vip.invalid_input', `bindings[${i}].config must be an object under 2 KB`);++ const k = `${b.product}:${b.binding}`;++ if (seen.has(k)) fail(422, 'vip.invalid_input', `bindings[${i}] repeats ${k}`);++ seen.add(k);++ return { product: String(b.product), binding: String(b.binding), config };++ });++ }++++ function setBindings(perkId, list) {++ const at = iso(now());++ const keep = new Set(list.map((b) => `${b.product}:${b.binding}`));++ for (const b of db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active'").all(perkId)) {++ if (!keep.has(`${b.product}:${b.binding}`)) db.prepare("UPDATE vip_product_bindings SET status = 'removed', updated_at = ? WHERE id = ?").run(at, b.id);++ }++ const up = db.prepare(`INSERT INTO vip_product_bindings (id, perk_id, product, binding, config, status, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, 'active', ?, ?)++ ON CONFLICT (perk_id, product, binding) DO UPDATE SET config = excluded.config, status = 'active', updated_at = excluded.updated_at`);++ for (const b of list) up.run(prefixedId('vpb', now()), perkId, b.product, b.binding, JSON.stringify(b.config), at, at);++ }++++ function create({ creatorId, key, name, description, kind = 'other', bindings = [], actor = null }) {++ const k = slug(key || name, 'key');++ if (byKey(creatorId, k)) fail(409, 'vip.perk_exists', `a perk with key ${k} already exists`);++ if (!KINDS.includes(kind)) fail(422, 'vip.invalid_input', `kind must be one of ${KINDS.join(', ')}`);++ const clean = cleanBindings(bindings) || [];++ const at = iso(now());++ const id = prefixedId('vpk', now());++ db.transaction(() => {++ db.prepare(`INSERT INTO vip_perks (id, creator_id, key, name, description, kind, status, created_by, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creatorId, k, text(name, 'name', 80, { required: true }), text(description, 'description', 500), kind, actor, at, at);++ setBindings(id, clean);++ })();++ return byId(id);++ }++++ function update(perkId, { name, description, kind, status, bindings }) {++ const p = byId(perkId);++ if (!p) fail(404, 'vip.perk_not_found', `no perk ${perkId}`);++ if (kind !== undefined && !KINDS.includes(kind)) fail(422, 'vip.invalid_input', `kind must be one of ${KINDS.join(', ')}`);++ if (status !== undefined && !['active', 'retired'].includes(status)) fail(422, 'vip.invalid_input', 'status must be active or retired');++ const clean = cleanBindings(bindings);++ db.transaction(() => {++ db.prepare('UPDATE vip_perks SET name = ?, description = ?, kind = ?, status = ?, updated_at = ? WHERE id = ?').run(++ name !== undefined ? text(name, 'name', 80, { required: true }) : p.name,++ description !== undefined ? text(description, 'description', 500) : p.description,++ kind !== undefined ? kind : p.kind, status !== undefined ? status : p.status, iso(now()), perkId);++ if (clean) setBindings(perkId, clean);++ })();++ return byId(perkId);++ }++++ /** Perks usable in a creator's plans: their own plus the network's. */++ function list({ creatorId, includeNetwork = true, includeRetired = false }) {++ const owners = includeNetwork && creatorId !== 'network' ? [creatorId, 'network'] : [creatorId];++ return db.prepare(`SELECT * FROM vip_perks WHERE creator_id IN (${owners.map(() => '?').join(',')}) ${includeRetired ? '' : "AND status = 'active'"}++ ORDER BY creator_id = 'network', name COLLATE NOCASE`).all(...owners);++ }++++ /** Resolve perk ids or keys for a plan of creatorId: own or network perks, active only. */++ function resolveForPlan(creatorId, refs) {++ if (refs == null) return null;++ if (!Array.isArray(refs) || refs.length > 30) fail(422, 'vip.invalid_input', 'perks must be a list of at most 30 perk ids or keys');++ const out = [];++ const seen = new Set();++ for (const r of refs) {++ const s = String(r || '');++ let p = s.startsWith('vpk_') ? byId(s) : (byKey(creatorId, s) || byKey('network', s));++ if (p && p.creator_id !== creatorId && p.creator_id !== 'network') p = null;++ if (!p || p.status !== 'active') fail(422, 'vip.perk_not_found', `no active perk ${s} for this creator`);++ if (!seen.has(p.id)) { seen.add(p.id); out.push(p); }++ }++ return out;++ }++++ const bindingsOf = (perkId) => db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active' ORDER BY product, binding").all(perkId);++++ function present(p, { withBindings = true } = {}) {++ if (!p) return null;++ return {++ id: p.id, key: p.key, name: p.name, description: p.description || null, kind: p.kind, status: p.status,++ scope: p.creator_id === 'network' ? 'network' : 'creator', creator_id: p.creator_id,++ bindings: withBindings ? bindingsOf(p.id).map((b) => ({ product: b.product, binding: b.binding, config: json(b.config, {}) })) : undefined,++ updated_at: p.updated_at,++ };++ }++++ return { byId, byKey, create, update, list, resolveForPlan, bindingsOf, present, KINDS };++}++++module.exports = { createPerks };nnew file mode 100644@@@ -0,0 +1,182 @@++'use strict';++++/**++ * vip_plans, vip_plan_versions, vip_plan_perks.++ *++ * Plans are versioned. Terms (name, description, benefits, perks, the Billing product) live in++ * immutable vip_plan_versions rows; an edit ALWAYS creates version N+1 and never touches an earlier++ * row (SQLite triggers refuse it). A published plan's edit is published at once and becomes what new++ * members buy; members who joined earlier keep the version they bought (vip_memberships).++ *++ * Prices are not plan terms here: Billing prices and charges a subscription, and records what each++ * period cost. A plan says which Billing product it is sold as (billing_kind), and Billing sells one++ * channel subscription per (member, creator) today, so a creator has at most one published plan++ * bound to it. A plan with no Billing product (a network plan today) can be published as terms, but++ * has no checkout until Billing sells it.++ */++const crypto = require('crypto');++const { fail, prefixedId, iso, text, slug, slugify, json } = require('../util');++++const BILLING_KINDS = ['channel_subscription'];++++function createPlans({ db, now, outbox, creators, perks }) {++ const byId = (id) => db.prepare('SELECT * FROM vip_plans WHERE id = ?').get(id) || null;++ const version = (id) => db.prepare('SELECT * FROM vip_plan_versions WHERE id = ?').get(id) || null;++ const latestVersion = (planId) => db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC LIMIT 1').get(planId) || null;++ const versions = (planId) => db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC').all(planId);++ const versionPerks = (versionId) => db.prepare('SELECT * FROM vip_plan_perks WHERE plan_version_id = ? ORDER BY position').all(versionId);++++ function cleanBenefits(v) {++ if (v == null) return null;++ const arr = Array.isArray(v) ? v : String(v).split('\n');++ const out = arr.map((b) => text(b, 'benefit', 200)).filter(Boolean);++ if (out.length > 20) fail(422, 'vip.invalid_input', 'at most 20 benefits');++ return out;++ }++++ /** Insert version N+1 of a plan from complete terms. Inside a transaction. */++ function insertVersion(plan, { name, description, benefits, perkRows, changeNote, actor, publish }) {++ const n = plan.latest_version + 1;++ const at = iso(now());++ const id = prefixedId('vpv', now());++ const perkSnap = perkRows.map((p) => ({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator' }));++ const terms = { name, description, benefits, perks: perkSnap, billing_kind: plan.billing_kind, price: plan.billing_kind ? 'set and charged by OpenVibe.Billing' : null };++ terms.digest = crypto.createHash('sha256').update(JSON.stringify([terms.name, terms.description, terms.benefits, terms.perks, terms.billing_kind])).digest('hex').slice(0, 16);++ db.prepare(`INSERT INTO vip_plan_versions (id, plan_id, version, name, description, benefits, terms, change_note, created_by, created_at, published_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, plan.id, n, name, description, JSON.stringify(benefits), JSON.stringify(terms), changeNote || null, actor, at, publish ? at : null);++ const ins = db.prepare('INSERT INTO vip_plan_perks (plan_version_id, perk_id, perk_key, perk_name, position) VALUES (?, ?, ?, ?, ?)');++ perkRows.forEach((p, i) => ins.run(id, p.id, p.key, p.name, i));++ db.prepare(`UPDATE vip_plans SET latest_version = ?, current_version_id = CASE WHEN ? THEN ? ELSE current_version_id END, updated_at = ? WHERE id = ?`)++ .run(n, publish ? 1 : 0, id, at, plan.id);++ return version(id);++ }++++ function emitPublished(plan, v, traceparent) {++ const creator = creators.byId(plan.creator_id);++ outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {++ plan_id: plan.id, slug: plan.slug, version: v.version, version_id: v.id,++ creator: creators.present(creator), billing_kind: plan.billing_kind,++ terms: json(v.terms, {}), published_at: v.published_at,++ }, { visibility: 'public', traceparent });++ }++++ function publishConflict(e) {++ if (e && /UNIQUE constraint failed: vip_plans\.creator_id, vip_plans\.billing_kind/.test(e.message)) {++ fail(409, 'vip.plan.billing_product_taken', 'this creator already has a published plan sold as a channel subscription; Billing sells one per member and creator today, so archive the other plan first');++ }++ throw e;++ }++++ function create({ creatorId, slug: s, name, description, benefits, perks: perkRefs, billingKind, publish = false, changeNote, actor = null, traceparent }) {++ const creator = creators.byId(creatorId);++ if (!creator) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);++ const kind = billingKind === undefined ? (creator.kind === 'network' ? null : 'channel_subscription') : (billingKind || null);++ if (kind && !BILLING_KINDS.includes(kind)) fail(422, 'vip.invalid_input', `billing_kind must be one of ${BILLING_KINDS.join(', ')} or null`);++ if (kind && creator.kind === 'network') fail(422, 'vip.invalid_input', 'Billing has no network membership product yet; a network plan has billing_kind null');++ const cleanName = text(name, 'name', 80, { required: true });++ const planSlug = s ? slug(s) : slugify(cleanName);++ if (db.prepare('SELECT 1 FROM vip_plans WHERE creator_id = ? AND slug = ?').get(creatorId, planSlug)) fail(409, 'vip.plan_exists', `a plan ${planSlug} already exists`);++ const perkRows = perks.resolveForPlan(creatorId, perkRefs || []);++ const at = iso(now());++ const id = prefixedId('vpl', now());++ try {++ return db.transaction(() => {++ db.prepare(`INSERT INTO vip_plans (id, creator_id, slug, status, billing_kind, current_version_id, latest_version, created_by, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)`).run(id, creatorId, planSlug, publish ? 'published' : 'draft', kind, actor, at, at);++ const v = insertVersion(byId(id), {++ name: cleanName, description: text(description, 'description', 2000), benefits: cleanBenefits(benefits) || [],++ perkRows, changeNote: changeNote || 'created', actor, publish,++ });++ if (publish) emitPublished(byId(id), v, traceparent);++ return byId(id);++ })();++ } catch (e) { return publishConflict(e); }++ }++++ /** An edit: version N+1 from the latest terms plus the changes. Published plans publish it. */++ function update(planId, { name, description, benefits, perks: perkRefs, changeNote, actor = null, traceparent }) {++ const plan = byId(planId);++ if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);++ if (plan.status === 'archived') fail(409, 'vip.plan_archived', 'an archived plan cannot be edited');++ const last = latestVersion(planId);++ const next = {++ name: name !== undefined ? text(name, 'name', 80, { required: true }) : last.name,++ description: description !== undefined ? text(description, 'description', 2000) : last.description,++ benefits: benefits !== undefined ? cleanBenefits(benefits) : json(last.benefits, []),++ };++ const perkRows = perkRefs !== undefined ? perks.resolveForPlan(plan.creator_id, perkRefs) : versionPerks(last.id).map((pp) => perks.byId(pp.perk_id)).filter((p) => p && p.status === 'active');++ const same = next.name === last.name && next.description === last.description && JSON.stringify(next.benefits) === last.benefits++ && JSON.stringify(perkRows.map((p) => p.id)) === JSON.stringify(versionPerks(last.id).map((pp) => pp.perk_id));++ if (same) return { plan, version: last, unchanged: true };++ const publish = plan.status === 'published';++ return db.transaction(() => {++ const v = insertVersion(plan, { ...next, perkRows, changeNote: text(changeNote, 'change_note', 300), actor, publish });++ if (publish) emitPublished(byId(planId), v, traceparent);++ return { plan: byId(planId), version: v, unchanged: false };++ })();++ }++++ function publish(planId, { traceparent } = {}) {++ const plan = byId(planId);++ if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);++ if (plan.status === 'published') return { plan, replay: true };++ if (plan.status === 'archived') fail(409, 'vip.plan_archived', 'an archived plan cannot be published again; create a new plan');++ try {++ return db.transaction(() => {++ const v = latestVersion(planId);++ const at = iso(now());++ db.prepare('UPDATE vip_plan_versions SET published_at = ? WHERE id = ? AND published_at IS NULL').run(at, v.id);++ db.prepare("UPDATE vip_plans SET status = 'published', current_version_id = ?, updated_at = ? WHERE id = ?").run(v.id, at, planId);++ emitPublished(byId(planId), version(v.id), traceparent);++ return { plan: byId(planId), replay: false };++ })();++ } catch (e) { return publishConflict(e); }++ }++++ /** No new members; existing members keep their terms until Billing ends their membership. */++ function archive(planId) {++ const plan = byId(planId);++ if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);++ if (plan.status === 'archived') return plan;++ const at = iso(now());++ db.prepare("UPDATE vip_plans SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(at, at, planId);++ return byId(planId);++ }++++ function list({ creatorId, includeDrafts = false, includeArchived = false }) {++ const st = ['published'];++ if (includeDrafts) st.push('draft');++ if (includeArchived) st.push('archived');++ return db.prepare(`SELECT * FROM vip_plans WHERE creator_id = ? AND status IN (${st.map(() => '?').join(',')}) ORDER BY sort, created_at`).all(creatorId, ...st);++ }++++ /** What a new member of this creator buys through Billing's product `kind`. */++ const purchasable = (creatorId, kind = 'channel_subscription') => db.prepare("SELECT * FROM vip_plans WHERE creator_id = ? AND billing_kind = ? AND status = 'published'").get(creatorId, kind) || null;++++ function presentVersion(v) {++ if (!v) return null;++ return {++ id: v.id, version: v.version, name: v.name, description: v.description || null, benefits: json(v.benefits, []),++ perks: versionPerks(v.id).map((p) => ({ id: p.perk_id, key: p.perk_key, name: p.perk_name })),++ terms: json(v.terms, {}), change_note: v.change_note || null, created_at: v.created_at, published_at: v.published_at || null,++ };++ }++++ function present(plan, { withVersions = false } = {}) {++ if (!plan) return null;++ const current = plan.current_version_id ? version(plan.current_version_id) : null;++ const latest = latestVersion(plan.id);++ return {++ id: plan.id, creator_id: plan.creator_id, slug: plan.slug, status: plan.status, billing_kind: plan.billing_kind,++ purchasable: plan.status === 'published' && !!plan.billing_kind,++ current_version: presentVersion(current),++ draft_version: latest && (!current || latest.version > current.version) ? presentVersion(latest) : null,++ versions: withVersions ? versions(plan.id).map(presentVersion) : undefined,++ created_at: plan.created_at, updated_at: plan.updated_at, archived_at: plan.archived_at || null,++ };++ }++++ return { byId, version, latestVersion, versions, versionPerks, create, update, publish, archive, list, purchasable, present, presentVersion, BILLING_KINDS };++}++++module.exports = { createPlans };nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++++/**++ * vip_gated_resource_rules: "resource R of product P is for members of creator C" — optionally for++ * members of one plan, or for members whose plan VERSION includes perk K. A product stores the++ * resource; VIP stores only the typed reference (EntityRef) and the rule.++ *++ * evaluate() FAILS CLOSED: no rule, a disabled rule, a suspended creator, an entitlement Billing++ * cannot confirm ("unknown"), a missing perk or any error → allow: false with the reason. Rules++ * marked sensitive always ask Billing directly (never the projection).++ */++const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../util');++++function createPolicies({ db, now, creators, plans, perks, memberships, entitlements }) {++ const byId = (id) => db.prepare('SELECT * FROM vip_gated_resource_rules WHERE id = ?').get(id) || null;++ const forResource = (r) => db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND status = 'active'`)++ .get(r.service, r.type, r.id) || null;++++ /** Create or replace the active rule for a resource. */++ function set({ creatorId, resource, requirement = 'member', planId = null, perkKey = null, sensitive = false, actor = null }) {++ const creator = creators.byId(creatorId);++ if (!creator || creator.kind !== 'creator') fail(404, 'vip.creator_not_found', 'gated resources belong to a creator');++ const r = entityRef(resource);++ if (!['member', 'plan', 'perk'].includes(requirement)) fail(422, 'vip.invalid_input', 'requirement must be member, plan or perk');++ if (requirement === 'plan') {++ const p = plans.byId(String(planId || ''));++ if (!p || p.creator_id !== creatorId) fail(422, 'vip.plan_not_found', 'plan_id must be one of this creator\'s plans');++ } else planId = null;++ if (requirement === 'perk') {++ perkKey = text(perkKey, 'perk_key', 48, { required: true });++ if (!perks.byKey(creatorId, perkKey) && !perks.byKey('network', perkKey)) fail(422, 'vip.perk_not_found', `no perk ${perkKey} for this creator`);++ } else perkKey = null;++ const existing = forResource(r);++ if (existing && existing.creator_id !== creatorId) fail(409, 'vip.rule_conflict', 'another creator already gates this resource');++ const at = iso(now());++ const id = prefixedId('vgr', now());++ db.transaction(() => {++ if (existing) db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(at, existing.id);++ db.prepare(`INSERT INTO vip_gated_resource_rules (id, creator_id, resource_service, resource_type, resource_id, requirement, plan_id, perk_key, sensitive, status, created_by, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creatorId, r.service, r.type, r.id, requirement, planId, perkKey, sensitive ? 1 : 0, actor, at, at);++ })();++ return byId(id);++ }++++ function disable(id) {++ const rule = byId(id);++ if (!rule) fail(404, 'vip.rule_not_found', `no rule ${id}`);++ db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(iso(now()), id);++ return byId(id);++ }++++ const list = (creatorId) => db.prepare("SELECT * FROM vip_gated_resource_rules WHERE creator_id = ? AND status = 'active' ORDER BY created_at DESC").all(creatorId);++++ function present(rule) {++ if (!rule) return null;++ const c = creators.byId(rule.creator_id);++ return {++ id: rule.id, creator: c && c.subject ? { type: 'user', id: c.subject } : null, creator_id: rule.creator_id,++ resource: { service: rule.resource_service, type: rule.resource_type, id: rule.resource_id },++ requirement: rule.requirement, plan_id: rule.plan_id || null, perk_key: rule.perk_key || null,++ sensitive: !!rule.sensitive, status: rule.status, updated_at: rule.updated_at,++ };++ }++++ const deny = (reason, extra = {}) => ({ allow: false, reason, ...extra });++++ /**++ * May `subject` see `resource`? { allow, reason, rule, entitlement }. `ruleId` pins the rule the++ * product believes applies (a mismatch denies). mode: 'auto' (default) or 'authoritative'.++ */++ async function evaluate({ subject, resource, ruleId = null, mode = 'auto', traceparent }) {++ let r;++ try { r = entityRef(resource); } catch { return deny('invalid_resource'); }++ const rule = ruleId ? byId(String(ruleId)) : forResource(r);++ if (!rule) return deny('no_rule');++ if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: present(rule) });++ if (rule.status !== 'active') return deny('rule_disabled', { rule: present(rule) });++ const creator = creators.byId(rule.creator_id);++ if (!creator || creator.status !== 'active' || !creator.subject) return deny('creator_unavailable', { rule: present(rule) });++ if (!subject) return deny('not_signed_in', { rule: present(rule) });++ if (!isUserSubject(subject)) return deny('not_a_member', { rule: present(rule) });++ // The creator always sees their own gated resources.++ if (subject === creator.subject) return { allow: true, reason: 'owner', rule: present(rule), entitlement: null };++ let ent;++ try {++ ent = await entitlements.check(subject, creator.subject, { mode: rule.sensitive ? 'authoritative' : (mode === 'authoritative' ? 'authoritative' : 'auto'), traceparent });++ } catch { return deny('entitlement_unknown', { rule: present(rule) }); }++ if (ent.status === 'unknown') return deny('entitlement_unknown', { rule: present(rule), entitlement: ent });++ if (!ent.active) return deny('not_a_member', { rule: present(rule), entitlement: ent });++ const m = memberships.get(subject, creator.id);++ if (rule.requirement === 'plan' && (!m || m.plan_id !== rule.plan_id)) return deny('plan_required', { rule: present(rule), entitlement: ent });++ if (rule.requirement === 'perk' && !memberships.perkKeys(m).includes(rule.perk_key)) return deny('perk_missing', { rule: present(rule), entitlement: ent });++ return { allow: true, reason: 'member', rule: present(rule), entitlement: ent };++ }++++ return { byId, forResource, set, disable, list, present, evaluate };++}++++module.exports = { createPolicies };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++++/**++ * Billing → VIP: POST /internal/events, the endpoint of VIP's OpenVibe.Events subscriptions++ * (scripts/subscribe.js): billing.entitlement.*, billing.subscription.*, billing.transaction.reversed.++ *++ * billing.entitlement.changed the projection takes Billing's { active, expires_at, subscription }++ * (granted, renewed, canceled, expired, refund, chargeback, …)++ * billing.subscription.canceled cancel at period end: the flag changes, the period still runs++ * billing.transaction.reversed a reversal that revoked subscription periods puts the pair's++ * projection in doubt at once (the next check asks Billing), even++ * before — or without — the matching entitlement event++ *++ * Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same SQLite transaction as++ * the change. Order: rows remember the Billing time they reflect; see domain/entitlements.js.++ * The signature (X-OpenVibe-Signature, HMAC-SHA256 of the raw body with VIP_EVENTS_SECRET) is++ * verified with openvibe-sdk's parseDelivery. Only events whose source is `billing` are applied.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { parseDelivery, createInbox } = require('openvibe-sdk/events');++const { isUserSubject } = require('../util');++++const CONSUMER = 'vip-billing';++++function consumerRouter({ domain, config, log = console }) {++ const router = express.Router();++ const inbox = createInbox(domain.db, { now: domain.now });++ inbox.ensureSchema();++ const { entitlements } = domain;++++ function handle(event) {++ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};++ const asOf = Date.parse(event.timestamp);++ if (!Number.isFinite(asOf)) return 'ignored:bad_timestamp';++ if (event.event_type === 'billing.entitlement.changed') {++ const member = p.subject && p.subject.id;++ const creator = p.streamer && p.streamer.id;++ if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';++ if (p.kind && p.kind !== entitlements.KIND) return 'ignored:kind';++ const sub = p.subscription || null;++ return entitlements.apply({++ member, creator, active: !!p.active, expiresAt: p.expires_at || null,++ cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,++ subscriptionStatus: sub ? sub.status : undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,++ }).outcome;++ }++ if (event.event_type === 'billing.subscription.canceled') {++ const s = p.subscription || {};++ const member = s.subscriber && s.subscriber.id;++ const creator = s.streamer && s.streamer.id;++ if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';++ return entitlements.applyCanceled({++ member, creator, subscriptionId: s.id || null, subscriptionStatus: s.status || null,++ currentPeriodEnd: s.current_period_end || null, eventId: event.event_id, asOf,++ }).outcome;++ }++ if (event.event_type === 'billing.transaction.reversed') {++ if (!(Number(p.entitlements_revoked) > 0)) return 'ignored:no_entitlement';++ // The reversal runs streamer → subscriber (from/to of the original swapped).++ const member = p.to_subject;++ const creator = p.from_subject;++ if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';++ return entitlements.doubt(member, creator) ? 'doubt' : 'no_projection';++ }++ return 'ignored:type';++ }++++ /** Apply one envelope (also used by tests). Returns { duplicate, outcome }. */++ function apply(event) {++ const r = inbox.once(CONSUMER, event.event_id, () => (event.source !== 'billing' ? 'ignored:source' : handle(event)));++ return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };++ }++++ router.post('/events', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {++ const secrets = config.events.webhookSecrets;++ if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);++ let delivery = null;++ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }++ if (!delivery) return http.sendProblem(res, 401, 'vip.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });++ const event = delivery.event;++ if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {++ return http.sendProblem(res, 400, 'vip.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx: req.ov });++ }++ let out;++ try {++ out = apply(event);++ } catch (e) {++ // Not acknowledged: Events retries it, and the inbox claim rolled back with the change.++ log.error(`[VIP] event ${event.event_id} (${event.event_type}) failed:`, e.message);++ return http.sendProblem(res, 500, 'vip.event_failed', { detail: 'processing failed; it will be retried', ctx: req.ov });++ }++ res.status(200).json({ event_id: event.event_id, duplicate: out.duplicate, outcome: out.outcome });++ });++++ return { router, apply, CONSUMER };++}++++module.exports = { consumerRouter, CONSUMER };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++++/**++ * VIP → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ *++ * vip.plan.published a plan version became what new members buy (publish, or an edit of a++ * published plan); payload carries the version's full terms++ * vip.membership.changed a member's standing with a creator changed, as projected from Billing++ * (granted, renewed, cancel scheduled, expired, refunded, …); payload++ * names the plan version the membership was bought under++ *++ * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only++ * if its change committed. Envelopes are validated against events.event-envelope@1 first. The relay++ * publishes with VIP's service token (events.event.publish, audience openvibe.events) only when++ * EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise rows wait in event_outbox.++ */++const { validate } = require('openvibe-contracts');++const { createClient } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++++const ACTOR = { type: 'service', id: 'vip' };++++function createVipOutbox({ db, config, fetchImpl, now = () => Date.now(), log = console }) {++ const enabled = !!(config.events.url && config.oauth.clientSecret);++ const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };++ if (fetchImpl) clientOpts.fetch = fetchImpl;++ if (enabled) {++ clientOpts.tokenProvider = createServiceTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),++ });++ } else {++ clientOpts.getToken = async () => { throw new Error('events relay disabled (EVENTS_URL / OV_OAUTH_CLIENT_SECRET unset)'); };++ }++ const events = createEventsClient(createClient(clientOpts), { source: 'vip' });++ let lastError = null;++ const outbox = createOutbox(db, {++ events,++ intervalMs: config.events.intervalMs,++ now,++ onError: (err) => {++ const msg = err && err.message;++ if (msg !== lastError) log.warn('[VIP] event publish failed (will retry):', msg);++ lastError = msg;++ },++ });++ outbox.ensureSchema();++++ /** Inside the caller's transaction. */++ function emit(eventType, subject, payload, { visibility = 'internal', priority = 'important', traceparent } = {}) {++ const env = events.prepare({ event_type: eventType, actor: ACTOR, subject, payload, visibility, priority }, { now: now() });++ const v = validate('events.event-envelope@1', env);++ if (!v.valid) throw new Error(`outbox: invalid envelope for ${eventType}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);++ return outbox.enqueue(env, { traceparent });++ }++++ return {++ emit,++ outbox,++ enabled,++ start() { if (enabled) outbox.start(); },++ stop: () => outbox.stop(),++ kick() { if (enabled) outbox.kick(); },++ status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),++ };++}++++module.exports = { createVipOutbox };nnew file mode 100644@@@ -0,0 +1,270 @@++'use strict';++++/**++ * Import OpenVibe.Live's subscription OFFERING into VIP, from a SNAPSHOT COPY of Live's database++ * (opened read-only). Memberships come from OpenVibe.Billing's entitlements (Billing already++ * imported Live's subscriptions, ADR-012), never from Live's rows.++ *++ * What Live offers today: every channel can be subscribed to, monthly, at the site-wide price in++ * site_settings (sub_price_usd, now Billing's rate), and a subscriber gets a star "Subscriber" badge++ * (Live chat, the PowerChat overlay relay, the stream AI's context flag). So:++ *++ * 1. perk one network perk `subscriber-badge` with product bindings for those three places++ * and OpenVibe.Chat's badge++ * 2. creators every Live user who is a streamer (role streamer / global_mod / admin) or has ever++ * been subscribed to → a VIP creator for their Network subject (resolve-batch). A Live++ * user with no subject yet is HELD (vip_migration_maps), never dropped; a later run++ * picks it up++ * 3. plans per creator, plan `channel-subscription` v1 "Channel subscription" (published, sold++ * as Billing's channel_subscription) with the badge perk — unless the creator already++ * has a published plan sold that way (then that plan is linked, nothing is created)++ * 4. members per creator, Billing's subscriptions (billing.entitlement.check): each ACTIVE one++ * becomes a VIP membership under the plan version above and a projection row from++ * Billing's entitlement; inactive ones are recorded excluded with the reason++ * 5. live rows every row of Live's `subscriptions` table is reconciled: linked to the VIP++ * membership / Billing subscription for the same pair, or held with the reason++ *++ * Idempotent: every source row is keyed in vip_migration_maps; a re-run creates nothing twice,++ * never edits a plan a creator has changed since, and releases what was held when it can.++ * --dry-run does everything inside a transaction that is rolled back.++ */++const { iso } = require('../util');++++const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);++const columns = (db, t) => (tableExists(db, t) ? db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name) : []);++const CREATOR_ROLES = ['streamer', 'global_mod', 'admin'];++const PLAN_SLUG = 'channel-subscription';++const PERK_KEY = 'subscriber-badge';++const ACTOR = 'svc:vip-import';++++class DryRun extends Error {}++++/** Billing through its API (VIP's service token, billing.entitlement.check). */++function billingApiSource(billing) {++ return {++ name: 'billing api',++ listSubscriptions: async (creator) => ((await billing.listSubscriptions({ streamer: creator })).subscriptions || []),++ entitlement: (member, creator) => billing.entitlement(member, creator),++ };++}++++/** Billing from a read-only SNAPSHOT of billing.db (operator tool, for dry runs without a token). */++function billingSnapshotSource(bdb, { now = () => Date.now() } = {}) {++ const subs = bdb.prepare('SELECT * FROM subscriptions WHERE streamer = ? ORDER BY created_at');++ return {++ name: 'billing snapshot',++ listSubscriptions: async (creator) => subs.all(creator).map((s) => ({++ id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, status: s.status,++ auto_renew: !!s.auto_renew, cancel_at_period_end: !!s.cancel_at_period_end, current_period_end: s.current_period_end, legacy_live_id: s.legacy_live_id ?? null,++ })),++ entitlement: async (member, creator) => {++ const at = iso(now());++ const rows = bdb.prepare(`SELECT starts_at, ends_at FROM entitlements WHERE subject = ? AND kind = 'channel_subscription' AND scope = ?++ AND revoked_at IS NULL AND ends_at > ? ORDER BY starts_at`).all(member, creator, at);++ const active = rows.some((r) => r.starts_at <= at);++ const sub = bdb.prepare('SELECT * FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(member, creator);++ return {++ active, expires_at: active ? rows.reduce((m, r) => (r.ends_at > m ? r.ends_at : m), '') : null,++ subscription: sub ? { id: sub.id, status: sub.status, auto_renew: !!sub.auto_renew, cancel_at_period_end: !!sub.cancel_at_period_end } : null,++ };++ },++ };++}++++async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRun = false, log = console }) {++ const { db, creators, perks, plans, entitlements } = domain;++ const started = domain.now();++ const runId = `vimp_${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,++ live_offering: null,++ counts: {++ creators: { imported: 0, unchanged: 0, held: 0, released: 0 }, plans: { created: 0, linked: 0, unchanged: 0 },++ memberships: { imported: 0, unchanged: 0, excluded: 0 }, live_subscriptions: { linked: 0, held: 0, unchanged: 0 },++ },++ holds: [], excluded: [], billing_errors: [],++ };++++ // ── Read the snapshot ────────────────────────────────────++ const hasUsers = tableExists(live, 'users');++ const userCols = columns(live, 'users');++ const users = hasUsers ? live.prepare(`SELECT id, username, display_name${userCols.includes('role') ? ', role' : ", 'user' AS role"} FROM users`).all() : [];++ const byLiveId = new Map(users.map((u) => [String(u.id), u]));++ const subCols = columns(live, 'subscriptions');++ const liveSubs = subCols.length ? live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];++ if (tableExists(live, 'site_settings')) {++ const get = (k) => { const r = live.prepare('SELECT value FROM site_settings WHERE key = ?').get(k); return r ? r.value : null; };++ report.live_offering = {++ sub_price_usd: get('sub_price_usd'), sub_streamer_share_pct: get('sub_streamer_share_pct'), sub_site_route_fee_pct: get('sub_site_route_fee_pct'),++ note: 'recorded for reference only: OpenVibe.Billing prices and charges subscriptions; VIP plan terms carry no price',++ };++ }++ const creatorIds = new Set(users.filter((u) => CREATOR_ROLES.includes(u.role)).map((u) => String(u.id)));++ for (const s of liveSubs) if (s.streamer_id != null) creatorIds.add(String(s.streamer_id));++ const allIds = new Set(creatorIds);++ for (const s of liveSubs) if (s.subscriber_id != null) allIds.add(String(s.subscriber_id));++ report.counts.live = { users: users.length, creators: creatorIds.size, subscriptions: liveSubs.length };++++ // ── Identities ───────────────────────────────────────────++ const map = allIds.size ? await resolveLiveUsers([...allIds]) : new Map();++ const subjectOf = (id) => { const v = id == null ? null : map.get(String(id)); return v ? (typeof v === 'string' ? v : v.subject) : null; };++ const handleOf = (id) => { const v = map.get(String(id)); const u = byLiveId.get(String(id)); return (v && typeof v === 'object' && v.username) || (u && u.username) || null; };++++ // ── Billing (async, before the transaction) ──────────────++ const billingSubs = new Map(); // creator subject → [subscription]++ const ents = new Map(); // `${member}:${creator}` → entitlement++ for (const id of creatorIds) {++ const s = subjectOf(id);++ if (!s || billingSubs.has(s)) continue;++ if (!billingSource) { billingSubs.set(s, null); continue; }++ try {++ const list = await billingSource.listSubscriptions(s);++ billingSubs.set(s, list);++ for (const sub of list) {++ if (sub.status !== 'active') continue;++ ents.set(`${sub.subscriber.id}:${s}`, await billingSource.entitlement(sub.subscriber.id, s));++ }++ } catch (e) {++ billingSubs.set(s, null);++ report.billing_errors.push({ creator: s, error: e.message });++ }++ }++++ const mapRow = db.prepare('SELECT * FROM vip_migration_maps WHERE source = ? AND source_table = ? AND source_id = ?');++ const upsertMap = db.prepare(`INSERT INTO vip_migration_maps (source, source_table, source_id, target_type, target_id, status, reason, run_id, created_at, updated_at)++ VALUES (@source, @table, @id, @target_type, @target_id, @status, @reason, @run, @at, @at)++ ON CONFLICT (source, source_table, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id, status = excluded.status,++ reason = excluded.reason, run_id = excluded.run_id, updated_at = excluded.updated_at`);++ const record = (source, table, id, fields) => upsertMap.run({ source, table, id: String(id), target_type: null, target_id: null, reason: null, run: runId, at, ...fields });++++ const run = () => {++ // ── 1. The network perk ──────────────────────────────++ let perk = perks.byKey('network', PERK_KEY);++ if (!perk) {++ perk = perks.create({++ creatorId: 'network', key: PERK_KEY, name: 'Subscriber badge', kind: 'badge', actor: ACTOR,++ description: 'A star badge next to your name in the creator\'s chat, as channel subscribers had on OpenVibe.Live.',++ bindings: [++ { product: 'live', binding: 'chat_badge', config: { badge: 'subscriber' } },++ { product: 'live', binding: 'powerchat_overlay', config: { is_subscriber: true } },++ { product: 'live', binding: 'ai_context', config: { flag: 'subscriber' } },++ { product: 'chat', binding: 'badge', config: { badge: 'subscriber' } },++ ],++ });++ record('live', 'offering', 'subscriber_badge', { target_type: 'perk', target_id: perk.id, status: 'imported' });++ }++++ // ── 2 + 3. Creators and their plan ───────────────────++ for (const id of [...creatorIds].sort((a, b) => Number(a) - Number(b))) {++ const prev = mapRow.get('live', 'users', id);++ const subject = subjectOf(id);++ const u = byLiveId.get(id);++ if (!subject) {++ const reason = `Live user ${id}${u ? ` (${u.username})` : ''} has no Network subject yet`;++ record('live', 'users', id, { status: 'held', reason });++ report.counts.creators.held++; report.holds.push({ live_user: Number(id), reason });++ continue;++ }++ const existed = creators.bySubject(subject);++ const c = creators.ensure({ subject, username: handleOf(id), displayName: u ? u.display_name : null, origin: existed ? existed.origin : 'import' });++ if (prev && prev.status !== 'held') report.counts.creators.unchanged++;++ else { report.counts.creators[prev ? 'released' : 'imported']++; }++ record('live', 'users', id, { target_type: 'creator', target_id: c.id, status: 'imported', reason: prev && prev.status === 'held' ? 'released from hold' : null });++++ const offer = mapRow.get('live', 'offering', `channel_subscription:${id}`);++ if (offer && offer.target_id && plans.byId(offer.target_id)) { report.counts.plans.unchanged++; continue; }++ const own = plans.purchasable(c.id);++ if (own) {++ record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: own.id, status: 'linked', reason: 'the creator already publishes a plan sold as the channel subscription' });++ report.counts.plans.linked++;++ continue;++ }++ const existingSlug = db.prepare('SELECT * FROM vip_plans WHERE creator_id = ? AND slug = ?').get(c.id, PLAN_SLUG);++ if (existingSlug) {++ record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: existingSlug.id, status: 'linked', reason: `plan ${PLAN_SLUG} already exists (status ${existingSlug.status})` });++ report.counts.plans.linked++;++ continue;++ }++ const name = c.display_name || c.username || 'this creator';++ const plan = plans.create({++ creatorId: c.id, slug: PLAN_SLUG, name: 'Channel subscription',++ description: `A monthly subscription to ${name}'s channel, carried over from OpenVibe.Live.`,++ benefits: [`A subscriber badge next to your name in ${name}'s chat`],++ perks: [PERK_KEY], billingKind: 'channel_subscription', publish: true, changeNote: 'imported from OpenVibe.Live', actor: ACTOR,++ });++ record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: plan.id, status: 'imported' });++ report.counts.plans.created++;++ }++++ // ── 4. Memberships from Billing ──────────────────────++ for (const [creatorSubject, list] of billingSubs) {++ if (!list) continue;++ for (const sub of list) {++ const member = sub.subscriber && sub.subscriber.id;++ const prev = mapRow.get('billing', 'subscriptions', sub.id);++ if (sub.status !== 'active') {++ if (!prev) {++ const reason = `Billing subscription is ${sub.status}: no current membership to file terms for`;++ record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });++ report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });++ } else report.counts.memberships.unchanged++;++ continue;++ }++ const e = ents.get(`${member}:${creatorSubject}`) || { active: false, expires_at: null };++ const out = entitlements.apply({++ member, creator: creatorSubject, active: !!e.active, expiresAt: e.expires_at || null,++ cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id, subscriptionStatus: sub.status,++ reason: 'imported', source: 'import', origin: 'import', asOf: domain.now(),++ });++ const m = out.membership;++ if (!e.active || !m) {++ const reason = 'Billing lists the subscription as active but its entitlement is not active now';++ record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });++ report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });++ continue;++ }++ if (prev && prev.status === 'imported' && prev.target_id === m.id) report.counts.memberships.unchanged++;++ else report.counts.memberships.imported++;++ record('billing', 'subscriptions', sub.id, { target_type: 'membership', target_id: m.id, status: 'imported' });++ }++ }++++ // ── 5. Live's subscriptions rows ─────────────────────++ const membershipFor = db.prepare('SELECT m.id FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?');++ for (const s of liveSubs) {++ const prev = mapRow.get('live', 'subscriptions', String(s.id));++ const member = subjectOf(s.subscriber_id);++ const creator = subjectOf(s.streamer_id);++ let fields;++ if (!member || !creator) {++ fields = { status: 'held', reason: `${!member ? `subscriber (Live user ${s.subscriber_id})` : `creator (Live user ${s.streamer_id})`} has no Network subject yet` };++ } else {++ const m = membershipFor.get(member, creator);++ const bsub = (billingSubs.get(creator) || []).find((x) => x.subscriber && x.subscriber.id === member);++ if (m) fields = { status: 'linked', target_type: 'membership', target_id: m.id };++ else if (bsub) fields = { status: 'linked', target_type: 'billing_subscription', target_id: bsub.id, reason: `Billing holds it (status ${bsub.status}); no current membership` };++ else if (billingSubs.get(creator) === null) fields = { status: 'held', reason: 'Billing was not reachable for this creator in this run' };++ else fields = { status: 'held', reason: 'Billing has no subscription for this pair (run Billing\'s Live import first)' };++ }++ if (prev && prev.status === fields.status && prev.target_id === (fields.target_id || null)) { report.counts.live_subscriptions.unchanged++; continue; }++ record('live', 'subscriptions', s.id, fields);++ report.counts.live_subscriptions[fields.status === 'held' ? 'held' : 'linked']++;++ if (fields.status === 'held') report.holds.push({ live_subscription: s.id, reason: fields.reason });++ }++ if (dryRun) throw new DryRun();++ };++++ try {++ db.transaction(run)();++ } catch (e) {++ if (!(e instanceof DryRun)) throw e;++ }++ report.finished_at = iso(domain.now());++ report.ok = report.billing_errors.length === 0;++ if (!billingSource) report.note = 'no Billing source: memberships were not imported';++ if (log && log.debug) log.debug(report);++ return report;++}++++module.exports = { importLive, billingApiSource, billingSnapshotSource, PLAN_SLUG, PERK_KEY };nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++++/**++ * OpenVibe.VIP — process entry. `node server/index.js`++ * Listens on PORT (4620) behind nginx (openvibe.vip); see deploy/.++ *++ * Background jobs (VIP_JOBS=off disables them): re-confirming with Billing the projections products++ * are using before they go stale, pruning sent outbox rows, and the events outbox relay when++ * EVENTS_URL is set.++ */++const { loadConfig } = require('./config');++const { createApp } = require('./app');++++const config = loadConfig();++if (config.isProduction && !config.formSecret) {++ console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');++ process.exit(1);++}++const app = createApp({ config });++const { domain, keys, outbox, metrics } = app.locals;++keys.start();++++const timers = [];++if (config.jobs.enabled) {++ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };++ every(config.jobs.refreshIntervalMs, () => domain.entitlements.refreshDue());++ every(6 * 3600 * 1000, () => outbox.outbox.prune());++ outbox.start();++}++++const server = app.listen(config.port, config.host, () => {++ console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);++});++server.keepAliveTimeout = 65_000;++++function shutdown(signal) {++ console.log(`[VIP] ${signal} — closing`);++ timers.forEach(clearInterval);++ outbox.stop();++ keys.stop();++ if (metrics && metrics.stop) metrics.stop();++ server.close(() => { try { domain.db.close(); } catch { /* */ } process.exit(0); });++ setTimeout(() => process.exit(0), 5000).unref();++}++process.on('SIGTERM', () => shutdown('SIGTERM'));++process.on('SIGINT', () => shutdown('SIGINT'));nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++++/**++ * OpenVibe.Network client (the same shape as OpenVibe.Tips and OpenVibe.Community).++ *++ * 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.vip) and the browser's Network user JWT, both offline.++ * identity Live user ids → canonical subjects via POST /internal/identity/resolve-batch++ * (capability identity.subject.resolve, audience openvibe.network) — the importer only.++ */++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++const jwt = require('jsonwebtoken');++const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++++const BATCH = 500;++++function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {++ let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;++ let timer = null;++ async function load() {++ const url = `${config.network.internalUrl}/api/.well-known/jwks`;++ try {++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });++ if (!res.ok) throw new Error(`JWKS ${res.status}`);++ const body = await res.json();++ const jwk = (body.keys || []).find((k) => k.kty === 'RSA');++ if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });++ else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });++ else throw new Error('JWKS contained no keys');++ return pem;++ } catch (e) {++ log.warn(`[VIP] Network key not loaded from ${url}: ${e.message}`);++ return null;++ }++ }++ function start() {++ if (config.network.publicKey || timer) return;++ const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });++ retry();++ timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);++ timer.unref();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++ return { get: () => pem, load, start, stop };++}++++/**++ * The OAuth client of the browser session layer plus offline verification of the Network user++ * JWT (RS256, issuer = the Network's public URL).++ */++function createUserAuth(config, keys) {++ const client = new OpenVibeAuthClient({++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ redirectUri: config.oauth.redirectUri,++ publicKey: null,++ authBase: config.network.url,++ internalBase: config.network.internalUrl,++ });++ /** Claims of a valid user token, or null. Service tokens are never user tokens. */++ function verify(token) {++ if (!token) return null;++ const key = keys.get();++ if (!key) return null;++ let claims;++ try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }++ if (!claims || typeof claims !== 'object') return null;++ if (typeof claims.sub === 'string' && /^(svc|app|mod):/.test(claims.sub)) return null;++ if (claims.actor_type === 'service') return null;++ return claims;++ }++ return { client, verify };++}++++function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {++ const base = config.network.internalUrl;++ let tokens = null;++ const tokenClient = () => {++ if (!tokens) {++ tokens = serviceAuth.createTokenClient({++ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,++ });++ }++ return tokens;++ };++++ async function post(body, retried = false) {++ const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokenClient().authHeaders()) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(10000),++ });++ if (res.status === 401 && !retried) { tokenClient().invalidate(); return post(body, true); }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);++ return data.results;++ }++++ /** Live user ids → Map(String(id) → { subject, username } | null). */++ async function resolveLiveUsers(liveIds) {++ const out = new Map();++ const all = [...new Set(liveIds.map(String))];++ for (let i = 0; i < all.length; i += BATCH) {++ const chunk = all.slice(i, i + BATCH);++ const results = await post({ system: 'live', type: 'user', ids: chunk });++ for (const k of chunk) {++ const p = results[k];++ out.set(k, p && p.subject && p.subject.type === 'user' ? { subject: p.subject.id, username: p.username || null } : null);++ }++ }++ return out;++ }++ return { resolveLiveUsers };++}++++module.exports = { createKeyProvider, createUserAuth, createIdentity };nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++/**++ * Track O: truthful readiness for GET /api/ready (openvibe-shared/ready).++ *++ * db required a real query on VIP's SQLite (the schema is there and answers)++ * network_jwks optional the Network signing key has loaded. Without it public plan pages still++ * render, but no service or user token can be verified (API calls and++ * sign-in answer 503), so it degrades rather than fails++ * billing optional Billing answers /api/health. Without it the projection still answers++ * while fresh; checkout, cancel, members lists and authoritative checks++ * fail closed (never "yes")++ *++ * `details` reports the outbox and the projection's size and staleness (counts only, no subjects).++ * Request metrics come from openvibe-shared/metrics in app.js; gauges for the outbox and projection++ * are registered there too.++ */++const { createReadiness } = require('openvibe-shared/ready');++++function createVipReadiness({ db, keys, config, outbox, now, release = null, fetchImpl = globalThis.fetch }) {++ return createReadiness({++ service: 'vip',++ release,++ checks: [++ { name: 'db', required: true, check: () => db.prepare('SELECT COUNT(*) AS n FROM vip_creators').get().n >= 1 || 'database has no network creator row' },++ {++ name: 'network_jwks', required: false,++ check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified'),++ },++ {++ name: 'billing', required: false, cacheMs: 15_000, timeoutMs: 2500,++ check: async () => {++ const res = await fetchImpl(`${config.billing.url}/api/health`, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });++ try { await res.body?.cancel(); } catch { /* not needed */ }++ return res.ok ? { ok: true, detail: { http_status: res.status } } : { ok: false, error: `answered HTTP ${res.status}` };++ },++ },++ ],++ details: (body) => {++ if (body.checks.db.status !== 'ok') return {};++ const t = now();++ const p = db.prepare('SELECT COUNT(*) AS n, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t);++ return {++ events: outbox.status(),++ billing_events_accepted: config.events.webhookSecrets.length > 0,++ projection: { rows: p.n || 0, past_valid_until: p.stale || 0, max_age_ms: config.projection.maxAgeMs, grace_ms: config.projection.graceMs },++ };++ },++ });++}++++module.exports = { createVipReadiness };nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++++/** Small shared pieces: ids, time, the error type, input checks. */++const { ids, validate } = require('openvibe-contracts');++++class VipError extends Error {++ constructor(status, code, detail, extra) {++ super(detail || code);++ this.status = status;++ this.code = code;++ this.detail = detail;++ this.extra = extra;++ }++}++++function fail(status, code, detail, extra) { throw new VipError(status, code, detail, extra); }++++const prefixedId = (prefix, ms = Date.now()) => `${prefix}_${ids.ulid(ms)}`;++const iso = (ms) => new Date(ms).toISOString();++const json = (v, d) => { if (v == null || v === '') return d; try { return JSON.parse(v); } catch { return d; } };++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++/** Optional trimmed text: null when empty, refused when longer than max. */++function text(v, field, max, { required = false } = {}) {++ if (v == null || v === '') { if (required) fail(422, 'vip.invalid_input', `${field} is required`); return null; }++ const s = String(v).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim();++ if (!s) { if (required) fail(422, 'vip.invalid_input', `${field} is required`); return null; }++ if (s.length > max) fail(422, 'vip.text_too_long', `${field} must be at most ${max} characters`);++ return s;++}++++const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;++function slug(v, field = 'slug') {++ const s = String(v || '').trim().toLowerCase();++ if (!SLUG_RE.test(s)) fail(422, 'vip.invalid_input', `${field} must be 1-48 of a-z, 0-9 and -`);++ return s;++}++function slugify(v) {++ const s = String(v || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);++ return s || 'plan';++}++++/** A user SubjectRef or bare usr_ id → the subject id. */++function userSubject(v, field = 'subject') {++ const ref = typeof v === 'string' ? { type: 'user', id: v } : v;++ if (!ref || !validate('identity.subject-ref@1', ref).valid || ref.type !== 'user' || !ids.isSubjectId('user', ref.id)) {++ fail(422, 'vip.invalid_subject', `${field} must be a user SubjectRef ({ type: 'user', id: 'usr_…' })`);++ }++ return ref.id;++}++const isUserSubject = (s) => typeof s === 'string' && ids.isSubjectId('user', s);++const userRef = (id) => (id ? { type: 'user', id } : null);++++/** An EntityRef { service, type, id } (common.entity-ref@1). */++function entityRef(v, field = 'resource') {++ if (!v || typeof v !== 'object' || !validate('common.entity-ref@1', v).valid) {++ fail(422, 'vip.invalid_input', `${field} must be an EntityRef ({ service, type, id })`);++ }++ return { service: String(v.service), type: String(v.type), id: String(v.id) };++}++++function bool(v, d = false) {++ if (v == null || v === '') return d;++ if (typeof v === 'boolean') return v;++ return ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase());++}++++module.exports = { VipError, fail, prefixedId, iso, json, esc, text, slug, slugify, userSubject, isUserSubject, userRef, entityRef, bool };nnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++++/**++ * Anti-forgery tokens for the server-rendered forms: HMAC(VIP_FORM_SECRET, subject | hour), valid++ * for the current and the previous hour. The session cookies are SameSite=Lax too, so a cross-site++ * POST does not carry them; the token is the second lock.++ */++const crypto = require('crypto');++++function createForms({ secret, now = () => Date.now() }) {++ const sign = (subject, bucket) => crypto.createHmac('sha256', String(secret)).update(`${subject}|${bucket}`).digest('base64url').slice(0, 32);++ const bucket = () => Math.floor(now() / 3600_000);++ function token(subject) { return secret && subject ? sign(subject, bucket()) : ''; }++ function verify(subject, given) {++ if (!secret || !subject || typeof given !== 'string' || given.length !== 32) return false;++ const b = bucket();++ return [b, b - 1].some((x) => {++ const want = Buffer.from(sign(subject, x));++ const got = Buffer.from(given);++ return want.length === got.length && crypto.timingSafeEqual(want, got);++ });++ }++ return { token, verify };++}++++module.exports = { createForms };nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++++/**++ * Page shell for every server-rendered page: full <head> SEO (title, description, canonical,++ * robots, Open Graph), the shared OpenVibe chrome (inline critical canvas + app icon from++ * openvibe-shared, navbar.js from the Network, the SSR footer and a <noscript> navigation), this++ * site's stylesheet and its small progressive script. Everything is useful without JavaScript.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const appIcon = require('openvibe-shared/app-icon');++const chrome = require('openvibe-shared/chrome-ssr');++++const SITE_NAME = 'OpenVibe.VIP';++const NETWORK_URL = 'https://openvibe.network';++const DEFAULT_DESCRIPTION = 'Memberships on the OpenVibe network: join a creator\'s plan, see the terms you joined under and the perks that come with it on every OpenVibe site.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++const _hashes = new Map();++function asset(rel) {++ if (!_hashes.has(rel)) {++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing in tests */ }++ _hashes.set(rel, v);++ }++ return `/${rel}?v=${_hashes.get(rel)}`;++}++const assetVersion = (rel) => { asset(rel); return _hashes.get(rel); };++++const NAV_LINKS = [++ { label: 'Memberships', href: '/me' },++ { label: 'Dashboard', href: '/dashboard' },++];++++function createLayout({ config, release }) {++ const abs = (p) => (/^https?:\/\//i.test(p) ? p : `${config.baseUrl}${p.startsWith('/') ? '' : '/'}${p}`);++++ /**++ * o: title, description, canonicalPath, robots ('index,follow' | 'noindex,nofollow'), body,++ * active ('home'|'memberships'|'dashboard'|'creator'), ogImage, jsonLd (array), viewer++ */++ function page(o) {++ const title = o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — memberships across OpenVibe`;++ const description = (o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300);++ const canonical = abs(o.canonicalPath || '/');++ const robots = o.robots || 'index,follow';++ const nav = {++ service: 'vip', apiBase: NETWORK_URL,++ links: NAV_LINKS.map((l) => ({ ...l, active: o.active === l.label.toLowerCase() })),++ history: { type: 'page', title: o.title || SITE_NAME },++ silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: `/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}`,++ };++ const jsonLd = (o.jsonLd || []).map((x) => `<script type="application/ld+json">${JSON.stringify(x).replace(/</g, '\\u003c')}</script>`).join('\n');++ const who = o.viewer++ ? `<span class="who">Signed in as <b>${esc(o.viewer.name || o.viewer.username || 'you')}</b> · <a href="/auth/logout?next=/">Sign out</a></span>`++ : `<a class="who" href="/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}">Sign in</a>`;++ return `<!DOCTYPE html>++<html lang="en" data-page="${esc(o.active || 'page')}">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>${esc(title)}</title>++<meta name="description" content="${esc(description)}">++<link rel="canonical" href="${esc(canonical)}">++<meta name="robots" content="${esc(robots)}">++<meta name="referrer" content="strict-origin-when-cross-origin">++<meta property="og:site_name" content="${SITE_NAME}">++<meta property="og:type" content="${o.ogType || 'website'}">++<meta property="og:title" content="${esc(o.title || SITE_NAME)}">++<meta property="og:description" content="${esc(description)}">++<meta property="og:url" content="${esc(canonical)}">++${o.ogImage ? `<meta property="og:image" content="${esc(o.ogImage)}">` : ''}++<meta name="twitter:card" content="summary">++${appIcon.headTags({ site: 'vip' })}++${release ? release.metaTag() : ''}++<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>++<link rel="stylesheet" href="${asset('css/vip.css')}">++${jsonLd}++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>++<script src="${asset('js/vip.js')}" defer></script>++</head>++<body>++<div id="navbar-mount"></div>++${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: NAV_LINKS })}++<header class="site-head"><a class="brand" href="/">${SITE_NAME}</a><nav>${NAV_LINKS.map((l) => `<a href="${l.href}"${o.active === l.label.toLowerCase() ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav>${who}</header>++<main id="main" class="page">++${o.body || ''}++</main>++${chrome.footer({ service: 'vip', variant: 'full' })}++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeNavbar) { OpenVibeNavbar.init(window.__OV_PAGE.navbar); document.documentElement.classList.add('ov-has-navbar'); } } catch (e) { /* the SSR header stays */ }++});++</script>++</body>++</html>`;++ }++++ return { page, abs };++}++++module.exports = { createLayout, esc, asset, assetVersion, SITE_NAME, DEFAULT_DESCRIPTION };nnew file mode 100644@@@ -0,0 +1,227 @@++'use strict';++++/**++ * Page bodies (HTML strings) for the server-rendered routes. Everything here works without++ * JavaScript: forms post to the server, which redirects back with a notice.++ *++ * Copy rules: say what a plan includes and what Billing charges; never "free", "$0" or "no ads", and++ * no begging copy.++ */++const { esc } = require('../util');++++const fmtDate = (s) => {++ if (!s) return '';++ const d = new Date(s);++ return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);++};++const money = (cents) => `$${(Number(cents) / 100).toFixed(2)}`;++const csrf = (t) => `<input type="hidden" name="_csrf" value="${esc(t)}">`;++++function notice(q) {++ if (!q) return '';++ if (q.error) return `<p class="notice notice-error" role="alert">${esc(q.error)}</p>`;++ if (q.ok) return `<p class="notice" role="status">${esc(q.ok)}</p>`;++ return '';++}++++function priceLine(rates) {++ const sub = rates && rates.subscription;++ if (!sub || !sub.price_cents) return '<p class="price muted">OpenVibe.Billing sets the price; it is shown at checkout.</p>';++ return `<p class="price"><b>${money(sub.price_cents)}</b> every ${esc(sub.period_days)} days, charged and renewed by OpenVibe.Billing.</p>`;++}++++function perkList(perks) {++ if (!perks || !perks.length) return '';++ return `<ul class="perks">${perks.map((p) => `<li><span class="perk-name">${esc(p.name)}</span></li>`).join('')}</ul>`;++}++function benefitList(benefits) {++ if (!benefits || !benefits.length) return '';++ return `<ul class="benefits">${benefits.map((b) => `<li>${esc(b)}</li>`).join('')}</ul>`;++}++++function errorPage({ status, title, message }) {++ return `<section class="card narrow"><p class="eyebrow">${esc(status)}</p><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.VIP home</a></p></section>`;++}++++function home({ creators }) {++ const list = creators.length++ ? `<ul class="creator-list">${creators.map((c) => `<li><a href="/${encodeURIComponent(c.username)}"><b>${esc(c.display_name || c.username)}</b><span class="muted">@${esc(c.username)}</span></a></li>`).join('')}</ul>`++ : '<p class="muted">No creator has published a plan here yet.</p>';++ return `<section class="hero">++<h1>Memberships across OpenVibe</h1>++<p>Join a creator's plan once and your membership is recognised on every OpenVibe site that honours it: a badge in chat, members-only posts, rooms and more, as each plan lists.</p>++<p class="muted">Payments, renewals, cancellations and refunds are handled by OpenVibe.Billing. VIP keeps the plans, the exact terms you joined under, and the perks that come with them.</p>++<p><a class="button" href="/me">Your memberships</a> <a class="button button-quiet" href="/dashboard">Offer a plan</a></p>++</section>++<section><h2>Creators with plans</h2>${list}</section>`;++}++++/** The creator's public plan page. */++function creatorPage({ creator, plans, viewer, member, rates, providers, token, query, isOwner }) {++ const name = creator.display_name || creator.username;++ const status = member && member.status === 'active'++ ? `<div class="card member-box"><p><b>You are a member.</b> ${member.cancel_at_period_end ? `Your membership ends on ${fmtDate(member.expires_at)}.` : `Paid through ${fmtDate(member.expires_at)}.`}</p>++${member.membership && member.membership.plan_version ? `<p class="muted">You joined under ${esc(member.membership.plan_version.name)} v${esc(member.membership.plan_version.version)}. Those terms stay yours while the membership lasts.</p>` : ''}<p><a href="/me">Manage your memberships</a></p></div>`++ : member && member.status === 'unknown'++ ? '<div class="card member-box"><p class="muted">We could not confirm your membership with OpenVibe.Billing just now. Nothing about it has changed; try again shortly.</p></div>'++ : '';++ const planCards = plans.length ? plans.map((p) => {++ const v = p.current_version;++ const join = !p.purchasable++ ? '<p class="muted">This plan is not sold through Billing yet.</p>'++ : isOwner ? '<p class="muted">This is your plan.</p>'++ : member && member.status === 'active' ? ''++ : viewer++ ? `<form method="post" action="/${encodeURIComponent(creator.username)}/join" class="join">${csrf(token)}<input type="hidden" name="plan_id" value="${esc(p.id)}">++<label>Pay with <select name="provider">${providers.map((x) => `<option value="${esc(x)}">${esc(x === 'credit' ? 'your Vibes credit' : x === 'powerchat' ? 'PowerChat' : x)}</option>`).join('')}</select></label>++<label class="check"><input type="checkbox" name="auto_renew" value="1" checked> Renew automatically</label>++<button type="submit">Join ${esc(v.name)}</button></form>`++ : `<p><a class="button" href="/auth/login?next=${encodeURIComponent(`/${creator.username}`)}">Sign in to join</a></p>`;++ return `<article class="card plan" id="plan-${esc(p.slug)}">++<h2>${esc(v.name)}</h2>++${v.description ? `<p>${esc(v.description)}</p>` : ''}++${benefitList(v.benefits)}++${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}++${p.purchasable ? priceLine(rates) : ''}++${join}++<p class="muted small">Version ${esc(v.version)} · published ${fmtDate(v.published_at)} · <a href="/${encodeURIComponent(creator.username)}/plans/${encodeURIComponent(p.slug)}">terms history</a></p>++</article>`;++ }).join('') : '<p class="muted">No plan is published yet.</p>';++ return `<section class="creator-head"><p class="eyebrow">Membership</p><h1>${esc(name)}</h1><p class="muted">@${esc(creator.username)}</p>++${creator.bio ? `<p>${esc(creator.bio)}</p>` : ''}</section>++${notice(query)}++${status}++<section class="plans">${planCards}</section>`;++}++++/** Public terms history of one plan: every published version, unchanged. */++function planHistory({ creator, plan, versions }) {++ return `<section><p class="eyebrow"><a href="/${encodeURIComponent(creator.username)}">${esc(creator.display_name || creator.username)}</a></p>++<h1>Terms history</h1>++<p class="muted">Each edit of a plan is a new version. Members keep the version they joined under; earlier versions are never rewritten.</p>++${versions.map((v) => `<article class="card version"><h2>${esc(v.name)} <span class="muted">v${esc(v.version)}</span></h2>++<p class="muted small">Published ${fmtDate(v.published_at)}${v.change_note ? ` · ${esc(v.change_note)}` : ''}</p>++${v.description ? `<p>${esc(v.description)}</p>` : ''}${benefitList(v.benefits)}${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}</article>`).join('')}++<p><a href="/${encodeURIComponent(creator.username)}#plan-${esc(plan.slug)}">Back to the plan</a></p></section>`;++}++++/** The member's own status page. */++function memberPage({ viewer, rows, token, query }) {++ if (!viewer) {++ return `<section class="card narrow"><h1>Your memberships</h1><p>Sign in with your OpenVibe account to see the plans you belong to and the terms you joined under.</p>++<p><a class="button" href="/auth/login?next=%2Fme">Sign in</a></p></section>`;++ }++ const body = rows.length ? rows.map(({ membership: m, creator: c, entitlement: e, preferences: pref }) => {++ const who = c ? (c.username ? `<a href="/${encodeURIComponent(c.username)}">${esc(c.display_name || c.username)}</a>` : esc(c.display_name || 'a creator')) : 'a creator';++ const state = !e ? '<span class="pill">unknown</span>'++ : e.status === 'active' ? `<span class="pill pill-on">active</span> ${e.cancel_at_period_end ? `ends ${fmtDate(e.expires_at)}` : `paid through ${fmtDate(e.expires_at)}`}`++ : e.status === 'inactive' ? `<span class="pill">ended</span>${e.expires_at ? ` ${fmtDate(e.expires_at)}` : ''}`++ : '<span class="pill">unconfirmed</span> OpenVibe.Billing did not answer just now; nothing changed';++ const v = m.plan_version;++ const cancel = e && e.status === 'active' && !e.cancel_at_period_end++ ? `<form method="post" action="/me/${encodeURIComponent(m.creator_id)}/cancel">${csrf(token)}<button type="submit" class="button-quiet">Cancel at period end</button></form>` : '';++ return `<article class="card membership"><h2>${who}</h2><p>${state}</p>++${v ? `<p>Joined under <b>${esc(v.name)}</b> v${esc(v.version)}${v.terms && v.terms.perks && v.terms.perks.length ? ` · perks: ${v.terms.perks.map((p) => esc(p.name)).join(', ')}` : ''}</p>` : '<p class="muted">Billing records this membership; the creator has no VIP plan for it.</p>'}++<form method="post" action="/me/${encodeURIComponent(m.creator_id)}/preferences" class="prefs">${csrf(token)}++<label class="check"><input type="checkbox" name="show_badge" value="1"${pref.show_badge ? ' checked' : ''}> Show my member badge</label>++<button type="submit" class="button-quiet">Save</button></form>++${cancel}</article>`;++ }).join('') : '<p class="muted">You have no memberships yet.</p>';++ return `<section><h1>Your memberships</h1>${notice(query)}${body}++<p class="muted small">Charges, renewals and refunds are OpenVibe.Billing's; the status shown here is confirmed with Billing when it is not recent.</p></section>`;++}++++function checkoutPage({ creator, out }) {++ const ref = out.checkout_ref;++ return `<section class="card narrow"><h1>Finish joining ${esc(creator.display_name || creator.username)}</h1>++${out.checkout_url ? `<p><a class="button" href="${esc(out.checkout_url)}" rel="noopener">Continue to payment</a></p>` : ''}++${ref && !out.checkout_url ? `<p>Complete the payment on PowerChat with this reference: <code>${esc(ref)}</code>${out.amount_cents ? ` (${money(out.amount_cents)})` : ''}.</p>` : ''}++<p class="muted">Your membership starts as soon as OpenVibe.Billing confirms the payment; <a href="/me">your memberships</a> shows it then.</p></section>`;++}++++// ── Dashboard ────────────────────────────────────────────────++function perkChoices(allPerks, selectedKeys) {++ if (!allPerks.length) return '<p class="muted small">No perks yet — define one below.</p>';++ return `<fieldset class="perk-choices"><legend>Perks</legend>${allPerks.map((p) => `<label class="check"><input type="checkbox" name="perks" value="${esc(p.key)}"${selectedKeys.includes(p.key) ? ' checked' : ''}> ${esc(p.name)}${p.scope === 'network' ? ' <span class="muted small">(network)</span>' : ''}</label>`).join('')}</fieldset>`;++}++++function planEditor({ plan, allPerks, token, base }) {++ const v = plan.draft_version || plan.current_version;++ const keys = v ? v.perks.map((p) => p.key) : [];++ const versions = (plan.versions || []).map((x) => `<li>v${esc(x.version)} · ${esc(x.name)} · ${x.published_at ? `published ${fmtDate(x.published_at)}` : 'draft'}${x.change_note ? ` · ${esc(x.change_note)}` : ''}${x.perks.length ? ` · perks: ${x.perks.map((p) => esc(p.key)).join(', ')}` : ''}</li>`).join('');++ const actions = [++ plan.status === 'draft' ? `<form method="post" action="${base}/plans/${esc(plan.id)}/publish" class="inline">${csrf(token)}<button type="submit">Publish</button></form>` : '',++ plan.status !== 'archived' ? `<form method="post" action="${base}/plans/${esc(plan.id)}/archive" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Archive</button></form>` : '',++ ].join(' ');++ return `<article class="card plan-editor"><h3>${esc(v ? v.name : plan.slug)} <span class="pill">${esc(plan.status)}</span></h3>++<p class="muted small">${plan.billing_kind ? 'Sold through OpenVibe.Billing as a channel subscription.' : 'Not sold through Billing (terms only).'} ${plan.current_version ? `New members buy v${esc(plan.current_version.version)}.` : ''}</p>++${plan.status !== 'archived' ? `<details><summary>Edit (creates version ${esc((plan.versions || []).length + 1)})</summary>++<form method="post" action="${base}/plans/${esc(plan.id)}">${csrf(token)}++<label>Name <input name="name" maxlength="80" required value="${esc(v ? v.name : '')}"></label>++<label>Description <textarea name="description" maxlength="2000" rows="3">${esc(v && v.description ? v.description : '')}</textarea></label>++<label>Benefits, one per line <textarea name="benefits" rows="3">${esc(v ? v.benefits.join('\n') : '')}</textarea></label>++${perkChoices(allPerks, keys)}++<label>What changed <input name="change_note" maxlength="300"></label>++<p class="muted small">Members who already joined keep the version they joined under.</p>++<button type="submit">Save as a new version</button></form></details>` : ''}++${actions}++<details><summary>Versions (${esc((plan.versions || []).length)})</summary><ul class="versions">${versions}</ul></details></article>`;++}++++function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base }) {++ if (!viewer) {++ return `<section class="card narrow"><h1>Creator dashboard</h1><p>Sign in with your OpenVibe account to offer a membership plan.</p>++<p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;++ }++ const title = isNetwork ? 'Network plans' : 'Your membership plans';++ const pageLink = !isNetwork && creator.username ? `<p class="muted">Your public page: <a href="/${encodeURIComponent(creator.username)}">openvibe.vip/${esc(creator.username)}</a></p>` : '';++ const planOptions = plans.filter((p) => p.status !== 'archived').map((p) => `<option value="${esc(p.id)}">${esc((p.current_version || p.draft_version || {}).name || p.slug)}</option>`).join('');++ return `<section><h1>${title}</h1>${isStaff ? `<p class="muted small">${isNetwork ? '<a href="/dashboard">Your own plans</a>' : '<a href="/dashboard?as=network">Network plans (staff)</a>'}</p>` : ''}${pageLink}${notice(query)}</section>++${isNetwork ? '' : `<section class="card"><h2>Profile</h2><form method="post" action="${base}/profile">${csrf(token)}++<label>Display name <input name="display_name" maxlength="80" value="${esc(creator.display_name || '')}"></label>++<label>About your memberships <textarea name="bio" maxlength="2000" rows="3">${esc(creator.bio || '')}</textarea></label>++<button type="submit">Save</button></form></section>`}++<section><h2>Plans</h2>${plans.length ? plans.map((p) => planEditor({ plan: p, allPerks, token, base })).join('') : '<p class="muted">No plans yet.</p>'}++<details class="card"><summary>New plan</summary><form method="post" action="${base}/plans">${csrf(token)}++<label>Name <input name="name" maxlength="80" required></label>++<label>Description <textarea name="description" maxlength="2000" rows="3"></textarea></label>++<label>Benefits, one per line <textarea name="benefits" rows="3"></textarea></label>++${perkChoices(allPerks, [])}++<label class="check"><input type="checkbox" name="publish" value="1"> Publish now</label>++<p class="muted small">${isNetwork ? 'Network plans are terms only until Billing sells a network membership.' : 'A published plan is sold through OpenVibe.Billing as your channel subscription; Billing sets the price. One published plan can be sold at a time.'}</p>++<button type="submit">Create plan</button></form></details></section>++<section><h2>Perks</h2>++${ownPerks.length ? `<ul class="perk-admin">${ownPerks.map((p) => `<li><details><summary><b>${esc(p.name)}</b> <span class="muted small">${esc(p.key)} · ${esc(p.kind)} · ${esc(p.status)}</span></summary>++<form method="post" action="${base}/perks/${esc(p.id)}">${csrf(token)}++<label>Name <input name="name" maxlength="80" value="${esc(p.name)}"></label>++<label>Description <input name="description" maxlength="500" value="${esc(p.description || '')}"></label>++<label>Product bindings, one "product binding" per line <textarea name="bindings" rows="2">${esc(p.bindings.map((b) => `${b.product} ${b.binding}`).join('\n'))}</textarea></label>++<label>Status <select name="status"><option value="active"${p.status === 'active' ? ' selected' : ''}>active</option><option value="retired"${p.status === 'retired' ? ' selected' : ''}>retired</option></select></label>++<p class="muted small">Renaming a perk does not change the terms of published versions.</p>++<button type="submit">Save perk</button></form></details></li>`).join('')}</ul>` : '<p class="muted">No perks of your own yet.</p>'}++<details class="card"><summary>New perk</summary><form method="post" action="${base}/perks">${csrf(token)}++<label>Name <input name="name" maxlength="80" required></label>++<label>Key (a-z, 0-9, -) <input name="key" maxlength="48" pattern="[a-z0-9][a-z0-9-]*"></label>++<label>Description <input name="description" maxlength="500"></label>++<label>Kind <select name="kind">${['badge', 'emote', 'gated_content', 'room', 'role', 'other'].map((k) => `<option>${k}</option>`).join('')}</select></label>++<label>Product bindings, one "product binding" per line (e.g. <code>chat badge</code>) <textarea name="bindings" rows="2"></textarea></label>++<button type="submit">Create perk</button></form></details></section>++${isNetwork ? '' : `<section><h2>Members</h2>++${members.length ? `<table class="members"><thead><tr><th>Member</th><th>Plan version</th><th>Paid through</th></tr></thead><tbody>${members.map((m) => `<tr><td><code>${esc(m.member.id)}</code></td><td>${m.membership && m.membership.plan_version ? `${esc(m.membership.plan_version.name)} v${esc(m.membership.plan_version.version)}` : '—'}</td><td>${fmtDate(m.current_period_end)}${m.cancel_at_period_end ? ' (ends)' : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No active members.</p>'}++<p class="muted small">${membersSource === 'billing' ? 'From OpenVibe.Billing.' : 'OpenVibe.Billing did not answer; this is VIP\'s recent projection and may be incomplete.'}</p></section>++<section><h2>Members-only resources</h2>++<p class="muted small">A product (Blog, Wiki, Community, Chat, Live) asks VIP whether a viewer may see a resource you gate here; anything VIP cannot confirm is refused.</p>++${rules.length ? `<ul class="rules">${rules.map((r) => `<li><code>${esc(r.resource.service)}/${esc(r.resource.type)}/${esc(r.resource.id)}</code> · ${esc(r.requirement)}${r.perk_key ? ` ${esc(r.perk_key)}` : ''}${r.sensitive ? ' · always checked with Billing' : ''}++<form method="post" action="${base}/rules/${esc(r.id)}/disable" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Remove</button></form></li>`).join('')}</ul>` : ''}++<details class="card"><summary>Gate a resource</summary><form method="post" action="${base}/rules">${csrf(token)}++<label>Product <input name="service" required placeholder="blog" pattern="[a-z][a-z0-9-]{1,39}"></label>++<label>Type <input name="type" required placeholder="post" pattern="[a-z][a-z0-9_]{1,39}"></label>++<label>Id <input name="id" required maxlength="128"></label>++<label>Who may see it <select name="requirement"><option value="member">any member</option><option value="plan">members of one plan</option><option value="perk">members whose plan includes a perk</option></select></label>++<label>Plan <select name="plan_id"><option value="">—</option>${planOptions}</select></label>++<label>Perk <select name="perk_key"><option value="">—</option>${allPerks.map((p) => `<option value="${esc(p.key)}">${esc(p.name)}</option>`).join('')}</select></label>++<label class="check"><input type="checkbox" name="sensitive" value="1"> Always check with Billing (slower; for sensitive content)</label>++<button type="submit">Save rule</button></form></details></section>`}`;++}++++module.exports = { errorPage, home, creatorPage, planHistory, memberPage, checkoutPage, dashboard, fmtDate };nnew file mode 100644@@@ -0,0 +1,251 @@++'use strict';++++/**++ * Server-rendered pages (useful without JavaScript):++ *++ * GET / directory of creators with published plans++ * GET /me the viewer's memberships, the version each was bought under, cancel++ * GET /dashboard[?as=network] creator dashboard: plans, versions, perks, members, gated resources++ * GET /:username a creator's plan page (join through Billing)++ * GET /:username/plans/:slug that plan's public terms history++ * GET /robots.txt, /sitemap.xml++ *++ * Every POST needs the signed-in viewer and the form's anti-forgery token; it acts through the same++ * domain calls as the API and redirects back with a notice (?ok= / ?error=).++ */++const express = require('express');++const { VipError, bool } = require('../util');++const { viewerMiddleware } = require('./session');++const { createForms } = require('./forms');++const pages = require('./pages');++++const RESERVED = new Set(['me', 'dashboard', 'auth', 'api', 'internal', 'css', 'js', 'terms', 'privacy', 'dmca', 'robots.txt', 'sitemap.xml', 'release.json', 'metrics', 'favicon.ico']);++++function createWebRoutes({ domain, config, layout, userAuth }) {++ const router = express.Router();++ const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;++ const forms = createForms({ secret: config.formSecret, now: domain.now });++ const isStaff = (v) => !!v && (config.staffRoles || []).includes(v.role);++ router.use(viewerMiddleware(userAuth));++ const body = express.urlencoded({ extended: false, limit: '32kb' });++++ let ratesCache = { at: 0, value: null };++ async function rates() {++ if (domain.now() - ratesCache.at < config.billing.ratesTtlMs) return ratesCache.value;++ try { ratesCache = { at: domain.now(), value: await billing.rates() }; } catch { ratesCache = { at: domain.now() - config.billing.ratesTtlMs + 30_000, value: null }; }++ return ratesCache.value;++ }++++ const send = (res, status, o) => res.status(status).type('html').send(layout.page(o));++ const notFound = (req, res) => send(res, 404, { title: 'Not found', robots: 'noindex', viewer: req.viewer, body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) });++ const back = (res, path, kind, msg) => res.redirect(303, `${path}${path.includes('?') ? '&' : '?'}${kind}=${encodeURIComponent(msg)}`);++ const list = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]).map(String).filter(Boolean);++ const lines = (v) => String(v || '').split('\n').map((s) => s.trim()).filter(Boolean);++++ /** A POST handler: viewer + anti-forgery token, VipError → redirect with the message. */++ function post(path, handler, { backTo } = {}) {++ router.post(path, body, async (req, res, next) => {++ const to = typeof backTo === 'function' ? backTo(req) : (backTo || req.path);++ if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(to)}`);++ if (!forms.verify(req.viewer.subject, req.body && req.body._csrf)) return back(res, to, 'error', 'That form expired. Please try again.');++ try {++ await handler(req, res, to);++ } catch (e) {++ if (e instanceof VipError) return back(res, to, 'error', e.detail || e.message);++ return next(e);++ }++ });++ }++++ router.get('/robots.txt', (req, res) => res.type('text/plain').send(`User-agent: *\nDisallow: /me\nDisallow: /dashboard\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));++ router.get('/sitemap.xml', (req, res) => {++ const urls = [`${config.baseUrl}/`, ...creators.listPublic({ limit: 5000 }).map((c) => `${config.baseUrl}/${encodeURIComponent(c.username)}`)];++ res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${u.replace(/&/g, '&')}</loc></url>`).join('\n')}\n</urlset>\n`);++ });++++ router.get('/', (req, res) => send(res, 200, {++ active: 'home', viewer: req.viewer, canonicalPath: '/',++ body: pages.home({ creators: creators.listPublic().map(creators.present) }),++ }));++++ // ── The member's page ────────────────────────────────────++ router.get('/me', async (req, res, next) => {++ try {++ const rows = [];++ if (req.viewer) {++ for (const m of memberships.forMember(req.viewer.subject)) {++ const c = creators.byId(m.creator_id);++ const e = c && c.subject ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;++ rows.push({ membership: memberships.present(m), creator: creators.present(c), entitlement: e, preferences: memberships.preferences(req.viewer.subject, m.creator_id) });++ }++ }++ send(res, 200, { title: 'Your memberships', active: 'memberships', robots: 'noindex,nofollow', canonicalPath: '/me', viewer: req.viewer, body: pages.memberPage({ viewer: req.viewer, rows, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query }) });++ } catch (e) { next(e); }++ });++ post('/me/:creatorId/cancel', async (req, res, to) => {++ const out = await checkout.cancel({ member: req.viewer.subject, creatorId: req.params.creatorId, traceparent: req.ov.traceparent });++ back(res, to, 'ok', out.already ? 'That membership was already set to end.' : `Cancelled. Your membership runs until ${pages.fmtDate(out.expires_at)}.`);++ }, { backTo: '/me' });++ post('/me/:creatorId/preferences', async (req, res, to) => {++ const c = creators.byId(req.params.creatorId);++ if (!c || !memberships.get(req.viewer.subject, c.id)) throw new VipError(404, 'vip.membership_not_found', 'no such membership');++ memberships.setPreferences(req.viewer.subject, c.id, { showBadge: bool(req.body.show_badge) });++ back(res, to, 'ok', 'Saved.');++ }, { backTo: '/me' });++++ // ── Creator dashboard ────────────────────────────────────++ /** The creator the dashboard acts for: the viewer, or the network for staff (?as=network). */++ function dashCreator(req) {++ const asNetwork = (req.query.as || (req.body && req.body.as)) === 'network';++ if (asNetwork) {++ if (!isStaff(req.viewer)) throw new VipError(403, 'vip.not_staff', 'only staff manage network plans');++ return creators.network();++ }++ return creators.ensure({ subject: req.viewer.subject, username: req.viewer.username, displayName: req.viewer.name, origin: 'self' });++ }++++ router.get('/dashboard', async (req, res, next) => {++ try {++ if (!req.viewer) return send(res, 200, { title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', viewer: null, body: pages.dashboard({ viewer: null }) });++ let creator;++ try { creator = dashCreator(req); } catch (e) { return send(res, 403, { title: 'Dashboard', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.errorPage({ status: 403, title: 'Not allowed', message: e.detail }) }); }++ const isNetwork = creator.kind === 'network';++ const planRows = plans.list({ creatorId: creator.id, includeDrafts: true, includeArchived: true }).map((p) => plans.present(p, { withVersions: true }));++ const allPerks = perks.list({ creatorId: creator.id }).map((p) => perks.present(p, { withBindings: false }));++ const ownPerks = perks.list({ creatorId: creator.id, includeNetwork: false, includeRetired: true }).map((p) => perks.present(p));++ let members = [];++ let membersSource = 'billing';++ if (!isNetwork) {++ const byMember = new Map(memberships.forCreator(creator.id).map((m) => [m.member_subject, m]));++ try {++ const out = await billing.listSubscriptions({ streamer: creator.subject, status: 'active' });++ members = (out.subscriptions || []).map((s) => ({ member: s.subscriber, current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, membership: memberships.present(byMember.get(s.subscriber.id)) }));++ } catch {++ membersSource = 'projection';++ members = entitlements.projectedMembers(creator.subject).map((pr) => ({ member: { type: 'user', id: pr.member_subject }, current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, membership: memberships.present(byMember.get(pr.member_subject)) }));++ }++ }++ send(res, 200, {++ title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,++ body: pages.dashboard({++ viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource,++ rules: isNetwork ? [] : policies.list(creator.id).map(policies.present), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',++ }),++ });++ } catch (e) { next(e); }++ });++++ // Dashboard writes: /dashboard/... for the viewer's own creator, /dashboard/network/... for staff.++ for (const base of ['/dashboard', '/dashboard/network']) {++ const withAs = (req) => { if (base === '/dashboard/network') req.query.as = 'network'; };++ const own = (req, row) => { withAs(req); const c = dashCreator(req); if (!row || row.creator_id !== c.id) throw new VipError(404, 'vip.not_found', 'not found'); return c; };++ const opts = { backTo: () => (base === '/dashboard/network' ? '/dashboard?as=network' : '/dashboard') };++ post(`${base}/profile`, async (req, res, to) => {++ withAs(req);++ const c = dashCreator(req);++ creators.update(c.id, { displayName: req.body.display_name, bio: req.body.bio });++ back(res, to, 'ok', 'Profile saved.');++ }, opts);++ post(`${base}/plans`, async (req, res, to) => {++ withAs(req);++ const c = dashCreator(req);++ const plan = plans.create({ creatorId: c.id, name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), publish: bool(req.body.publish), actor: req.viewer.subject, traceparent: req.ov.traceparent });++ back(res, to, 'ok', `Plan created${plan.status === 'published' ? ' and published' : ''}.`);++ }, opts);++ post(`${base}/plans/:id`, async (req, res, to) => {++ const plan = plans.byId(req.params.id);++ own(req, plan);++ const out = plans.update(plan.id, { name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), changeNote: req.body.change_note, actor: req.viewer.subject, traceparent: req.ov.traceparent });++ back(res, to, 'ok', out.unchanged ? 'Nothing changed.' : `Saved as version ${out.version.version}.`);++ }, opts);++ post(`${base}/plans/:id/publish`, async (req, res, to) => {++ const plan = plans.byId(req.params.id);++ own(req, plan);++ plans.publish(plan.id, { traceparent: req.ov.traceparent });++ back(res, to, 'ok', 'Published.');++ }, opts);++ post(`${base}/plans/:id/archive`, async (req, res, to) => {++ const plan = plans.byId(req.params.id);++ own(req, plan);++ plans.archive(plan.id);++ back(res, to, 'ok', 'Archived. Current members keep their terms until their membership ends.');++ }, opts);++ const bindingLines = (v) => lines(v).map((l) => { const [product, binding] = l.split(/\s+/); return { product, binding }; });++ post(`${base}/perks`, async (req, res, to) => {++ withAs(req);++ const c = dashCreator(req);++ perks.create({ creatorId: c.id, key: req.body.key || undefined, name: req.body.name, description: req.body.description, kind: req.body.kind || 'other', bindings: bindingLines(req.body.bindings), actor: req.viewer.subject });++ back(res, to, 'ok', 'Perk created.');++ }, opts);++ post(`${base}/perks/:id`, async (req, res, to) => {++ const p = perks.byId(req.params.id);++ own(req, p);++ perks.update(p.id, { name: req.body.name, description: req.body.description, status: req.body.status, bindings: bindingLines(req.body.bindings) });++ back(res, to, 'ok', 'Perk saved.');++ }, opts);++ if (base === '/dashboard') {++ post(`${base}/rules`, async (req, res, to) => {++ const c = dashCreator(req);++ policies.set({++ creatorId: c.id, resource: { service: req.body.service, type: req.body.type, id: req.body.id }, requirement: req.body.requirement || 'member',++ planId: req.body.plan_id || null, perkKey: req.body.perk_key || null, sensitive: bool(req.body.sensitive), actor: req.viewer.subject,++ });++ back(res, to, 'ok', 'Rule saved.');++ }, opts);++ post(`${base}/rules/:id/disable`, async (req, res, to) => {++ const rule = policies.byId(req.params.id);++ own(req, rule);++ policies.disable(rule.id);++ back(res, to, 'ok', 'Rule removed.');++ }, opts);++ }++ }++++ // ── Creator pages ────────────────────────────────────────++ router.get('/:username', async (req, res, next) => {++ try {++ if (RESERVED.has(req.params.username.toLowerCase())) return notFound(req, res);++ const c = creators.byUsername(req.params.username);++ if (!c || c.status !== 'active') return notFound(req, res);++ if (c.username !== req.params.username) return res.redirect(301, `/${encodeURIComponent(c.username)}`);++ const list = plans.list({ creatorId: c.id }).map((p) => plans.present(p));++ const isOwner = !!(req.viewer && req.viewer.subject === c.subject);++ const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;++ const name = c.display_name || c.username;++ send(res, 200, {++ title: `${name} memberships`, active: 'creator', canonicalPath: `/${c.username}`, viewer: req.viewer,++ description: c.bio || `Membership plans from ${name} on OpenVibe.`,++ robots: list.length ? 'index,follow' : 'noindex,follow',++ body: pages.creatorPage({ creator: creators.present(c), plans: list, viewer: req.viewer, member, rates: list.some((p) => p.purchasable) ? await rates() : null, providers: config.billing.providers, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query, isOwner }),++ });++ } catch (e) { next(e); }++ });++ router.get('/:username/plans/:slug', (req, res) => {++ const c = creators.byUsername(req.params.username);++ if (!c || c.status !== 'active') return notFound(req, res);++ const plan = plans.list({ creatorId: c.id, includeArchived: true }).find((p) => p.slug === req.params.slug);++ if (!plan) return notFound(req, res);++ const versions = plans.versions(plan.id).filter((v) => v.published_at).map(plans.presentVersion);++ return send(res, 200, {++ title: `Terms history · ${c.display_name || c.username}`, canonicalPath: `/${c.username}/plans/${plan.slug}`, viewer: req.viewer, robots: 'noindex,follow',++ body: pages.planHistory({ creator: creators.present(c), plan, versions }),++ });++ });++ post('/:username/join', async (req, res, to) => {++ const c = creators.byUsername(req.params.username);++ if (!c) throw new VipError(404, 'vip.creator_not_found', 'no such creator');++ const plan = plans.byId(String(req.body.plan_id || ''));++ if (!plan || plan.creator_id !== c.id) throw new VipError(404, 'vip.plan_not_found', 'that plan is not offered here');++ const out = await checkout.start({++ member: req.viewer.subject, planId: req.body.plan_id, provider: req.body.provider, autoRenew: bool(req.body.auto_renew),++ traceparent: req.ov.traceparent,++ });++ if (out.membership_started) return back(res, '/me', 'ok', `Welcome — you are now a member of ${c.display_name || c.username}.`);++ if (out.checkout_url && !out.checkout_ref) return res.redirect(303, out.checkout_url);++ return send(res, 200, { title: 'Finish joining', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.checkoutPage({ creator: creators.present(c), out }) });++ }, { backTo: (req) => `/${req.params.username}` });++++ return router;++}++++module.exports = { createWebRoutes };nnew file mode 100644@@@ -0,0 +1,213 @@++'use strict';++++/**++ * openvibe.vip — OAuth2 client session layer (identity provider: OpenVibe.Network), the same++ * shape as OpenVibe.Community's server/auth/routes.js. Registered in the Network as client `vip`++ * with redirect https://openvibe.vip/auth/callback.++ *++ * GET /auth/login → Network /oauth/authorize (?silent=1 → prompt=none; ?next= same-site path)++ * GET /auth/callback → server-side code exchange, session cookies++ * POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)++ * GET /auth/logout → clear cookies (best-effort refresh revoke), hint=guest++ * GET /auth/me → the signed-in user (offline JWT verification)++ * POST /auth/refresh → rotate tokens++ *++ * Cookies (host-only): ov_token (access JWT, Lax, JS-readable for the navbar), ov_refresh (httpOnly,++ * Path=/auth), ov_sso_hint. These cookies only ever sign a person into the pages of this site;++ * the API never reads them.++ */++const express = require('express');++const crypto = require('crypto');++const { userPrincipal } = require('../api/auth');++++const ACCESS_COOKIE = 'ov_token';++const REFRESH_COOKIE = 'ov_refresh';++const HINT_COOKIE = 'ov_sso_hint';++const STATE_COOKIE = 'ov_oauth_state';++const NEXT_COOKIE = 'ov_oauth_next';++const SILENT_COOKIE = 'ov_oauth_silent';++++function claimsToUser(claims) {++ if (!claims) return null;++ const { iat, exp, aud, iss, nbf, jti, ...user } = claims; // eslint-disable-line no-unused-vars++ return user;++}++++function decodeJwtPayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try { const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); return c && typeof c === 'object' ? c : null; } catch { return null; }++}++++function sanitizeNext(next, config) {++ if (!next || typeof next !== 'string') return '/';++ if (/^\/(?!\/|\\)/.test(next)) return next;++ try {++ const u = new URL(next);++ if (u.protocol !== 'https:') return '/';++ const allowed = [config.baseUrl, config.network.url].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);++ if (allowed.includes(u.hostname)) return u.toString();++ } catch { /* fall through */ }++ return '/';++}++++function withParam(target, key, value) {++ const hashAt = target.indexOf('#');++ const hash = hashAt >= 0 ? target.slice(hashAt) : '';++ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;++ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;++}++++/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */++function viewerMiddleware(userAuth) {++ return (req, _res, next) => {++ const token = req.cookies && req.cookies[ACCESS_COOKIE];++ const claims = token ? userAuth.verify(token) : null;++ req.viewer = claims ? userPrincipal(claims) : null;++ req.viewerClaims = claims ? claimsToUser(claims) : null;++ next();++ };++}++++function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch } = {}) {++ const router = express.Router();++ const secure = config.cookies.secure;++ const accessOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });++ const refreshOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });++ const flowOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });++ const hintOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });++++ function setSession(res, access, refresh) {++ res.cookie(ACCESS_COOKIE, access, accessOpts());++ if (refresh) res.cookie(REFRESH_COOKIE, refresh, refreshOpts());++ res.cookie(HINT_COOKIE, 'account', hintOpts());++ }++ function clearSession(res) {++ res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });++ res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });++ }++ function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }++++ async function tokenGrant(body) {++ let lastErr = null;++ for (const base of [config.network.internalUrl, config.network.url]) {++ if (!base) continue;++ try {++ const res = await fetchImpl(`${base}/oauth/token`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),++ signal: AbortSignal.timeout(10_000),++ });++ const data = await res.json().catch(() => ({}));++ if (!res.ok) {++ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);++ err.status = res.status; err.error = data.error || 'invalid_grant';++ throw err;++ }++ return data;++ } catch (err) {++ lastErr = err;++ if (err.status && err.status < 500) throw err;++ }++ }++ throw lastErr || new Error('Network unreachable');++ }++++ router.get('/login', (req, res) => {++ const silent = !!req.query.silent && req.query.silent !== '0';++ if (silent && userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }++ const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);++ let target = url;++ if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }++ res.cookie(STATE_COOKIE, state, flowOpts());++ const next = sanitizeNext(req.query.next, config);++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });++ if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });++ res.redirect(target);++ });++++ router.get('/callback', async (req, res) => {++ const { code, state, error } = req.query;++ const next = sanitizeNext(req.cookies && req.cookies[NEXT_COOKIE], config);++ const silent = req.cookies && req.cookies[SILENT_COOKIE] === '1';++ if (error) {++ clearFlow(res);++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));++ return res.redirect(withParam('/', 'auth_error', String(error)));++ }++ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');++ const expected = req.cookies && req.cookies[STATE_COOKIE];++ clearFlow(res);++ if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {++ return res.status(400).type('text/plain').send('Sign-in state mismatch — please try signing in again.');++ }++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });++ setSession(res, data.access_token, data.refresh_token);++ return res.redirect(next);++ } catch (err) {++ console.error('[VIP] code exchange failed:', err.message);++ return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');++ }++ });++++ const fedcmBody = express.json({ limit: '16kb' });++ router.post('/fedcm', fedcmBody, async (req, res) => {++ const { token, nonce } = req.body || {};++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce || nonce.length > 256) {++ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });++ }++ const claims = decodeJwtPayload(token);++ const a = Buffer.from(String((claims && claims.nonce) || '')); const b = Buffer.from(nonce);++ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });++ try {++ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });++ setSession(res, data.access_token, data.refresh_token);++ return res.json({ ok: true, user: data.user || claimsToUser(userAuth.verify(data.access_token)) });++ } catch (err) {++ if (err.status && err.status < 500) return res.status(401).json({ error: err.error || 'invalid_grant' });++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });++ }++ });++++ router.get('/logout', async (req, res) => {++ const refresh = req.cookies && req.cookies[REFRESH_COOKIE];++ if (refresh) {++ try {++ await fetchImpl(`${config.network.internalUrl}/oauth/revoke`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: refresh }),++ signal: AbortSignal.timeout(3000),++ });++ } catch { /* optional */ }++ }++ clearSession(res);++ res.cookie(HINT_COOKIE, 'guest', hintOpts());++ res.redirect(sanitizeNext(req.query.next, config));++ });++++ router.get('/me', (req, res) => {++ const token = (String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null) || (req.cookies && req.cookies[ACCESS_COOKIE]);++ const claims = userAuth.verify(token);++ if (!claims) return res.status(401).json({ error: 'Not authenticated' });++ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });++ });++++ router.post('/refresh', async (req, res) => {++ const refresh = req.cookies && req.cookies[REFRESH_COOKIE];++ if (!refresh) return res.status(401).json({ error: 'No refresh token' });++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });++ setSession(res, data.access_token, data.refresh_token);++ return res.json({ token: data.access_token, user: claimsToUser(userAuth.verify(data.access_token)) });++ } catch (err) {++ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });++ }++ });++++ return router;++}++++module.exports = { createSessionRoutes, viewerMiddleware, sanitizeNext, ACCESS_COOKIE };nnew file mode 100644@@@ -0,0 +1,231 @@++'use strict';++/**++ * Checkout hand-off and cancellation through Billing, members lists, readiness/metrics, and the++ * server-rendered pages (useful without JavaScript, forms with anti-forgery tokens).++ */++const assert = require('assert');++const { boot, harness } = require('./helpers/app');++++const { test, run } = harness('api');++++(async () => {++ const t = await boot();++ const creator = t.network.newUser('lena', { role: 'streamer' });++ const buyer = t.network.newUser('milo');++ const payer = t.network.newUser('nora');++ let plan;++++ test('health, release and truthful readiness', async () => {++ const h = await t.call('GET', '/api/health', { token: null });++ assert.strictEqual(h.json.service, 'vip');++ const rel = await t.call('GET', '/release.json', { token: null });++ assert.strictEqual(rel.json.service, 'vip');++ // Billing down: VIP stays ready (the projection still answers while fresh) and says so.++ t.billing.state.down = true;++ const r = await t.call('GET', '/api/ready', { token: null });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.ready, true);++ assert.strictEqual(r.json.status, 'degraded');++ assert.deepStrictEqual(r.json.degraded, ['billing']);++ assert.strictEqual(r.json.checks.db.required, true);++ assert.strictEqual(r.json.checks.billing.required, false);++ assert.strictEqual(typeof r.json.projection.rows, 'number');++ t.billing.state.down = false;++ });++++ test('/metrics answers loopback callers with golden signals and VIP gauges', async () => {++ const m = await fetch(`${t.base}/metrics`);++ assert.strictEqual(m.status, 200);++ const text = await m.text();++ assert.match(text, /vip_outbox_pending/);++ assert.match(text, /vip_projection_rows\{state="fresh"\}/);++ assert.match(text, /http_requests_total|http_request_duration_seconds/);++ });++++ test('the creator publishes a plan', async () => {++ plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Front row', description: 'Monthly membership', benefits: ['Badge in chat'], publish: true } })).json.plan;++ assert.ok(plan && plan.purchasable);++ });++++ test('checkout hand-off returns Billing\'s checkout (PowerChat reference / provider URL) and records the version', async () => {++ const pc = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'powerchat' } });++ assert.strictEqual(pc.status, 201, pc.text);++ assert.match(pc.json.checkout_ref, /^pcsub:/);++ assert.strictEqual(pc.json.membership_started, false);++ assert.strictEqual(pc.json.checkout.plan_version_id, plan.current_version.id);++ const intentCall = t.billing.calls.filter((c) => c.path === '/api/v1/intents').pop();++ assert.strictEqual(intentCall.key, `vip:checkout:${pc.json.checkout.id}`);++ assert.strictEqual(intentCall.body.kind, 'subscription');++ assert.deepStrictEqual(intentCall.body.streamer, { type: 'user', id: creator.subject });++ const st = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });++ assert.match(st.json.checkout_url, /^https:\/\/checkout\.stripe\.test\//);++ const bad = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'bitcoin-atm' } });++ assert.strictEqual(bad.status, 422);++ const self = await t.call('POST', '/api/v1/checkout', { user: creator, body: { plan_id: plan.id, provider: 'stripe' } });++ assert.strictEqual(self.status, 422);++ });++++ test('when Billing settles the handed-off checkout, the membership is filed under the version chosen then', async () => {++ await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { benefits: ['Badge in chat', 'Monthly stream'] } }); // v2 published meanwhile++ await t.deliverAll(t.billing.pay(buyer.subject, creator.subject, { provider: 'stripe' }).events);++ const m = t.domain.memberships.get(buyer.subject, t.domain.creators.bySubject(creator.subject).id);++ assert.strictEqual(m.origin, 'checkout');++ assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 1);++ const again = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });++ assert.strictEqual(again.status, 409);++ assert.strictEqual(again.json.code, 'vip.already_member');++ });++++ test('paying from Vibes credit starts the membership at once, through Billing', async () => {++ const poor = await t.call('POST', '/api/v1/checkout', { user: payer, body: { plan_id: plan.id, provider: 'credit' } });++ assert.strictEqual(poor.status, 409);++ assert.strictEqual(poor.json.code, 'vip.checkout.insufficient_credit');++ t.billing.state.credit.set(payer.subject, 1000);++ const ok = await t.call('POST', '/api/v1/checkout', { user: payer, body: { plan_id: plan.id, provider: 'credit' } });++ assert.strictEqual(ok.status, 201, ok.text);++ assert.strictEqual(ok.json.membership_started, true);++ const e = await t.call('GET', `/api/v1/entitlements/check?creator=${creator.subject}&mode=projection`, { user: payer });++ assert.strictEqual(e.json.status, 'active');++ assert.strictEqual(e.json.membership.plan_version.version, 2);++ // Billing's own event for the same grant arrives later and changes nothing.++ const before = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === payer.subject).length;++ const out = await t.deliverAll(t.billing.state.lastEvents);++ assert.ok(['unchanged', 'stale_ignored'].includes(out[1].json.outcome), out[1].json.outcome);++ assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === payer.subject).length, before);++ assert.strictEqual(before, 1);++ });++++ test('a service checks out on behalf of a member only with vip.membership.checkout', async () => {++ const x = t.network.newUser('otto');++ const no = await t.call('POST', '/api/v1/checkout', { cap: ['vip.entitlement.check'], body: { subject: { type: 'user', id: x.subject }, plan_id: plan.id, provider: 'stripe' } });++ assert.strictEqual(no.status, 403);++ const yes = await t.call('POST', '/api/v1/checkout', { cap: ['vip.membership.checkout'], body: { subject: { type: 'user', id: x.subject }, plan_id: plan.id, provider: 'stripe' } });++ assert.strictEqual(yes.status, 201, yes.text);++ });++++ test('Billing frozen or down: checkout is refused and nothing is claimed', async () => {++ const x = t.network.newUser('pia');++ t.billing.state.down = true;++ try {++ const r = await t.call('POST', '/api/v1/checkout', { user: x, body: { plan_id: plan.id, provider: 'stripe' } });++ assert.strictEqual(r.status, 503);++ assert.strictEqual(r.json.code, 'vip.billing_unavailable');++ } finally { t.billing.state.down = false; }++ });++++ test('cancel goes through Billing (member only) and keeps the paid period', async () => {++ const cid = t.domain.creators.bySubject(creator.subject).id;++ const svc = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { cap: ['vip.membership.checkout'] });++ assert.strictEqual(svc.status, 403);++ const r = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { user: buyer });++ assert.strictEqual(r.status, 200, r.text);++ const cancelCall = t.billing.calls.filter((c) => /\/cancel$/.test(c.path)).pop();++ assert.match(cancelCall.key, /^vip:cancel:/);++ const e = await t.call('GET', `/api/v1/entitlements/check?creator=${creator.subject}&mode=projection`, { user: buyer });++ assert.strictEqual(e.json.active, true);++ assert.strictEqual(e.json.cancel_at_period_end, true);++ const again = await t.call('POST', `/api/v1/memberships/lena/cancel`, { user: buyer });++ assert.strictEqual(again.json.already, true);++ const none = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { user: t.network.newUser('quin') });++ assert.strictEqual(none.status, 404);++ void cid;++ });++++ test('membership status: users see their own, services need vip.membership.status', async () => {++ const own = await t.call('GET', `/api/v1/memberships/${payer.subject}`, { user: payer });++ assert.strictEqual(own.status, 200);++ assert.strictEqual(own.json.memberships.length, 1);++ assert.strictEqual(own.json.memberships[0].creator.username, 'lena');++ assert.strictEqual((await t.call('GET', `/api/v1/memberships/${payer.subject}`, { user: buyer })).status, 403);++ assert.strictEqual((await t.call('GET', `/api/v1/memberships/${payer.subject}`, { cap: ['vip.membership.status'] })).status, 200);++ assert.strictEqual((await t.call('GET', `/api/v1/memberships/${payer.subject}`, { cap: [] })).status, 403);++ });++++ test('creator members: from Billing, or the fresh projection when Billing is down', async () => {++ const r = await t.call('GET', `/api/v1/creators/${creator.subject}/members`, { user: creator });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.source, 'billing');++ assert.strictEqual(r.json.members.length, 2);++ assert.ok(r.json.members.every((m) => m.membership && m.membership.plan_version));++ t.billing.state.down = true;++ try {++ const p = await t.call('GET', '/api/v1/creators/lena/members', { cap: ['vip.creator.members.list'] });++ assert.strictEqual(p.json.source, 'projection');++ assert.strictEqual(p.json.members.length, 2);++ } finally { t.billing.state.down = false; }++ assert.strictEqual((await t.call('GET', '/api/v1/creators/lena/members', { user: buyer })).status, 403);++ });++++ test('public creator page works without JavaScript and without an account', async () => {++ const p = await t.page('/lena');++ assert.strictEqual(p.status, 200);++ assert.match(p.text, /Front row/);++ assert.match(p.text, /Monthly stream/);++ assert.match(p.text, /\$4\.99<\/b> every 30 days/);++ assert.match(p.text, /Sign in to join/);++ assert.match(p.text, /<link rel="canonical" href="http:\/\/vip\.test\/lena">/);++ assert.match(p.text, /openvibe\.network\/shared\/navbar\.js/);++ assert.match(p.text, /<noscript>/);++ assert.doesNotMatch(p.text, /\bfree\b|\$0|no ads/i);++ const hist = await t.page(`/lena/plans/${plan.slug}`);++ assert.strictEqual(hist.status, 200);++ assert.match(hist.text, /v1/);++ assert.match(hist.text, /v2/);++ assert.strictEqual((await t.page('/LENA')).location, '/lena');++ assert.strictEqual((await t.page('/nobody-here')).status, 404);++ const sm = await t.page('/sitemap.xml');++ assert.match(sm.text, /http:\/\/vip\.test\/lena/);++ });++++ test('joining from the page: form with anti-forgery token → Billing credit → member page', async () => {++ const x = t.network.newUser('rita');++ t.billing.state.credit.set(x.subject, 600);++ const p = await t.page('/lena', { user: x });++ const token = t.csrf(p.text);++ assert.ok(token);++ const forged = await t.page('/lena/join', { user: x, method: 'POST', form: { plan_id: plan.id, provider: 'credit', auto_renew: '1' } });++ assert.strictEqual(forged.status, 303);++ assert.match(forged.location, /error=/);++ const ok = await t.page('/lena/join', { user: x, method: 'POST', form: { _csrf: token, plan_id: plan.id, provider: 'credit', auto_renew: '1' } });++ assert.strictEqual(ok.status, 303);++ assert.match(ok.location, /^\/me\?ok=/);++ const me = await t.page('/me', { user: x });++ assert.match(me.text, /Front row<\/b> v2/);++ assert.match(me.text, /Cancel at period end/);++ const handoff = await t.page('/lena/join', { user: t.network.newUser('sam'), method: 'POST', form: { _csrf: 'x'.repeat(32), plan_id: plan.id, provider: 'stripe' } });++ assert.match(handoff.location, /error=/);++ });++++ test('dashboard: plans, versions, perks and members; forms create versions', async () => {++ const d = await t.page('/dashboard', { user: creator });++ assert.strictEqual(d.status, 200);++ assert.match(d.text, /Front row/);++ assert.match(d.text, /Versions \(2\)/);++ assert.match(d.text, /From OpenVibe\.Billing\./);++ const token = t.csrf(d.text);++ const edit = await t.page(`/dashboard/plans/${plan.id}`, { user: creator, method: 'POST', form: { _csrf: token, name: 'Front row', description: 'Monthly membership', benefits: 'Badge in chat\nMonthly stream\nEarly VODs', change_note: 'early VODs' } });++ assert.strictEqual(edit.status, 303);++ assert.match(decodeURIComponent(edit.location), /Saved as version 3/);++ const perk = await t.page('/dashboard/perks', { user: creator, method: 'POST', form: { _csrf: token, name: 'Early VODs', key: 'early-vods', kind: 'gated_content', bindings: 'live vod_early_access' } });++ assert.match(decodeURIComponent(perk.location), /Perk created/);++ const other = t.network.newUser('tess', { role: 'streamer' });++ const steal = await t.page(`/dashboard/plans/${plan.id}`, { user: other, method: 'POST', form: { _csrf: t.csrf((await t.page('/dashboard', { user: other })).text), name: 'mine now' } });++ assert.match(steal.location, /error=/);++ assert.strictEqual(t.domain.plans.latestVersion(plan.id).version, 3);++ const net = await t.page('/dashboard?as=network', { user: creator });++ assert.strictEqual(net.status, 403);++ const anon = await t.page('/dashboard');++ assert.match(anon.text, /Sign in/);++ });++++ test('API errors are problem+json', async () => {++ const r = await t.call('GET', '/api/v1/plans/vpl_missing', { token: null });++ assert.strictEqual(r.status, 404);++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);++ assert.strictEqual(r.json.code, 'vip.plan_not_found');++ const bad = await t.call('GET', '/api/v1/plans', { token: 'garbage' });++ assert.strictEqual(bad.status, 401);++ });++++ await run().finally(() => t.close());++})();nnew file mode 100644@@@ -0,0 +1,262 @@++'use strict';++/**++ * The entitlement projection: Billing events converge cancel / renew / refund; lost events converge++ * through valid_until and the authoritative fallback; a stale projection never authorizes past++ * valid_until (+ grace); ordering and duplicates are safe. Injected clock throughout.++ */++const assert = require('assert');++const { signDelivery } = require('openvibe-sdk/events');++const { boot, harness } = require('./helpers/app');++const { DAY } = require('./helpers/stubs');++++const { test, run } = harness('entitlements');++const MIN = 60_000;++++(async () => {++ const t = await boot();++ const creator = t.network.newUser('dora', { role: 'streamer' });++ const svc = { cap: ['vip.entitlement.check'] };++ const check = async (member, mode) => (await t.call('POST', '/api/v1/entitlements/check', { ...svc, body: { subject: { type: 'user', id: member.subject }, creator: { type: 'user', id: creator.subject }, mode } })).json;++ await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } });++++ test('a grant event makes the member active from the projection, without asking Billing', async () => {++ const m = t.network.newUser('m1');++ const [settled, ent] = t.billing.pay(m.subject, creator.subject).events;++ assert.strictEqual((await t.deliver(settled)).json.outcome, 'ignored:type');++ const r = await t.deliver(ent);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json.outcome, 'changed');++ const calls = t.billing.entitlementCalls();++ const e = await check(m);++ assert.strictEqual(e.status, 'active');++ assert.strictEqual(e.active, true);++ assert.strictEqual(e.source, 'projection');++ assert.strictEqual(e.membership.plan_version.version, 1);++ assert.strictEqual(t.billing.entitlementCalls(), calls);++ const changed = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);++ assert.strictEqual(changed.length, 1);++ assert.strictEqual(changed[0].payload.active, true);++ assert.strictEqual(changed[0].payload.reason, 'granted');++ assert.strictEqual(changed[0].payload.plan_version, 1);++ });++++ test('a redelivered event is applied once', async () => {++ const m = t.network.newUser('m2');++ const ent = t.billing.pay(m.subject, creator.subject).events[1];++ assert.strictEqual((await t.deliver(ent)).json.duplicate, false);++ assert.strictEqual((await t.deliver(ent)).json.duplicate, true);++ assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject).length, 1);++ });++++ test('deliveries must be signed; only Billing is believed', async () => {++ const m = t.network.newUser('m3');++ const ent = t.billing.pay(m.subject, creator.subject).events[1];++ assert.strictEqual((await t.deliver(ent, { secret: 'x'.repeat(48) })).status, 401);++ const forged = { ...ent, event_id: t.billing.envelope('x.y.z', { type: 'x', id: '1' }, {}).event_id, source: 'live' };++ assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');++ assert.strictEqual(t.domain.entitlements.getRow(m.subject, creator.subject), null);++ const raw = JSON.stringify({ event: ent, seq: 1 });++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw + ' ', t.EVENTS_SECRET) } });++ assert.strictEqual(res.status, 401);++ });++++ test('cancel: the period still runs, then the member converges to inactive', async () => {++ const m = t.network.newUser('m4');++ const { sub, events } = t.billing.pay(m.subject, creator.subject);++ await t.deliverAll(events);++ t.clock.advance(5 * DAY);++ await t.deliverAll(t.billing.cancel(sub.id).events);++ let e = await check(m);++ assert.strictEqual(e.active, true);++ assert.strictEqual(e.cancel_at_period_end, true);++ const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);++ assert.strictEqual(ev[ev.length - 1].payload.reason, 'cancel_scheduled');++ t.clock.advance(26 * DAY);++ await t.deliverAll(t.billing.expire(sub.id, 'canceled').events);++ e = await check(m, 'projection');++ assert.strictEqual(e.status, 'inactive');++ assert.strictEqual(e.active, false);++ t.clock.advance(-31 * DAY);++ });++++ test('cancel with the expiry event lost: past the period end VIP asks Billing and answers inactive', async () => {++ const m = t.network.newUser('m5');++ const { sub, events } = t.billing.pay(m.subject, creator.subject);++ await t.deliverAll(events);++ await t.deliverAll(t.billing.cancel(sub.id).events);++ t.clock.advance(31 * DAY);++ t.billing.expire(sub.id, 'canceled'); // event never delivered++ const calls = t.billing.entitlementCalls();++ const e = await check(m);++ assert.strictEqual(e.status, 'inactive');++ assert.strictEqual(e.source, 'billing');++ assert.strictEqual(t.billing.entitlementCalls(), calls + 1);++ t.clock.advance(-31 * DAY);++ });++++ test('renew: the renewal event extends the membership past the old period end', async () => {++ const m = t.network.newUser('m6');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ t.clock.advance(30 * DAY - 5 * MIN);++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events); // renewed 5 minutes before the end++ t.clock.advance(10 * MIN); // the old period is over++ const e = await check(m, 'projection');++ assert.strictEqual(e.active, true);++ assert.strictEqual(e.stale, false);++ assert.strictEqual(Date.parse(e.expires_at), Date.parse(t.billing.entitlement(m.subject, creator.subject).expires_at));++ const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);++ assert.strictEqual(ev[ev.length - 1].payload.reason, 'renewed');++ t.clock.advance(-(30 * DAY + 5 * MIN));++ });++++ test('renew with the event lost: the projection stops answering; Billing confirms the renewal', async () => {++ const m = t.network.newUser('m7');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ t.clock.advance(29 * DAY);++ await check(m, 'authoritative'); // fresh row synced on day 29++ t.billing.pay(m.subject, creator.subject); // renewal event never delivered++ t.clock.advance(1 * DAY + 2 * MIN); // past the old period end + grace++ assert.strictEqual((await check(m, 'projection')).status, 'unknown');++ const e = await check(m);++ assert.strictEqual(e.status, 'active');++ assert.strictEqual(e.source, 'billing');++ assert.strictEqual((await check(m, 'projection')).status, 'active');++ t.clock.advance(-(30 * DAY + 2 * MIN));++ });++++ test('refund: the refund events revoke at once', async () => {++ const m = t.network.newUser('m8');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ const out = await t.deliverAll(t.billing.refund(m.subject, creator.subject).events);++ assert.deepStrictEqual(out.map((o) => o.json.outcome), ['doubt', 'changed']);++ const e = await check(m, 'projection');++ assert.strictEqual(e.active, false);++ const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);++ assert.strictEqual(ev[ev.length - 1].payload.reason, 'refund');++ assert.strictEqual(ev[ev.length - 1].payload.active, false);++ });++++ test('refund with only the reversal delivered: the projection is in doubt and Billing decides', async () => {++ const m = t.network.newUser('m9');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ const [reversed] = t.billing.refund(m.subject, creator.subject).events;++ await t.deliver(reversed);++ t.clock.advance(2 * MIN);++ assert.strictEqual((await check(m, 'projection')).status, 'unknown');++ const e = await check(m);++ assert.strictEqual(e.status, 'inactive');++ assert.strictEqual(e.source, 'billing');++ t.clock.advance(-2 * MIN);++ });++++ test('refund with every event lost: the stale "yes" ends at valid_until (max age), never later', async () => {++ const m = t.network.newUser('m10');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ t.billing.refund(m.subject, creator.subject); // nothing delivered++ const row = t.domain.entitlements.getRow(m.subject, creator.subject);++ assert.strictEqual(row.valid_until - row.synced_at, t.config.projection.maxAgeMs);++ t.clock.advance(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1);++ assert.strictEqual((await check(m, 'projection')).active, false);++ assert.strictEqual((await check(m, 'projection')).status, 'unknown');++ const e = await check(m);++ assert.strictEqual(e.status, 'inactive');++ t.clock.advance(-(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1));++ });++++ test('a stale projection cannot authorize past valid_until + grace, even with Billing down', async () => {++ const m = t.network.newUser('m11');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ t.billing.state.down = true;++ try {++ t.clock.advance(t.config.projection.maxAgeMs + 10_000); // stale, inside grace++ let e = await check(m);++ assert.strictEqual(e.status, 'active');++ assert.strictEqual(e.stale, true);++ assert.strictEqual(e.reason, 'billing_unavailable');++ assert.strictEqual((await check(m, 'authoritative')).status, 'unknown');++ t.clock.advance(t.config.projection.graceMs); // past grace++ e = await check(m);++ assert.strictEqual(e.status, 'unknown');++ assert.strictEqual(e.active, false);++ assert.strictEqual((await check(m, 'projection')).status, 'unknown');++ // A hundred days later it is still "unknown", never "yes".++ t.clock.advance(100 * DAY);++ assert.strictEqual((await check(m)).active, false);++ } finally {++ t.billing.state.down = false;++ t.clock.advance(-(t.config.projection.maxAgeMs + 10_000 + t.config.projection.graceMs + 100 * DAY));++ }++ assert.strictEqual((await check(m)).status, 'active');++ });++++ test('authoritative mode always asks Billing', async () => {++ const m = t.network.newUser('m12');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ const calls = t.billing.entitlementCalls();++ const e = await check(m, 'authoritative');++ assert.strictEqual(e.source, 'billing');++ assert.strictEqual(e.active, true);++ assert.strictEqual(t.billing.entitlementCalls(), calls + 1);++ });++++ test('an unknown member with Billing down is "unknown", not "no" and never "yes"', async () => {++ const m = t.network.newUser('m13');++ t.billing.state.down = true;++ try {++ const e = await check(m);++ assert.strictEqual(e.status, 'unknown');++ assert.strictEqual(e.active, false);++ } finally { t.billing.state.down = false; }++ });++++ test('out of order: an older grant after a newer revocation is ignored; an older revocation after a newer grant puts the row in doubt', async () => {++ const m = t.network.newUser('m14');++ const grant = t.billing.pay(m.subject, creator.subject).events[1];++ t.clock.advance(1000);++ const revoke = t.billing.refund(m.subject, creator.subject).events[1];++ await t.deliver(revoke);++ assert.strictEqual((await t.deliver(grant)).json.outcome, 'stale_ignored');++ assert.strictEqual((await check(m, 'projection')).active, false);++++ const m2 = t.network.newUser('m15');++ const g1 = t.billing.pay(m2.subject, creator.subject).events[1];++ t.clock.advance(1000);++ const r1 = t.billing.refund(m2.subject, creator.subject).events[1];++ t.clock.advance(1000);++ const g2 = t.billing.pay(m2.subject, creator.subject).events[1];++ await t.deliver(g1);++ await t.deliver(g2);++ assert.strictEqual((await t.deliver(r1)).json.outcome, 'doubt');++ t.clock.advance(2 * MIN);++ assert.strictEqual((await check(m2, 'projection')).status, 'unknown');++ assert.strictEqual((await check(m2)).status, 'active'); // Billing: the newer grant stands++ });++++ test('the refresh job re-confirms projections in use before they go stale', async () => {++ const m = t.network.newUser('m16');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ await check(m, 'projection'); // marks it used++ t.clock.advance(t.config.projection.maxAgeMs - MIN);++ const out = await t.domain.entitlements.refreshDue();++ assert.ok(out.checked >= 1 && out.ok >= 1);++ const row = t.domain.entitlements.getRow(m.subject, creator.subject);++ assert.strictEqual(row.source, 'billing_check');++ assert.ok(row.valid_until > t.clock.now() + 10 * MIN);++ });++++ test('a user checks only their own entitlement; services need the capability', async () => {++ const m = t.network.newUser('m17');++ const other = t.network.newUser('m18');++ const own = await t.call('GET', `/api/v1/entitlements/check?creator=${creator.subject}`, { user: m });++ assert.strictEqual(own.status, 200);++ assert.strictEqual(own.json.member.id, m.subject);++ const theirs = await t.call('GET', `/api/v1/entitlements/check?creator=${creator.subject}&subject=${other.subject}`, { user: m });++ assert.strictEqual(theirs.status, 403);++ const noCap = await t.call('POST', '/api/v1/entitlements/check', { cap: ['vip.plan.list'], body: { subject: m.subject, creator: creator.subject } });++ assert.strictEqual(noCap.status, 403);++ const byName = await t.call('POST', '/api/v1/entitlements/check', { ...svc, body: { subject: m.subject, creator: 'dora' } });++ assert.strictEqual(byName.status, 200, byName.text);++ });++++ await run().finally(() => t.close());++})();nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++/**++ * Boots VIP against the stubs on a random port with a temp database and an injected clock shared++ * with the Billing stub. Jobs are off: tests drive refreshes explicitly.++ *++ * t.call(method, path, { body, user, cap, sub, token }) user → Bearer user JWT; else a service token with `cap`++ * t.deliver(envelope) a signed OpenVibe.Events delivery to /internal/events++ * t.page(path, { user, method, form }) an SSR page (cookie session), form posts++ * t.clock.advance(ms)++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { signDelivery } = require('openvibe-sdk/events');++const { startNetwork, startBilling } = require('./stubs');++++const EVENTS_SECRET = 'e'.repeat(48);++++async function boot(opts = {}) {++ const network = await startNetwork();++ const clock = { t: Date.now(), now() { return this.t; }, advance(ms) { this.t += ms; } };++ clock.now = clock.now.bind(clock);++ const billing = await startBilling(network, clock);++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vip-test-'));++ const env = {++ NODE_ENV: 'test',++ VIP_DB_PATH: path.join(dir, 'vip.db'),++ BASE_URL: 'http://vip.test',++ OV_NETWORK_URL: network.url,++ OV_NETWORK_INTERNAL_URL: network.url,++ OV_NETWORK_ISSUER: network.url,++ OV_NETWORK_PUBLIC_KEY: network.publicPem,++ OV_OAUTH_CLIENT_ID: 'vip',++ OV_OAUTH_CLIENT_SECRET: 'shh',++ VIP_FORM_SECRET: 'form-secret-for-tests',++ BILLING_URL: billing.url,++ VIP_BILLING_TIMEOUT_MS: '1500',++ VIP_CHECKOUT_PROVIDERS: 'powerchat,stripe,credit',++ VIP_EVENTS_SECRET: EVENTS_SECRET,++ VIP_JOBS: 'off',++ VIP_PROJECTION_MAX_AGE_MS: String(15 * 60 * 1000),++ VIP_PROJECTION_GRACE_MS: String(60 * 1000),++ ...(opts.env || {}),++ };++ for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];++ const { loadConfig } = require('../../server/config');++ const { createApp } = require('../../server/app');++ const config = loadConfig(env);++ const logs = [];++ const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')), debug() {} };++ const app = createApp({ config, now: clock.now, log });++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const domain = app.locals.domain;++++ async function call(method, p, { body, user, cap = [], sub = 'svc:live', token, headers = {} } = {}) {++ const h = { ...headers };++ if (token !== null) h.Authorization = `Bearer ${token || (user ? network.signUser(user) : network.signService({ sub, cap }))}`;++ if (body !== undefined) h['Content-Type'] = 'application/json';++ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });++ const text = await res.text();++ let json = null; try { json = JSON.parse(text); } catch { /* html */ }++ return { status: res.status, headers: res.headers, json, text };++ }++++ async function deliver(event, { secret = EVENTS_SECRET, seq = 1 } = {}) {++ const raw = JSON.stringify({ event, seq });++ const res = await fetch(`${base}/internal/events`, {++ method: 'POST', body: raw,++ headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },++ });++ return { status: res.status, json: await res.json().catch(() => null) };++ }++ async function deliverAll(events) { const out = []; for (const e of events) out.push(await deliver(e)); return out; }++++ /** An SSR request as a signed-in person (ov_token cookie) or anonymous. */++ async function page(p, { user, method = 'GET', form } = {}) {++ const headers = {};++ if (user) headers.Cookie = `ov_token=${network.signUser(user)}`;++ let bodyStr;++ if (form) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; bodyStr = new URLSearchParams(form).toString(); }++ const res = await fetch(base + p, { method, headers, body: bodyStr, redirect: 'manual' });++ return { status: res.status, location: res.headers.get('location'), text: await res.text() };++ }++ const csrf = (html) => { const m = /name="_csrf" value="([^"]+)"/.exec(html); return m ? m[1] : null; };++++ const outboxEvents = (type) => domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type);++++ async function close() {++ await new Promise((r) => server.close(r));++ app.locals.outbox.stop();++ await billing.close();++ await network.close();++ try { domain.db.close(); } catch { /* */ }++ fs.rmSync(dir, { recursive: true, force: true });++ }++++ return { app, base, config, domain, network, billing, clock, logs, call, deliver, deliverAll, page, csrf, outboxEvents, close, EVENTS_SECRET };++}++++/** Tiny test harness: sequential async tests, a summary, exit code. */++function harness(name) {++ const tests = [];++ const test = (title, fn) => tests.push({ title, fn });++ async function run() {++ let failed = 0;++ for (const t of tests) {++ try { await t.fn(); console.log(` ✓ ${t.title}`); } catch (e) { failed++; console.log(` ✗ ${t.title}\n${e.stack}`); }++ }++ console.log(`${name}: ${tests.length - failed}/${tests.length} passed`);++ process.exit(failed ? 1 : 0);++ }++ return { test, run };++}++++module.exports = { boot, harness, EVENTS_SECRET };nnew file mode 100644@@@ -0,0 +1,254 @@++'use strict';++/**++ * Stand-ins for the services VIP talks to, each on a random port with a real RS256 key pair.++ *++ * startNetwork() JWKS, client-credentials token endpoint (scope → cap), user JWTs++ * (signUser), resolve-batch for the importer++ * startBilling(net, clock) the parts of OpenVibe.Billing VIP uses — intents, credit subscriptions,++ * cancel, subscriptions list/get, entitlements, rates, health — with++ * Billing's rules that matter here (service token audience + capability,++ * Idempotency-Key replay, self-dealing, one subscription per pair, periods++ * on the injected clock), plus the envelopes Billing would publish for each++ * change (grant, renew, cancel, expire, refund) so tests deliver them++ */++const http = require('http');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { serviceAuth, ids } = require('openvibe-contracts');++++const DAY = 86_400_000;++++function listen(server) {++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)));++}++function readBody(req) {++ return new Promise((resolve) => { const c = []; req.on('data', (d) => c.push(d)); req.on('end', () => resolve(Buffer.concat(c).toString('utf8'))); });++}++const send = (res, status, obj) => { res.writeHead(status, { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' }); res.end(JSON.stringify(obj)); };++++async function startNetwork() {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const legacy = {};++ const grants = [];++ const state = { resolveDown: false };++ let issuer = 'http://network.test';++ let n = 0;++++ function signService({ sub = 'svc:live', aud = ['openvibe.vip'], cap = [], expSec = 300 } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);++ }++ function signUser(u) {++ return jwt.sign({ sub: String(++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user' },++ privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });++ }++ function newUser(username, { liveId = null, role = 'user' } = {}) {++ const subject = ids.newId('user');++ if (liveId != null) legacy[String(liveId)] = { subject, username };++ return { subject, username, display_name: username[0].toUpperCase() + username.slice(1), role };++ }++ function mapLive(liveId, username) { const subject = ids.newId('user'); legacy[String(liveId)] = { subject, username }; return subject; }++++ const server = http.createServer(async (req, res) => {++ const raw = await readBody(req);++ if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });++ if (req.url === '/oauth/token' && req.method === 'POST') {++ const body = Object.fromEntries(new URLSearchParams(raw));++ grants.push(body);++ if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });++ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);++ return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });++ }++ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });++ if (!v.ok) return send(res, 401, { code: v.code });++ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return send(res, 403, { code: 'capability.denied' });++ if (state.resolveDown) return send(res, 503, { error: 'down' });++ const body = JSON.parse(raw || '{}');++ const results = {};++ for (const id of body.ids || []) {++ const s = body.system === 'live' ? legacy[String(id)] : null;++ results[String(id)] = s ? { subject: { type: 'user', id: s.subject }, username: s.username } : null;++ }++ return send(res, 200, { results });++ }++ send(res, 404, { error: 'not found' });++ });++ const url = await listen(server);++ issuer = url;++ return { url, publicPem, signService, signUser, newUser, mapLive, legacy, grants, state, close: () => new Promise((r) => server.close(r)) };++}++++/**++ * Billing stub. Periods are 30 days on `clock.now()`. Each mutation returns the envelopes Billing++ * would emit; tests deliver them (or not, to simulate loss).++ */++async function startBilling(network, clock) {++ const subs = new Map(); // id → sub++ const periods = []; // { sub_id, subject, scope, starts, ends, revoked, txn }++ const idem = new Map();++ const calls = [];++ const state = { down: false, frozen: false, credit: new Map() };++ const now = () => clock.now();++ const iso = (ms) => new Date(ms).toISOString();++ let seq = 0;++ const nextId = (p) => `${p}_${ids.ulid(now())}${(++seq).toString(36)}`.slice(0, 40);++++ const pairKey = (a, b) => `${a}:${b}`;++ const byPair = (a, b) => [...subs.values()].find((s) => s.subscriber === a && s.streamer === b) || null;++ function entitlement(subject, scope) {++ const t = now();++ const live = periods.filter((p) => p.subject === subject && p.scope === scope && !p.revoked && p.starts <= t && p.ends > t);++ let exp = live.length ? Math.max(...live.map((p) => p.ends)) : null;++ if (exp) {++ for (;;) {++ const next = periods.filter((p) => p.subject === subject && p.scope === scope && !p.revoked && p.starts <= exp && p.ends > exp);++ if (!next.length) break;++ exp = Math.max(...next.map((p) => p.ends));++ }++ }++ const s = byPair(subject, scope);++ return {++ subject: { type: 'user', id: subject }, streamer: { type: 'user', id: scope }, kind: 'channel_subscription', active: !!exp, expires_at: exp ? iso(exp) : null,++ subscription: s ? { id: s.id, status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, provider: s.provider } : null,++ };++ }++ const present = (s) => ({++ id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, tier: 1, provider: s.provider,++ status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, price_cents: 499,++ current_period_end: s.current_period_end, created_at: s.created_at, updated_at: s.updated_at, legacy_live_id: s.legacy_live_id ?? null,++ });++ function envelope(type, subject, payload) {++ return {++ event_id: ids.newId('event', now()), event_type: type, version: 1, source: 'billing', actor: { type: 'service', id: 'billing' },++ timestamp: iso(now()), priority: 'important', visibility: 'internal', subject, payload,++ };++ }++ const entEvent = (member, creator, reason, extra = {}) => envelope('billing.entitlement.changed', { type: 'entitlement', id: `${member}:channel_subscription:${creator}` }, { ...entitlement(member, creator), reason, ...extra });++++ /** Pay one period (grant or renew). Returns { sub, events }. */++ function pay(member, creator, { provider = 'credit', autoRenew = true, legacyLiveId = null } = {}) {++ const t = now();++ let s = byPair(member, creator);++ const renewal = !!(s && s.status === 'active');++ const start = s && s.current_period_end && Date.parse(s.current_period_end) > t ? Date.parse(s.current_period_end) : t;++ const end = start + 30 * DAY;++ const txn = nextId('txn');++ if (!s) {++ s = { id: nextId('sub'), subscriber: member, streamer: creator, provider, created_at: iso(t), legacy_live_id: legacyLiveId };++ subs.set(s.id, s);++ }++ Object.assign(s, { status: 'active', auto_renew: autoRenew, cancel_at_period_end: false, current_period_end: iso(end), updated_at: iso(t) });++ periods.push({ sub_id: s.id, subject: member, scope: creator, starts: start, ends: end, revoked: false, txn });++ return { sub: s, txn, events: [envelope('billing.transaction.settled', { type: 'transaction', id: txn }, { transaction_id: txn, type: 'subscription', from_subject: member, to_subject: creator }), entEvent(member, creator, renewal ? 'renewed' : 'granted', { transaction_id: txn })] };++ }++ function cancel(id) {++ const s = subs.get(id);++ s.cancel_at_period_end = true; s.auto_renew = false; s.updated_at = iso(now());++ return { sub: s, events: [envelope('billing.subscription.canceled', { type: 'subscription', id }, { subscription: present(s), provider_sync: 'not_needed' })] };++ }++ /** The renewal sweep for one subscription whose period ended without renewal. */++ function expire(id, status = 'canceled') {++ const s = subs.get(id);++ s.status = status; s.auto_renew = false; s.updated_at = iso(now());++ return { sub: s, events: [entEvent(s.subscriber, s.streamer, status)] };++ }++ /** Refund the latest period's payment: revokes it. */++ function refund(member, creator) {++ const s = byPair(member, creator);++ const p = [...periods].reverse().find((x) => x.sub_id === s.id && !x.revoked);++ p.revoked = true;++ const left = periods.filter((x) => x.sub_id === s.id && !x.revoked && x.ends > now());++ if (!left.length) { s.status = 'expired'; s.current_period_end = iso(now()); s.auto_renew = false; }++ const rtx = nextId('txn');++ return {++ sub: s,++ events: [++ envelope('billing.transaction.reversed', { type: 'transaction', id: rtx }, { transaction_id: rtx, type: 'refund', from_subject: creator, to_subject: member, reverses_txn: p.txn, entitlements_revoked: 1 }),++ entEvent(member, creator, 'refund', { revoked_period: { starts_at: iso(p.starts), ends_at: iso(p.ends) } }),++ ],++ };++ }++++ function auth(req, cap) {++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.billing' });++ if (!v.ok) return [401, { code: v.code }];++ if (cap && !(v.claims.cap || []).includes(cap)) return [403, { code: 'capability.denied', detail: `${cap} not granted` }];++ return null;++ }++++ const server = http.createServer(async (req, res) => {++ const raw = await readBody(req);++ const u = new URL(req.url, 'http://x');++ const p = u.pathname;++ calls.push({ method: req.method, path: p, query: Object.fromEntries(u.searchParams), key: req.headers['idempotency-key'] || null, body: raw ? JSON.parse(raw) : null });++ if (p === '/api/health') return state.down ? send(res, 503, { ok: false }) : send(res, 200, { ok: true });++ if (state.down) { req.socket.destroy(); return undefined; }++ const need = (cap) => { const e = auth(req, cap); if (e) { send(res, e[0], e[1]); return false; } return true; };++ const body = raw ? JSON.parse(raw) : {};++ const replay = () => { const k = req.headers['idempotency-key']; if (k && idem.has(k)) { const r = idem.get(k); send(res, r.status, r.body); return true; } return false; };++ const store = (status, out) => { const k = req.headers['idempotency-key']; if (k) idem.set(k, { status, body: out }); send(res, status, out); };++++ if (p === '/api/v1/rates' && req.method === 'GET') {++ return send(res, 200, { currency: 'vibes-bits', subscription: { price_cents: 499, streamer_share_pct: 70, site_route_fee_pct: 10, period_days: 30 }, providers: { powerchat: true, stripe: true } });++ }++ if (p === '/api/v1/intents' && req.method === 'POST') {++ if (!need('billing.intent.create')) return undefined;++ if (replay()) return undefined;++ if (body.subject.id === body.streamer.id) return send(res, 422, { code: 'billing.self_dealing' });++ if (!['powerchat', 'stripe'].includes(body.provider)) return send(res, 409, { code: 'billing.provider_disabled' });++ const id = nextId('pi');++ const intent = { id, provider: body.provider, kind: 'subscription', subject: body.subject, streamer: body.streamer, amount_cents: body.provider === 'powerchat' ? 549 : 499, status: 'created', checkout_ref: body.provider === 'powerchat' ? `pcsub:${id}` : undefined };++ return store(201, { intent, checkout_url: body.provider === 'stripe' ? `https://checkout.stripe.test/${id}` : null });++ }++ if (p === '/api/v1/subscriptions' && req.method === 'POST') {++ if (!need('billing.subscription.manage')) return undefined;++ if (replay()) return undefined;++ if (state.frozen) return send(res, 503, { code: 'billing.frozen' });++ const member = body.subscriber.id; const creator = body.streamer.id;++ if (member === creator) return send(res, 422, { code: 'billing.self_dealing' });++ const credit = state.credit.get(member) || 0;++ if (credit < 499) return send(res, 409, { code: 'billing.insufficient_funds', detail: 'not enough credit' });++ state.credit.set(member, credit - 499);++ const out = pay(member, creator, { autoRenew: body.auto_renew !== false });++ state.lastEvents = out.events;++ return store(201, { subscription: present(out.sub), entitlement: entitlement(member, creator), transaction: { id: out.txn } });++ }++ let m = p.match(/^\/api\/v1\/subscriptions\/([^/]+)\/cancel$/);++ if (m && req.method === 'POST') {++ if (!need('billing.subscription.manage')) return undefined;++ if (replay()) return undefined;++ const s = subs.get(m[1]);++ if (!s) return send(res, 404, { code: 'billing.subscription_not_found' });++ const out = cancel(s.id);++ state.lastEvents = out.events;++ return store(200, { subscription: present(out.sub), provider_sync: 'not_needed' });++ }++ m = p.match(/^\/api\/v1\/subscriptions\/([^/]+)$/);++ if (m && req.method === 'GET') {++ if (!need('billing.entitlement.check')) return undefined;++ const s = subs.get(m[1]);++ if (!s) return send(res, 404, { code: 'billing.subscription_not_found' });++ return send(res, 200, { subscription: present(s), entitlement: entitlement(s.subscriber, s.streamer) });++ }++ if (p === '/api/v1/subscriptions' && req.method === 'GET') {++ if (!need('billing.entitlement.check')) return undefined;++ const q = Object.fromEntries(u.searchParams);++ const list = [...subs.values()].filter((s) => (!q.streamer || s.streamer === q.streamer) && (!q.subscriber || s.subscriber === q.subscriber) && (!q.status || s.status === q.status));++ return send(res, 200, { subscriptions: list.map(present) });++ }++ m = p.match(/^\/api\/v1\/entitlements\/([^/]+)$/);++ if (m && req.method === 'GET') {++ if (!need('billing.entitlement.check')) return undefined;++ return send(res, 200, entitlement(m[1], u.searchParams.get('streamer')));++ }++ return send(res, 404, { code: 'not_found' });++ });++ const url = await listen(server);++ const entitlementCalls = () => calls.filter((c) => c.path.startsWith('/api/v1/entitlements/')).length;++ return { url, subs, periods, calls, state, pay, cancel, expire, refund, entitlement, envelope, entitlementCalls, byPair, pairKey, close: () => new Promise((r) => server.close(r)) };++}++++module.exports = { startNetwork, startBilling, DAY };nnew file mode 100644@@@ -0,0 +1,157 @@++'use strict';++/**++ * scripts/import-live.js: Live's subscription offering → creators, the subscriber-badge perk and a++ * v1 "Channel subscription" plan; memberships from Billing (never from Live's rows); every Live row++ * reconciled; idempotent; holds released on a later run; dry run keeps nothing.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const Database = require('better-sqlite3');++const { boot, harness } = require('./helpers/app');++++const { test, run } = harness('import');++++function liveSnapshot() {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vip-live-'));++ const file = path.join(dir, 'live.db');++ const db = new Database(file);++ db.exec(`++ CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, display_name TEXT, role TEXT DEFAULT 'user');++ CREATE TABLE subscriptions (id INTEGER PRIMARY KEY, subscriber_id INTEGER, streamer_id INTEGER, tier INTEGER DEFAULT 1, is_active INTEGER,++ started_at TEXT, expires_at TEXT, status TEXT, current_period_end TEXT, provider TEXT);++ CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT);++ INSERT INTO site_settings VALUES ('sub_price_usd', '4.99'), ('sub_streamer_share_pct', '70'), ('sub_site_route_fee_pct', '10');++ INSERT INTO users VALUES (1, 'japaneseoldguy', 'Japanese Old Guy', 'streamer'), (2, 'viewer1', 'Viewer One', 'user'),++ (3, 'viewer2', NULL, 'user'), (4, 'newstreamer', NULL, 'streamer'), (5, 'casual', NULL, 'user'), (6, 'selfmade', 'Self Made', 'streamer');++ INSERT INTO subscriptions VALUES (1, 2, 1, 1, 1, '2026-09-01 10:00:00', NULL, 'active', '2026-10-01 10:00:00', 'bucks'),++ (2, 3, 1, 1, 0, '2026-07-01 10:00:00', NULL, 'expired', '2026-08-01 10:00:00', 'stripe'),++ (3, 2, 5, 1, 1, '2026-09-10 10:00:00', NULL, 'active', '2026-10-10 10:00:00', 'bucks');++ `);++ db.close();++ return { file, dir };++}++++(async () => {++ const t = await boot();++ const { importLive, billingApiSource } = require('../server/importer/live');++ const { createIdentity } = require('../server/network');++ const identity = createIdentity(t.config);++ const snap = liveSnapshot();++ const live = new Database(snap.file, { readonly: true, fileMustExist: true });++++ // Network knows every Live user but #4 (held); Billing imported Live's subscriptions already.++ const S = {};++ for (const [id, name] of [[1, 'japaneseoldguy'], [2, 'viewer1'], [3, 'viewer2'], [5, 'casual'], [6, 'selfmade']]) S[id] = t.network.mapLive(id, name);++ t.billing.pay(S[2], S[1], { legacyLiveId: 1 });++ const expired = t.billing.pay(S[3], S[1], { legacyLiveId: 2, provider: 'stripe' }).sub;++ t.billing.expire(expired.id, 'expired');++ t.billing.pay(S[2], S[5], { legacyLiveId: 3 });++ // #6 already made their own plan on VIP before the import.++ const selfmade = { subject: S[6], username: 'selfmade', role: 'streamer' };++ const own = (await t.call('POST', '/api/v1/plans', { user: selfmade, body: { name: 'My own plan', publish: true } })).json.plan;++++ const doImport = (opts = {}) => importLive(t.domain, { live, billingSource: billingApiSource(t.domain.billing), resolveLiveUsers: identity.resolveLiveUsers, log: null, ...opts });++ const snapshotCounts = () => Object.fromEntries(['vip_creators', 'vip_plans', 'vip_plan_versions', 'vip_perks', 'vip_memberships', 'vip_migration_maps', 'vip_entitlement_projection', 'event_outbox']++ .map((x) => [x, t.domain.db.prepare(`SELECT COUNT(*) AS n FROM ${x}`).get().n]));++++ test('a dry run reports everything and keeps nothing', async () => {++ const before = snapshotCounts();++ const r = await doImport({ dryRun: true });++ assert.strictEqual(r.dry_run, true);++ assert.strictEqual(r.counts.plans.created, 2);++ assert.deepStrictEqual(snapshotCounts(), before);++ });++++ let first;++ test('the first run imports the offering, files Billing memberships and reconciles every Live row', async () => {++ first = await doImport();++ assert.strictEqual(first.ok, true, JSON.stringify(first.billing_errors));++ assert.strictEqual(first.live_offering.sub_price_usd, '4.99');++ assert.deepStrictEqual(first.counts.live, { users: 6, creators: 4, subscriptions: 3 });++ assert.deepStrictEqual(first.counts.creators, { imported: 3, unchanged: 0, held: 1, released: 0 });++ assert.deepStrictEqual(first.counts.plans, { created: 2, linked: 1, unchanged: 0 });++ assert.deepStrictEqual(first.counts.memberships, { imported: 2, unchanged: 0, excluded: 1 });++ assert.deepStrictEqual(first.counts.live_subscriptions, { linked: 3, held: 0, unchanged: 0 });++ assert.ok(first.holds.some((h) => h.live_user === 4));++++ const perk = t.domain.perks.byKey('network', 'subscriber-badge');++ assert.deepStrictEqual(t.domain.perks.bindingsOf(perk.id).map((b) => `${b.product}:${b.binding}`).sort(), ['chat:badge', 'live:ai_context', 'live:chat_badge', 'live:powerchat_overlay']);++ const jog = t.domain.creators.bySubject(S[1]);++ assert.strictEqual(jog.username, 'japaneseoldguy');++ assert.strictEqual(jog.display_name, 'Japanese Old Guy');++ const plan = t.domain.plans.purchasable(jog.id);++ assert.strictEqual(plan.slug, 'channel-subscription');++ assert.strictEqual(plan.latest_version, 1);++ assert.deepStrictEqual(t.domain.plans.versionPerks(plan.current_version_id).map((p) => p.perk_key), ['subscriber-badge']);++ const terms = JSON.parse(t.domain.plans.version(plan.current_version_id).terms);++ assert.strictEqual(terms.price, 'set and charged by OpenVibe.Billing');++ assert.doesNotMatch(JSON.stringify(terms), /\bfree\b|\$0|no ads/i);++++ const m = t.domain.memberships.get(S[2], jog.id);++ assert.strictEqual(m.origin, 'import');++ assert.strictEqual(m.plan_version_id, plan.current_version_id);++ assert.strictEqual(t.domain.memberships.get(S[3], jog.id), null, 'an expired Billing subscription is not a membership');++ assert.strictEqual(t.domain.plans.purchasable(t.domain.creators.bySubject(S[6]).id).id, own.id, 'the creator\'s own plan is linked, not replaced');++ const e = await t.call('GET', `/api/v1/entitlements/check?creator=${S[1]}&mode=projection`, { user: { subject: S[2], username: 'viewer1' } });++ assert.strictEqual(e.json.status, 'active');++++ const maps = t.domain.db.prepare("SELECT status, COUNT(*) AS n FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' GROUP BY status").all();++ assert.deepStrictEqual(maps, [{ status: 'linked', n: 3 }]);++ const expiredRow = t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '2'").get();++ assert.strictEqual(expiredRow.target_type, 'billing_subscription');++ assert.strictEqual(t.outboxEvents('vip.plan.published').length, 3);++ });++++ test('a second run changes nothing', async () => {++ const before = snapshotCounts();++ const again = await doImport();++ assert.deepStrictEqual(snapshotCounts(), before);++ assert.deepStrictEqual(again.counts.plans, { created: 0, linked: 0, unchanged: 3 });++ assert.deepStrictEqual(again.counts.memberships, { imported: 0, unchanged: 3, excluded: 0 });++ assert.deepStrictEqual(again.counts.live_subscriptions, { linked: 0, held: 0, unchanged: 3 });++ assert.strictEqual(again.counts.creators.held, 1);++ });++++ test('a creator\'s edits after the import are never overwritten', async () => {++ const jog = t.domain.creators.bySubject(S[1]);++ const plan = t.domain.plans.purchasable(jog.id);++ t.domain.plans.update(plan.id, { name: 'Japanese Old Guy club', actor: S[1] });++ await doImport();++ assert.strictEqual(t.domain.plans.latestVersion(plan.id).version, 2);++ assert.strictEqual(t.domain.plans.latestVersion(plan.id).name, 'Japanese Old Guy club');++ });++++ test('a held creator is released once the Network knows them', async () => {++ S[4] = t.network.mapLive(4, 'newstreamer');++ const r = await doImport();++ assert.strictEqual(r.counts.creators.released, 1);++ assert.strictEqual(r.counts.creators.held, 0);++ assert.strictEqual(r.counts.plans.created, 1);++ assert.strictEqual(t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'users' AND source_id = '4'").get().status, 'imported');++ });++++ test('Billing unreachable: the run says so, holds what it could not reconcile and never invents memberships', async () => {++ const x = t.network.mapLive(7, 'late');++ const db2 = new Database(snap.file);++ db2.exec("INSERT INTO users VALUES (7, 'late', NULL, 'streamer'); INSERT INTO subscriptions VALUES (4, 2, 7, 1, 1, '2026-09-20 10:00:00', NULL, 'active', '2026-10-20 10:00:00', 'bucks');");++ db2.close();++ t.billing.pay(S[2], x);++ t.billing.state.down = true;++ let r;++ try { r = await doImport(); } finally { t.billing.state.down = false; }++ assert.strictEqual(r.ok, false);++ assert.ok(r.billing_errors.length >= 1);++ assert.strictEqual(t.domain.memberships.get(S[2], t.domain.creators.bySubject(x).id), null);++ const row = t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get();++ assert.strictEqual(row.status, 'held');++ assert.match(row.reason, /Billing was not reachable/);++ const r2 = await doImport();++ assert.strictEqual(r2.ok, true);++ assert.strictEqual(t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get().status, 'linked');++ assert.ok(t.domain.memberships.get(S[2], t.domain.creators.bySubject(x).id));++ });++++ await run().finally(() => { live.close(); fs.rmSync(snap.dir, { recursive: true, force: true }); return t.close(); });++})();nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++/**++ * Plans are versioned: an edit creates a new version, historical terms are never rewritten, and a++ * membership keeps the version it was bought under (renewals too); a new purchase after a lapse++ * takes the current version.++ */++const assert = require('assert');++const { boot, harness } = require('./helpers/app');++const { DAY } = require('./helpers/stubs');++++const { test, run } = harness('plans');++++(async () => {++ const t = await boot();++ const creator = t.network.newUser('alice', { role: 'streamer' });++ const early = t.network.newUser('bob');++ const late = t.network.newUser('carol');++ let plan;++++ test('a creator defines perks and publishes a plan (v1) with them', async () => {++ const perk = await t.call('POST', '/api/v1/perks', { user: creator, body: { name: 'Members emotes', key: 'emotes', kind: 'emote', bindings: [{ product: 'chat', binding: 'emote_set', config: { set: 'alice' } }] } });++ assert.strictEqual(perk.status, 201, perk.text);++ assert.deepStrictEqual(perk.json.perk.bindings, [{ product: 'chat', binding: 'emote_set', config: { set: 'alice' } }]);++ const perk2 = await t.call('POST', '/api/v1/perks', { user: creator, body: { name: 'Supporter role', key: 'supporter-role', kind: 'role' } });++ assert.strictEqual(perk2.status, 201);++ const res = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Inner circle', description: 'Monthly support', benefits: ['Emotes', 'A role'], perks: ['emotes', 'supporter-role'], publish: true } });++ assert.strictEqual(res.status, 201, res.text);++ plan = res.json.plan;++ assert.strictEqual(plan.status, 'published');++ assert.strictEqual(plan.billing_kind, 'channel_subscription');++ assert.strictEqual(plan.current_version.version, 1);++ assert.deepStrictEqual(plan.current_version.perks.map((p) => p.key), ['emotes', 'supporter-role']);++ const ev = t.outboxEvents('vip.plan.published');++ assert.strictEqual(ev.length, 1);++ assert.strictEqual(ev[0].subject.id, plan.id);++ assert.strictEqual(ev[0].payload.version, 1);++ assert.strictEqual(ev[0].visibility, 'public');++ });++++ test('a member who joins under v1 is filed under v1', async () => {++ await t.deliverAll(t.billing.pay(early.subject, creator.subject).events);++ const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);++ assert.strictEqual(m.plan_version_id, plan.current_version.id);++ });++++ test('editing a published plan creates v2, publishes it, and never touches v1', async () => {++ const before = t.domain.plans.version(plan.current_version.id);++ const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'Inner circle', perks: ['supporter-role'], benefits: ['A role'], change_note: 'emotes moved to a separate plan' } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json.version.version, 2);++ assert.strictEqual(res.json.plan.current_version.version, 2);++ const after = t.domain.plans.version(plan.current_version.id);++ assert.deepStrictEqual(after, before, 'v1 row unchanged');++ const versions = await t.call('GET', `/api/v1/plans/${plan.id}/versions`, { token: null });++ assert.deepStrictEqual(versions.json.versions.map((v) => v.version), [2, 1]);++ assert.deepStrictEqual(versions.json.versions[1].perks.map((p) => p.key), ['emotes', 'supporter-role']);++ assert.strictEqual(t.outboxEvents('vip.plan.published').length, 2);++ });++++ test('the database refuses to rewrite or delete a version', () => {++ assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_versions SET name = 'rewritten' WHERE id = ?").run(plan.current_version.id), /immutable/);++ assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_versions SET terms = '{}' WHERE id = ?").run(plan.current_version.id), /immutable/);++ assert.throws(() => t.domain.db.prepare('DELETE FROM vip_plan_versions WHERE id = ?').run(plan.current_version.id), /immutable/);++ assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_perks SET perk_name = 'x' WHERE plan_version_id = ?").run(plan.current_version.id), /immutable/);++ });++++ test('an edit that changes nothing creates no version', async () => {++ const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'Inner circle' } });++ assert.strictEqual(res.json.unchanged, true);++ assert.strictEqual(res.json.plan.current_version.version, 2);++ });++++ test('renaming a perk does not change published terms', async () => {++ const perk = t.domain.perks.byKey(t.domain.creators.bySubject(creator.subject).id, 'emotes');++ await t.call('PATCH', `/api/v1/perks/${perk.id}`, { user: creator, body: { name: 'Renamed emotes' } });++ const v1 = (await t.call('GET', `/api/v1/plans/${plan.id}/versions`, { token: null })).json.versions[1];++ assert.strictEqual(v1.perks.find((p) => p.key === 'emotes').name, 'Members emotes');++ });++++ test('old members keep old terms; new members get the new version', async () => {++ await t.deliverAll(t.billing.pay(late.subject, creator.subject).events);++ const cid = t.domain.creators.bySubject(creator.subject).id;++ const mEarly = t.domain.memberships.get(early.subject, cid);++ const mLate = t.domain.memberships.get(late.subject, cid);++ assert.strictEqual(t.domain.plans.version(mEarly.plan_version_id).version, 1);++ assert.strictEqual(t.domain.plans.version(mLate.plan_version_id).version, 2);++ assert.deepStrictEqual(t.domain.memberships.perkKeys(mEarly), ['emotes', 'supporter-role']);++ assert.deepStrictEqual(t.domain.memberships.perkKeys(mLate), ['supporter-role']);++ const status = await t.call('GET', `/api/v1/memberships/${early.subject}`, { user: early });++ assert.strictEqual(status.status, 200);++ assert.strictEqual(status.json.memberships[0].plan_version.version, 1);++ assert.strictEqual(status.json.memberships[0].entitlement.status, 'active');++ });++++ test('a renewal keeps the version the membership was bought under', async () => {++ t.clock.advance(29 * DAY);++ await t.deliverAll(t.billing.pay(early.subject, creator.subject).events); // reason: renewed++ const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);++ assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 1);++ });++++ test('a new purchase after the membership lapsed takes the current version', async () => {++ const sub = t.billing.byPair(late.subject, creator.subject);++ await t.deliverAll(t.billing.cancel(sub.id).events);++ t.clock.advance(2 * DAY); // late's period (30 days from day 0) ended on day 30++ await t.deliverAll(t.billing.expire(sub.id, 'canceled').events);++ await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { benefits: ['A role', 'Monthly Q&A'], change_note: 'added Q&A' } });++ await t.deliverAll(t.billing.pay(late.subject, creator.subject).events); // reason: granted++ const m = t.domain.memberships.get(late.subject, t.domain.creators.bySubject(creator.subject).id);++ assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 3);++ });++++ test('Billing sells one channel subscription per creator: a second published plan is refused', async () => {++ const res = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second', publish: true } });++ assert.strictEqual(res.status, 409);++ assert.strictEqual(res.json.code, 'vip.plan.billing_product_taken');++ const draft = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second' } });++ assert.strictEqual(draft.status, 201);++ assert.strictEqual((await t.call('POST', `/api/v1/plans/${draft.json.plan.id}/publish`, { user: creator })).status, 409);++ assert.strictEqual((await t.call('POST', `/api/v1/plans/${plan.id}/archive`, { user: creator })).status, 200);++ const pub = await t.call('POST', `/api/v1/plans/${draft.json.plan.id}/publish`, { user: creator });++ assert.strictEqual(pub.status, 200, pub.text);++ });++++ test('archived plans keep their members on their terms and cannot be edited', async () => {++ const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);++ assert.strictEqual(m.plan_id, plan.id);++ const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'x' } });++ assert.strictEqual(res.status, 409);++ });++++ test('only the creator (or a service holding the capability) manages a plan', async () => {++ const other = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: early, body: { name: 'hijack' } });++ assert.strictEqual(other.status, 403);++ const svcNo = await t.call('POST', '/api/v1/plans', { cap: ['vip.plan.list'], body: { creator: { type: 'user', id: creator.subject }, name: 'x' } });++ assert.strictEqual(svcNo.status, 403);++ assert.strictEqual(svcNo.json.code, 'capability.denied');++ const svc = await t.call('POST', '/api/v1/plans', { cap: ['vip.plan.create'], body: { creator: { type: 'user', id: creator.subject }, name: 'Via Live' } });++ assert.strictEqual(svc.status, 201, svc.text);++ assert.strictEqual(svc.json.plan.status, 'draft');++ const anon = await t.call('POST', '/api/v1/plans', { token: null, body: { name: 'x' } });++ assert.strictEqual(anon.status, 401);++ });++++ test('network plans are staff-only and have no Billing product yet', async () => {++ const staff = t.network.newUser('root', { role: 'admin' });++ const no = await t.call('POST', '/api/v1/plans', { user: creator, body: { creator: 'network', name: 'Network pass' } });++ assert.strictEqual(no.status, 403);++ const yes = await t.call('POST', '/api/v1/plans', { user: staff, body: { creator: 'network', name: 'Network pass', publish: true } });++ assert.strictEqual(yes.status, 201, yes.text);++ assert.strictEqual(yes.json.plan.billing_kind, null);++ assert.strictEqual(yes.json.plan.purchasable, false);++ const co = await t.call('POST', '/api/v1/checkout', { user: early, body: { plan_id: yes.json.plan.id, provider: 'stripe' } });++ assert.strictEqual(co.status, 409);++ assert.strictEqual(co.json.code, 'vip.checkout.unavailable');++ });++++ test('drafts are hidden from the public', async () => {++ const drafts = t.domain.plans.list({ creatorId: t.domain.creators.bySubject(creator.subject).id, includeDrafts: true }).filter((p) => p.status === 'draft');++ const res = await t.call('GET', `/api/v1/plans/${drafts[0].id}`, { token: null });++ assert.strictEqual(res.status, 404);++ const own = await t.call('GET', `/api/v1/plans/${drafts[0].id}`, { user: creator });++ assert.strictEqual(own.status, 200);++ });++++ await run().finally(() => t.close());++})();nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++/**++ * Gated-resource policy: "may subject S see resource R?" — and every doubt is a no (fail closed).++ * Also the consumer seam (openvibe-vip/client), which turns every failure into a denial.++ */++const assert = require('assert');++const { boot, harness } = require('./helpers/app');++const { createVipClient } = require('../client/vip-client');++++const { test, run } = harness('policies');++++(async () => {++ const t = await boot();++ const creator = t.network.newUser('erin', { role: 'streamer' });++ const member = t.network.newUser('finn');++ const oldMember = t.network.newUser('gus');++ const stranger = t.network.newUser('hal');++ const blog = { cap: ['vip.resource.policy.evaluate'], sub: 'svc:blog' };++ const post = { service: 'blog', type: 'post', id: '42' };++ const evaluate = async (subject, resource = post, extra = {}) => (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject, resource, ...extra } })).json;++ let plan;++++ await t.call('POST', '/api/v1/perks', { user: creator, body: { name: 'Backstage posts', key: 'backstage', kind: 'gated_content', bindings: [{ product: 'blog', binding: 'gated_post' }] } });++ plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Backstage', perks: ['backstage'], publish: true } })).json.plan;++ await t.deliverAll(t.billing.pay(oldMember.subject, creator.subject).events); // joins under v1 (with the perk)++ await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { perks: [] } }); // v2 drops it++ await t.deliverAll(t.billing.pay(member.subject, creator.subject).events); // joins under v2++++ test('no rule for a resource → denied', async () => {++ const d = await evaluate(member.subject);++ assert.deepStrictEqual([d.allow, d.reason], [false, 'no_rule']);++ });++++ test('a creator gates a resource; members pass, others are refused', async () => {++ const r = await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: post, requirement: 'member' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual(r.json.rule.resource, post);++ let d = await evaluate(member.subject);++ assert.deepStrictEqual([d.allow, d.reason], [true, 'member']);++ d = await evaluate(stranger.subject);++ assert.deepStrictEqual([d.allow, d.reason], [false, 'not_a_member']);++ d = await evaluate(null);++ assert.deepStrictEqual([d.allow, d.reason], [false, 'not_signed_in']);++ d = await evaluate('gst_01J0000000000000000000000Z');++ assert.strictEqual(d.allow, false);++ d = await evaluate(creator.subject);++ assert.deepStrictEqual([d.allow, d.reason], [true, 'owner']);++ });++++ test('a perk rule follows the version the member bought: v1 has the perk, v2 does not', async () => {++ const res = { service: 'blog', type: 'post', id: 'backstage-1' };++ await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'perk', perk_key: 'backstage' } });++ assert.strictEqual((await evaluate(oldMember.subject, res)).allow, true);++ const d = await evaluate(member.subject, res);++ assert.deepStrictEqual([d.allow, d.reason], [false, 'perk_missing']);++ });++++ test('a plan rule admits members of that plan only', async () => {++ const res = { service: 'wiki', type: 'page', id: 'p1' };++ const r = await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'plan', plan_id: plan.id } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual((await evaluate(member.subject, res)).allow, true);++ assert.strictEqual((await evaluate(stranger.subject, res)).allow, false);++ });++++ test('an entitlement VIP cannot confirm is a denial (Billing down, no fresh projection)', async () => {++ const newcomer = t.network.newUser('ivy');++ t.billing.state.down = true;++ try {++ const d = await evaluate(newcomer.subject);++ assert.deepStrictEqual([d.allow, d.reason], [false, 'entitlement_unknown']);++ t.clock.advance(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1);++ const d2 = await evaluate(member.subject);++ assert.deepStrictEqual([d2.allow, d2.reason], [false, 'entitlement_unknown']);++ } finally {++ t.billing.state.down = false;++ t.clock.advance(-(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1));++ }++ });++++ test('a sensitive rule always asks Billing: with Billing down even a fresh member is refused', async () => {++ const res = { service: 'community', type: 'space', id: 'vault' };++ await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'member', sensitive: true } });++ const calls = t.billing.entitlementCalls();++ assert.strictEqual((await evaluate(member.subject, res)).allow, true);++ assert.strictEqual(t.billing.entitlementCalls(), calls + 1);++ t.billing.state.down = true;++ try {++ const d = await evaluate(member.subject, res);++ assert.deepStrictEqual([d.allow, d.reason], [false, 'entitlement_unknown']);++ assert.strictEqual((await evaluate(member.subject, post)).allow, true, 'a non-sensitive rule still uses the fresh projection');++ } finally { t.billing.state.down = false; }++ });++++ test('a refunded member loses access as soon as the refund arrives', async () => {++ const res = { service: 'blog', type: 'post', id: 'refund-check' };++ await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res } });++ const x = t.network.newUser('jay');++ await t.deliverAll(t.billing.pay(x.subject, creator.subject).events);++ assert.strictEqual((await evaluate(x.subject, res)).allow, true);++ await t.deliverAll(t.billing.refund(x.subject, creator.subject).events);++ assert.strictEqual((await evaluate(x.subject, res)).allow, false);++ });++++ test('rule pinning, disabled rules, malformed resources and unknown rules all deny', async () => {++ const rule = (await t.call('GET', `/api/v1/policies?service=blog&type=post&id=42`, { cap: ['vip.resource.policy.get'] })).json.rule;++ assert.ok(rule && rule.id);++ assert.strictEqual((await evaluate(member.subject, post, { rule_id: rule.id })).allow, true);++ assert.strictEqual((await evaluate(member.subject, { service: 'blog', type: 'post', id: 'other' }, { rule_id: rule.id })).reason, 'rule_mismatch');++ assert.strictEqual((await evaluate(member.subject, { service: 'blog' })).reason, 'invalid_resource');++ assert.strictEqual((await evaluate(member.subject, post, { rule_id: 'vgr_nope' })).reason, 'no_rule');++ const del = await t.call('DELETE', `/api/v1/policies/${rule.id}`, { user: creator });++ assert.strictEqual(del.status, 200);++ assert.strictEqual((await evaluate(member.subject, post, { rule_id: rule.id })).reason, 'rule_disabled');++ assert.strictEqual((await evaluate(member.subject, post)).reason, 'no_rule');++ });++++ test('only the creator sets rules; another creator cannot take over a gated resource', async () => {++ const res = { service: 'blog', type: 'post', id: 'mine' };++ await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res } });++ const other = t.network.newUser('kim', { role: 'streamer' });++ const r = await t.call('POST', '/api/v1/policies', { user: other, body: { resource: res } });++ assert.strictEqual(r.status, 409);++ const svcNo = await t.call('POST', '/api/v1/policies', { cap: ['vip.resource.policy.evaluate'], body: { creator: { type: 'user', id: creator.subject }, resource: res } });++ assert.strictEqual(svcNo.status, 403);++ const evalNo = await t.call('POST', '/api/v1/policies/evaluate', { cap: ['vip.entitlement.check'], body: { subject: member.subject, resource: res } });++ assert.strictEqual(evalNo.status, 403);++ const self = await t.call('POST', '/api/v1/policies/evaluate', { user: member, body: { resource: res } });++ assert.strictEqual(self.json.allow, true);++ const spy = await t.call('POST', '/api/v1/policies/evaluate', { user: stranger, body: { subject: member.subject, resource: res } });++ assert.strictEqual(spy.status, 403);++ });++++ test('the consumer client: allows members, and fails closed on every failure', async () => {++ const res = { service: 'blog', type: 'post', id: 'mine' };++ const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: ['vip.resource.policy.evaluate', 'vip.entitlement.check'] })}` }) };++ const vip = createVipClient({ baseUrl: t.base, tokenClient });++ assert.strictEqual((await vip.evaluate({ subject: member.subject, resource: res })).allow, true);++ assert.strictEqual((await vip.evaluate({ subject: stranger.subject, resource: res })).allow, false);++ assert.strictEqual(await vip.isMember(member.subject, creator.subject), true);++ assert.strictEqual(await vip.isMember(stranger.subject, creator.subject), false);++++ const down = createVipClient({ baseUrl: 'http://127.0.0.1:9', tokenClient, timeoutMs: 500 });++ const d = await down.evaluate({ subject: member.subject, resource: res });++ assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);++ assert.strictEqual(await down.isMember(member.subject, creator.subject), false);++++ const noCap = createVipClient({ baseUrl: t.base, tokenClient: { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: [] })}` }) } });++ assert.strictEqual((await noCap.evaluate({ subject: member.subject, resource: res })).allow, false);++ const badToken = createVipClient({ baseUrl: t.base, getToken: async () => 'not-a-token' });++ assert.strictEqual((await badToken.evaluate({ subject: member.subject, resource: res })).allow, false);++ const tokenFails = createVipClient({ baseUrl: t.base, getToken: async () => { throw new Error('no grant'); } });++ assert.strictEqual((await tokenFails.checkEntitlement({ subject: member.subject, creator: creator.subject })).status, 'unknown');++ const garbage = createVipClient({ baseUrl: t.base, tokenClient, fetch: async () => new Response('{"allow":"yes"}', { status: 200 }) });++ assert.strictEqual((await garbage.evaluate({ subject: member.subject, resource: res })).allow, false, 'only a literal true allows');++ });++++ await run().finally(() => t.close());++})();nnew file mode 100644@@@ -0,0 +1,57 @@++#!/usr/bin/env node++/**++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails++ * if any of them fails. They use in-process stubs of OpenVibe.Network and OpenVibe.Billing,++ * temp databases and random ports; none needs the network or a++ * running service.++ *++ * npm test # everything++ * npm test -- plans import # only files whose name contains one of the words++ */++'use strict';++const { spawn } = require('child_process');++const fs = require('fs');++const path = require('path');++++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));++const files = fs.readdirSync(__dirname)++ .filter((f) => f.endsWith('.test.js'))++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))++ .sort();++const TIMEOUT_MS = 60000;++++function runOne(file) {++ return new Promise((resolve) => {++ const started = Date.now();++ const child = spawn(process.execPath, [path.join(__dirname, file)], {++ cwd: path.join(__dirname, '..'),++ env: { ...process.env, NODE_ENV: 'test' },++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ let output = '';++ child.stdout.on('data', (c) => { output += c; });++ child.stderr.on('data', (c) => { output += c; });++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);++ child.on('close', (code, signal) => {++ clearTimeout(timer);++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });++ });++ });++}++++(async () => {++ if (!files.length) { console.error('no test files matched'); process.exit(1); }++ const results = [];++ for (const f of files) {++ const r = await runOne(f);++ results.push(r);++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(28)} ${String(r.ms).padStart(6)}ms`);++ }++ const failed = results.filter((r) => !r.ok);++ for (const r of failed) {++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);++ console.log(r.output.split('\n').slice(-60).join('\n'));++ }++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);++ process.exit(failed.length ? 1 : 0);++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+# OpenVibe.VIP — environment (development: .env; production: /etc/openvibe/vip.env).
+# Names only here; never commit a value.
+
+# ── Runtime ──────────────────────────────────────────────
+NODE_ENV=development
+PORT=4620
+HOST=127.0.0.1
+BASE_URL=http://localhost:4620
+VIP_DB_PATH=./data/vip.db
+# TRUST_PROXY=1
+
+# ── OpenVibe.Network (identity) ──────────────────────────
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# OV_NETWORK_ISSUER=https://openvibe.network
+# OV_NETWORK_PUBLIC_KEY= # optional; otherwise fetched from /api/.well-known/jwks
+# VIP_AUDIENCE=openvibe.vip
+# Client `vip`: service tokens (Billing, Events, Network) and browser sign-in
+OV_OAUTH_CLIENT_ID=vip
+OV_OAUTH_CLIENT_SECRET=
+# OV_OAUTH_REDIRECT_URI=https://openvibe.vip/auth/callback
+# Signs the pages' anti-forgery tokens (required in production): openssl rand -hex 32
+VIP_FORM_SECRET=
+# Network roles that manage network plans and see any creator's members
+VIP_STAFF_ROLES=admin
+
+# ── OpenVibe.Billing (entitlement truth, checkout, cancel) ──
+BILLING_URL=http://127.0.0.1:4600
+# BILLING_AUDIENCE=openvibe.billing
+# VIP_BILLING_TIMEOUT_MS=5000
+# Providers offered on plan pages (must be enabled on Billing); `credit` pays from Vibes credit
+VIP_CHECKOUT_PROVIDERS=powerchat,credit
+# Turns Billing's PowerChat checkout_ref into a link: {ref} and {cents}
+VIP_POWERCHAT_LINK_TEMPLATE=
+# VIP_BILLING_RATES_TTL_MS=300000
+
+# ── Entitlement projection ───────────────────────────────
+# A projection answer is trusted at most this long after VIP last heard from Billing (and never
+# past the paid period's end); within the grace a projection-only check still answers (stale),
+# after it VIP answers "unknown" until Billing confirms.
+VIP_PROJECTION_MAX_AGE_MS=900000
+VIP_PROJECTION_GRACE_MS=60000
+
+# ── OpenVibe.Events ──────────────────────────────────────
+# Outbox relay (vip.plan.published, vip.membership.changed); off when empty
+EVENTS_URL=
+# EVENTS_RELAY_INTERVAL_MS=2000
+# Signing secret of VIP's Billing-event subscriptions (scripts/subscribe.js); comma-separate to rotate
+VIP_EVENTS_SECRET=
+
+# ── Jobs ─────────────────────────────────────────────────
+# VIP_JOBS=off
+# VIP_REFRESH_INTERVAL_MS=60000
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # production runs Node 22
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - run: npm test
+ # The vip.* capabilities and the alpha service manifest are proposals (docs/) until the lead
+ # releases them in openvibe-contracts; until then the check reports the placeholder manifest.
+ - run: npx openvibe-contracts-check --service vip
+ continue-on-error: true
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
@@ -2,69 +2,276 @@
@@ -2,69 +2,276 @@
> Memberships: plans, perks and benefits recognised across every OpenVibe site.
> Memberships: plans, perks and benefits recognised across every OpenVibe site.
-**Status:** placeholder — planning only, no runnable code yet.
-**Domain:** `openvibe.vip`
-**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.3.
+**Status:** alpha — runtime built and tested (roadmap Wave 10), **not deployed**. `openvibe.vip` keeps
+its [OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) placeholder until the launch rule
+below holds.
+**Domain:** `openvibe.vip` (port 4620, service id `vip`)
+**Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) —
+Billing holds the entitlement truth; VIP holds plans and perks.
+**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.3; roadmap §15.11, §29, Wave 10.
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
## Purpose
## Purpose
-Creator and network membership plans and perks with product bindings. Billing remains the subscription and entitlement truth; VIP owns plan versions, perk definitions, member/creator UX and gated-resource policy references.
+Creator and network membership plans and perks with product bindings. A creator publishes a plan;
+a member joins it through OpenVibe.Billing; every OpenVibe product can ask VIP whether a person is a
+member and whether they may see a members-only resource. VIP owns plan versions, perk definitions,
+member and creator UX and gated-resource policy; Billing remains the subscription and entitlement
+truth.
## Owns
## Owns
-- `vip_creators`, `vip_plans`, `vip_plan_versions`, `vip_perks`, `vip_plan_perks`, `vip_product_bindings`, `vip_member_preferences`, `vip_gated_resource_rules`, `vip_migration_maps`
+The nine authority tables (VIP is their only writer):
+
+| Table | Holds |
+|---|---|
+| `vip_creators` | a creator (a Network `usr_` subject, with a cached handle for `/:username`) or the network itself |
+| `vip_plans` | a plan: status (draft/published/archived), the Billing product it is sold as, its current version |
+| `vip_plan_versions` | **immutable** plan terms, one row per edit (SQLite triggers refuse UPDATE/DELETE except the one-time publish stamp) |
+| `vip_perks` | perk definitions (creator or network scope): badge, emote, gated content, room, role, other |
+| `vip_plan_perks` | **immutable**: which perks a plan *version* includes, with the key and name it was published with |
+| `vip_product_bindings` | how a perk shows up in a product (`live chat_badge`, `chat badge`, `blog gated_post`, …) |
+| `vip_member_preferences` | per member and creator: show the badge, be listed |
+| `vip_gated_resource_rules` | "resource `{service, type, id}` is for members of creator C (of plan P / with perk K)", optionally *sensitive* |
+| `vip_migration_maps` | legacy source row → VIP target, with status (`imported`, `linked`, `held`, `excluded`) and reason |
+
+Operational tables, authority for nothing outside VIP: `vip_memberships` (which plan **version** a
+membership was bought under — never whether it is active), `vip_entitlement_projection` (a cache of
+Billing entitlements with `valid_until`), `vip_checkouts` (checkout hand-offs), and the openvibe-sdk
+`event_outbox` / `idempotency_receipts`. `vip_memberships` is a tenth table the charter did not
+list; it is needed so that "a membership always points at the version it was bought under" has a
+home that is not a cache.
## Does not own
## Does not own
-- subscription contracts, charges, renewals, refunds, canonical entitlements (Billing)
+- subscription contracts, prices, charges, renewals, cancellations, refunds, chargebacks and the
+ canonical entitlements — **OpenVibe.Billing**. VIP never prices anything and never moves money;
+ plan terms carry no price (`"price": "set and charged by OpenVibe.Billing"`).
+- identities (OpenVibe.Network), the products' own resources (Blog posts, Chat rooms, …: VIP stores
+ only an EntityRef), badges' rendering (each product).
-## Planned surfaces
+## Depends on
-- plan/perk management, member status, checkout hand-off, badges/cards/widgets
-- entitlement projection client with a direct authoritative check for sensitive actions
+- **OpenVibe.Billing** — checkout (`billing.intent.create`), credit-paid periods and cancel
+ (`billing.subscription.manage`), the authoritative check and subscription lists (`billing.entitlement.check`),
+ and its events `billing.entitlement.changed`, `billing.subscription.canceled`, `billing.transaction.reversed`.
+- **OpenVibe.Network** — RS256 service and user tokens (JWKS), OAuth sign-in for the pages, identity
+ resolve (importer only).
+- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.
+- **openvibe-contracts** v0.14.0, **openvibe-sdk** v0.2.2 (outbox, inbox, delivery signatures),
+ **openvibe-shared** v1.3.0 (chrome, legal pages, release, metrics, readiness).
+- Consumers (not wired yet): Live, Chat, Community, Blog, Wiki — through the client below.
+- **Not** OpenVibe.Live: VIP works with Live unavailable (nothing here calls Live).
-## Data (authority tables / families)
+## Run it
-- see above
+```bash
+fnm exec --using=22.22.1 npm install
+cp .env.example .env # OV_OAUTH_CLIENT_SECRET, VIP_FORM_SECRET, VIP_EVENTS_SECRET, BILLING_URL …
+fnm exec --using=22.22.1 npm run dev # http://localhost:4620
+fnm exec --using=22.22.1 npm test # 5 test files: stub Network + Billing, temp DBs, random ports, injected clock
+node scripts/subscribe.js # create the three Events subscriptions for Billing's events
+node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]
+```
-## Capabilities and events
+Production (at launch): `/opt/openvibe.vip`, env `/etc/openvibe/vip.env`, unit
+[deploy/systemd/openvibe-vip.service](deploy/systemd/openvibe-vip.service) (state in `/var/lib/openvibe-vip`),
+vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf).
-- `vip.plan.*`, `vip.perk.*`, `vip.membership.checkout|status`, `vip.entitlement.check`, `vip.resource.policy.get|evaluate`, `vip.creator.members.list`
+## Design
-Events: ``vip.plan.published``, ``vip.membership.changed` (projection of Billing events)`
+### Versioned plans
-## Depends on
+- Creating a plan writes version 1. **Every edit writes version N+1**; nothing ever updates an earlier
+ version (triggers enforce it). An edit of a published plan is published at once and becomes what
+ new members buy (`vip.plan.published`); a draft's edits stay drafts until published.
+- A membership records the version it was bought under: the member's own checkout hand-off, or —
+ for a subscription bought elsewhere (e.g. on Live before its cutover) — the creator's current
+ purchasable version at the moment Billing granted it. **Renewals keep that version**, however
+ often the plan changes; a new purchase after the membership lapsed takes the current version.
+- Perk rules evaluate the perks of the member's version, not the plan's latest.
+- Renaming a perk never changes published terms (each version keeps its snapshot).
+- Billing sells one channel subscription per (member, creator) today, so a creator has at most **one
+ published plan sold through Billing** (a unique index; a second is refused with
+ `409 vip.plan.billing_product_taken`). Tiers and network memberships need a Billing product first:
+ a network plan (staff) can be published as terms, but checkout answers `409 vip.checkout.unavailable`.
+- Archiving stops new members; existing members keep their terms until Billing ends the membership.
+
+### The entitlement projection (Billing is the truth)
+
+`vip_entitlement_projection` is a cache of Billing's entitlements, fed by Billing's events
+(signed Events webhook + openvibe-sdk `createInbox`, exactly once) and by direct checks.
+
+- `valid_until = min(paid period end, last word from Billing + VIP_PROJECTION_MAX_AGE_MS)`; a "no"
+ is kept for the max age.
+- `now ≤ valid_until`: the projection answers. Past it, mode `auto` asks Billing
+ (`GET /api/v1/entitlements/:subject?streamer=`) and re-projects; only if Billing cannot answer
+ and we are within `VIP_PROJECTION_GRACE_MS` does it return the stale answer (flagged `stale: true`).
+- After the grace, **VIP answers `"unknown"`, which never authorizes** — a stale cache cannot
+ authorize indefinitely, whatever the paid period says.
+- Mode `authoritative` (sensitive actions: checkout, cancel, rules marked *sensitive*) always asks
+ Billing and answers `unknown` when it cannot.
+- Ordering: each row remembers the Billing time it reflects. An older event that would grant is
+ ignored; an older event that would revoke puts the row in doubt (`valid_until = 0`), so the next
+ check goes to Billing — out-of-order delivery can deny briefly, never grant wrongly.
+- `billing.transaction.reversed` that revoked periods puts the pair in doubt at once, even before (or
+ without) the matching `billing.entitlement.changed`.
+- Convergence window: seconds when events flow; when every event for a change is lost, at most
+ `VIP_PROJECTION_MAX_AGE_MS + VIP_PROJECTION_GRACE_MS` (15 min + 60 s by default), and never past
+ the paid period's end for renewals and cancellations.
+- A background job re-confirms, with Billing, projections that products used in the last hour
+ before they go stale.
+
+### Checkout and cancellation (Billing does both)
+
+- `POST /api/v1/checkout` asks Billing first (a member cannot buy twice), records the plan version
+ chosen, and hands off: `provider: credit` → Billing `POST /subscriptions` (paid from Vibes credit,
+ membership starts at once); any other provider → Billing `POST /intents` (kind subscription),
+ returning Billing's `checkout_url` or PowerChat `checkout_ref`. The membership starts when Billing's
+ settlement event arrives. Idempotency keys come from VIP's ids (`vip:checkout:<id>`).
+- Cancel is the member's own action: VIP confirms with Billing that the subscription is theirs, then
+ Billing `POST /subscriptions/:id/cancel` (at period end).
+
+### Events (openvibe-sdk outbox, same transaction as the change)
+
+| Event | When | Payload |
+|---|---|---|
+| `vip.plan.published` (public) | a plan version becomes what new members buy | plan id, slug, version, version id, creator, billing_kind, full terms |
+| `vip.membership.changed` (internal) | a member's standing changed as projected from Billing (granted, renewed, cancel_scheduled, canceled, expired, refund, …) | member, creator, active, expires_at, cancel_at_period_end, previous, reason, plan id, plan version, source, Billing event id |
+
+## API (`/api/v1`)
+
+Service tokens: Network client-credentials, audience `openvibe.vip`, one capability per route (the
+`vip.*` ids are proposals in [docs/capabilities-proposal/](docs/capabilities-proposal/); until they ship
+in an openvibe-contracts release, grants are matched with contracts' `capabilities.grants()` — exact id
+or a `.*` family). User tokens: a Network user JWT; people act on their own things (their plans,
+perks, rules, members; their own memberships and checks); staff roles (`VIP_STAFF_ROLES`) manage
+network plans. Errors are RFC 9457 problem+json. Creators are named by SubjectRef, `usr_` id, VIP
+handle or `network`.
+
+| Method & path | Capability (services) | Users | Does |
+|---|---|---|---|
+| `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` | — | — | liveness; truthful readiness (db required; Network key and Billing optional → degraded); release; loopback metrics |
+| `GET /creators/:ref` | — (public) | owner sees drafts | creator, published plans, own perks |
+| `GET /plans?creator=&include=drafts,archived` | `vip.plan.list` for drafts | owner | plans with current/draft versions |
+| `GET /plans/:id`, `GET /plans/:id/versions` | `vip.plan.list` for drafts | owner | a plan; its version history (published versions are public) |
+| `POST /plans` | `vip.plan.create` (body `creator`) | self / staff for `network` | create (v1), optionally `publish` |
+| `PATCH /plans/:id` | `vip.plan.update` | owner | edit → new version |
+| `POST /plans/:id/publish` | `vip.plan.update` | owner | publish the latest version |
+| `POST /plans/:id/archive` | `vip.plan.archive` | owner | archive |
+| `GET /perks?creator=&network=&retired=` | `vip.perk.list` for retired | owner | perks with product bindings |
+| `POST /perks`, `PATCH /perks/:id` | `vip.perk.create` / `vip.perk.update` | owner | define / edit a perk and its bindings |
+| `POST /checkout` | `vip.membership.checkout` (body `subject`) | self | hand-off to Billing: `{ checkout, membership_started, checkout_url, checkout_ref }` |
+| `GET /memberships/:subject[?mode=]` | `vip.membership.status` | self | memberships, version bought under, entitlement, preferences |
+| `POST /memberships/:creator/cancel` | — | self only | cancel at period end through Billing |
+| `PUT /memberships/:creator/preferences` | — | self only | `{ show_badge, listed }` |
+| `GET\|POST /entitlements/check` `{subject, creator, mode}` | `vip.entitlement.check` | self | `{ status: active\|inactive\|unknown, active, expires_at, cancel_at_period_end, source, stale, valid_until, membership }` |
+| `GET /policies?service=&type=&id=` / `?creator=` | `vip.resource.policy.get` | owner | the rule for a resource / a creator's rules |
+| `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` *(proposed addition)* | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |
+| `POST /policies/evaluate` `{subject, resource, rule_id?, mode?}` | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement }` — **fails closed** |
+| `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |
-- OpenVibe.Billing
-- OpenVibe.Network
-- OpenVibe.Events
-- consumers: Live, Chat, Community, Blog, Wiki
+Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,
+`creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`, `plan_required`,
+`perk_missing`. Only `allow: true` allows.
-## Acceptance (must be true before "done")
+## Consumer seam: `openvibe-vip/client`
-- cancel/renew/refund converges across all consuming products within the defined window
-- plan edits are versioned and never rewrite historical terms
-- a stale cache cannot authorise indefinitely
-- VIP works with Live unavailable
+A module with no dependencies for Blog, Wiki, Chat, Live and Community
+([client/vip-client.js](client/vip-client.js)). Every failure — VIP unreachable, a timeout, a refused
+token, a malformed answer — is a denial, never an exception and never a "yes".
-## Bootstrap / extraction source
+```js
+const { createVipClient } = require('openvibe-vip/client');
+const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl: `${NETWORK}/oauth/token`, clientId, clientSecret,
+ audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });
+const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens, timeoutMs: 2000 });
-Live's channel-subscription behaviour and perks, reconciled into Billing first.
+const d = await vip.evaluate({ subject: viewer ? viewer.subject : null, resource: { service: 'blog', type: 'post', id: '42' } });
+if (!d.allow) return renderMembersOnlyTeaser(d.reason);
+
+await vip.isMember(viewerSubject, creatorSubject); // boolean; false on any doubt
+await vip.checkEntitlement({ subject, creator, mode: 'authoritative' }); // sensitive content
+```
+
+Until the SDK grows a `vip` module, consumers pin this repository's tag tarball and require only
+`openvibe-vip/client` (it loads nothing else); moving it into openvibe-sdk is the intended home.
+Live's badge mapping (`live chat_badge`, `live powerchat_overlay`, `live ai_context`) and Chat's
+(`chat badge`) are declared as product bindings of the network perk `subscriber-badge`.
+
+## Pages (server-rendered, useful without JavaScript, shared chrome)
+
+`/` directory of creators with published plans · `/:username` a creator's plans (terms, perks,
+Billing's price and period from `GET /api/v1/rates`, join form) · `/:username/plans/:slug` public terms
+history · `/me` your memberships, the version you joined under, cancel, badge preference ·
+`/dashboard` plans, versions, perks and bindings, members, members-only resources
+(`?as=network` for staff) · `/robots.txt`, `/sitemap.xml` · `/terms`, `/privacy`, `/dmca`
+(openvibe-shared legal). Navbar from `https://openvibe.network/shared/navbar.js`, SSR footer and
+`<noscript>` navigation from openvibe-shared. Forms carry HMAC anti-forgery tokens
+(`VIP_FORM_SECRET`); cookies are SameSite=Lax. No feed: plans are not a publication stream.
+
+## Import from Live
+
+`scripts/import-live.js` reads a **read-only snapshot** of Live's database and reflects Live's
+current offering — every channel can be subscribed to monthly at the site-wide price, and a
+subscriber gets a star "Subscriber" badge (Live chat, the PowerChat overlay relay, the stream AI's
+context flag):
+
+1. the network perk `subscriber-badge` with those product bindings plus Chat's badge;
+2. a creator for every Live streamer (role streamer / global_mod / admin) or subscribed-to user,
+ resolved to a Network subject (unmapped users are **held**, never dropped);
+3. per creator, plan `channel-subscription` v1 "Channel subscription", published and sold as Billing's
+ `channel_subscription`, with the badge perk — or a link to the creator's own published plan;
+4. **memberships from Billing** (its subscriptions per creator via the API, or `--billing-db`
+ snapshot): each active one is filed under that v1 with a projection row; inactive ones are
+ recorded as excluded with the reason;
+5. every row of Live's `subscriptions` is reconciled: linked to the VIP membership or Billing
+ subscription for the pair, or held with the reason.
+
+Idempotent (keys in `vip_migration_maps`), never overwrites a creator's later edits, releases holds
+on a later run; `--dry-run` rolls everything back; exit 1 when Billing could not be read. Live's
+`sub_price_usd`/share/fee are reported for reference only — Billing prices.
+
+## Security notes
+
+- Every API route checks one capability (services) or ownership (users); a presented token is judged
+ on that token alone. The API never reads cookies.
+- Billing events: HMAC-SHA256 signature over the raw body (`VIP_EVENTS_SECRET`, rotation by list),
+ `source` must be `billing`, exactly-once inbox, ordering guard.
+- Fail closed everywhere a membership is decided; "unknown" is never "yes".
+- Outbound calls go only to configured service URLs (Network, Billing, Events); no user-supplied
+ URLs are fetched. Checkout redirects only to http(s) URLs Billing returned.
+- Secrets appear as environment variable names only.
+
+## Acceptance (what the tests prove)
+
+| Criterion | Test |
+|---|---|
+| plan edits create versions and never rewrite historical terms | `test/plans.test.js` (edit → v2, v1 row byte-identical, triggers refuse UPDATE/DELETE, perk rename keeps terms) |
+| old members keep old terms; renewals keep them; a new purchase after a lapse takes the current version | `test/plans.test.js`, `test/api.test.js` (checkout version survives a later edit) |
+| cancel / renew / refund converge (events, and with events lost) | `test/entitlements.test.js` (injected clock) |
+| a stale projection cannot authorize past `valid_until` (+ grace), even with Billing down for 100 days | `test/entitlements.test.js` |
+| authoritative fallback and sensitive checks | `test/entitlements.test.js`, `test/policies.test.js` |
+| gated-resource evaluation fails closed (no rule, unknown, perk missing, guest, VIP down in the client) | `test/policies.test.js` |
+| import idempotent, holds released, dry run keeps nothing, Billing outage reported | `test/import.test.js` |
+| VIP works with Live unavailable | no code path calls Live; every test runs without a Live stub |
+
+Not yet demonstrated (needs the other services): convergence **across consuming products** (Live,
+Chat, Community do not call VIP yet), and a real Billing/Events round trip on the host.
## Launch rule
## Launch rule
This repository does not make the product real, and the domain keeps its placeholder page on
This repository does not make the product real, and the domain keeps its placeholder page on
[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
-following exist here (plan §12.12):
-
-1. an owning runtime with health/readiness endpoints and observability;
-2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
-3. server-rendered or static public routes that are useful without JavaScript;
-4. real persistence and end-to-end workflows;
-5. capability and event registration against `OpenVibe.Contracts`;
-6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
-7. acceptance tests proving the advertised functionality.
+following exist (plan §12.12):
+
+1. an owning runtime with health/readiness endpoints and observability — ✔ (`/api/health`, truthful `/api/ready`, `/metrics`);
+2. canonical identity/auth integration (Network subjects, scoped service principals) — ✔ in code; the `vip` principal and grants are not provisioned yet;
+3. server-rendered public routes useful without JavaScript — ✔;
+4. real persistence and end-to-end workflows — ✔ against stubs; not yet against the running Billing and Events;
+5. capability and event registration against `OpenVibe.Contracts` — proposed ([docs/](docs/)), not released;
+6. a migration/seed strategy ✔, a security/threat review (notes above; a review by someone else is still due), sitemap/robots ✔ (no feed by design);
+7. acceptance tests proving the advertised functionality — ✔ for VIP itself; cross-product convergence waits for the consumers.
The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
@@ -1,7 +1,24 @@
@@ -1,7 +1,24 @@
{
{
"repository": "OpenVibers/OpenVibe.VIP",
"repository": "OpenVibers/OpenVibe.VIP",
- "stage": "placeholder",
- "code": false,
+ "stage": "alpha",
+ "code": true,
+ "deployed": false,
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-21"
+ "roadmap": "Wave 10",
+ "works": [
+ "plans, immutable plan versions, perks, product bindings, member preferences and gated-resource rules in VIP's own SQLite",
+ "entitlement projection fed by Billing events (signed webhook + openvibe-sdk inbox) with valid_until, grace and a direct Billing fallback; 'unknown' never authorizes",
+ "checkout hand-off and cancellation through OpenVibe.Billing; the membership is filed under the plan version chosen",
+ "API /api/v1 for service and user tokens; consumer client openvibe-vip/client that fails closed",
+ "server-rendered creator plan pages, terms history, member status and creator dashboard with the shared chrome",
+ "outbox events vip.plan.published and vip.membership.changed (openvibe-sdk)",
+ "import of Live's subscription offering, memberships from Billing entitlements"
+ ],
+ "not_yet": [
+ "not deployed; openvibe.vip is still an OpenVibe.Sites placeholder",
+ "vip.* capabilities and the service manifest are proposals, not in an openvibe-contracts release",
+ "Live, Chat and Community do not call VIP yet (consumer integration is the next step)",
+ "Billing sells one channel subscription per member and creator: one purchasable plan per creator, no tiers, no network membership product"
+ ],
+ "updated": "2026-09-22"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+
+/**
+ * openvibe-vip/client — the consumer seam for products that honour VIP memberships (Blog, Wiki,
+ * Chat, Live, Community, …). No dependencies; Node 18+ (global fetch) or any fetch you pass.
+ *
+ * const { createVipClient } = require('openvibe-vip/client');
+ * const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl, clientId, clientSecret,
+ * audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });
+ * const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });
+ *
+ * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' } });
+ * if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED
+ *
+ * const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…' }); // e.active
+ * await vip.isMember('usr_…', 'usr_…'); // boolean, false on any doubt
+ *
+ * Every failure — VIP unreachable, a timeout, a refused token, a malformed answer — is a denial
+ * ({ allow: false, reason: 'vip_unavailable' } / { status: 'unknown', active: false }), never an
+ * exception and never a "yes". Pass mode: 'authoritative' for sensitive content: VIP then asks
+ * OpenVibe.Billing directly instead of its projection.
+ */
+
+function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null, getToken = null, fetch: fetchImpl = globalThis.fetch, timeoutMs = 2000, log = null } = {}) {
+ if (typeof fetchImpl !== 'function') throw new TypeError('createVipClient: a fetch implementation is required');
+ const base = String(baseUrl).replace(/\/+$/, '');
+
+ async function headers() {
+ if (tokenClient && typeof tokenClient.authHeaders === 'function') return tokenClient.authHeaders();
+ if (typeof getToken === 'function') return { Authorization: `Bearer ${await getToken()}` };
+ throw new Error('createVipClient: tokenClient or getToken is required');
+ }
+
+ async function post(path, body, retried = false) {
+ const res = await fetchImpl(`${base}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await headers()) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (res.status === 401 && !retried && tokenClient && typeof tokenClient.invalidate === 'function') { tokenClient.invalidate(); return post(path, body, true); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || typeof data !== 'object') throw new Error(`VIP ${res.status}: ${(data && (data.code || data.detail)) || 'bad response'}`);
+ return data;
+ }
+ const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };
+
+ /** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */
+ async function evaluate({ subject = null, resource, ruleId, mode } = {}) {
+ try {
+ const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, rule_id: ruleId, mode });
+ return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
+ } catch (err) {
+ note('evaluate', err);
+ return { allow: false, reason: 'vip_unavailable', error: err.message };
+ }
+ }
+
+ /** The entitlement of `subject` to `creator` (usr_ ids or SubjectRefs). */
+ async function checkEntitlement({ subject, creator, mode } = {}) {
+ try {
+ const e = await post('/api/v1/entitlements/check', { subject, creator, mode });
+ return { ...e, active: e.active === true && e.status === 'active' };
+ } catch (err) {
+ note('checkEntitlement', err);
+ return { status: 'unknown', active: false, reason: 'vip_unavailable', error: err.message };
+ }
+ }
+
+ const isMember = async (subject, creator, opts = {}) => (await checkEntitlement({ subject, creator, mode: opts.mode })).active;
+
+ return { evaluate, checkEntitlement, isMember };
+}
+
+module.exports = { createVipClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.vip — Nginx Configuration (REFERENCE, not deployed)
+##
+## Deploy to /etc/nginx/sites-available/openvibe.vip.conf and reload nginx — only at launch: until
+## the launch rule in README.md holds, openvibe.vip keeps its OpenVibe.Sites placeholder.
+##
+## The Node app (port 4620, systemd unit openvibe-vip) renders every page and serves /api/v1.
+## Other OpenVibe services call it on http://127.0.0.1:4620 directly. Not published here:
+## /internal/ OpenVibe.Events delivers Billing events to 127.0.0.1:4620/internal/events
+## /metrics host-local scrapers only (Node also refuses forwarded requests)
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=ovvip_api:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=ovvip_auth:10m rate=10r/m;
+# Form posts only (join, cancel, dashboard): an empty key is not limited.
+map $request_method $ovvip_form_key { POST $binary_remote_addr; default ""; }
+limit_req_zone $ovvip_form_key zone=ovvip_forms:10m rate=2r/s;
+
+# ── HTTP → HTTPS redirect ──────────────────────────────────
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.vip www.openvibe.vip;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.vip$request_uri; }
+}
+
+# ── www → apex ─────────────────────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.vip;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.vip/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.vip/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.vip$request_uri;
+}
+
+# ── HTTPS server ───────────────────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.vip;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.vip/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.vip/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.vip.access.log;
+ error_log /var/log/nginx/openvibe.vip.error.log;
+
+ client_max_body_size 256k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml text/xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── Never public ────────────────────────────────────────
+ location /internal/ { return 404; }
+ location = /metrics { return 404; }
+
+ # ── OAuth session endpoints (strict rate limit) ─────────
+ location /auth/ {
+ limit_req zone=ovvip_auth burst=10 nodelay;
+ proxy_pass http://127.0.0.1:4620;
+ }
+
+ # ── API (service and user tokens; moderate rate limit) ──
+ location /api/ {
+ limit_req zone=ovvip_api burst=30 nodelay;
+ proxy_pass http://127.0.0.1:4620;
+ }
+
+ # ── Static assets: Node marks hashed ?v= URLs immutable ──
+ location ~ ^/(?:css|js)/[^/]+\.(?:css|js)$ {
+ proxy_pass http://127.0.0.1:4620;
+ }
+
+ # ── Pages; form posts (join, cancel, dashboard) are rate limited ──
+ location / {
+ limit_req zone=ovvip_forms burst=20 nodelay;
+ proxy_pass http://127.0.0.1:4620;
+ proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+[Unit]
+Description=OpenVibe.VIP — memberships: plans, versioned terms, perks and gated-resource policy
+# Network signs the tokens VIP verifies (its key is fetched at boot and retried); Billing holds
+# the entitlement truth; Events relays the outbox when EVENTS_URL is set. VIP starts without any.
+After=network.target
+Wants=openvibe-network.service openvibe-billing.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.vip
+EnvironmentFile=/etc/openvibe/vip.env
+Environment=NODE_ENV=production
+Environment=PORT=4620
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.vip/server/index.js
+Restart=always
+RestartSec=5
+# The VIP database lives in /var/lib/openvibe-vip (ADR-007: one database per service);
+# the code tree stays read-only.
+StateDirectory=openvibe-vip
+StateDirectoryMode=0700
+Environment=VIP_DB_PATH=/var/lib/openvibe-vip/vip.db
+UMask=0077
+NoNewPrivileges=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectSystem=strict
+ProtectHome=true
+ProtectKernelTunables=true
+ProtectKernelModules=true
+ProtectKernelLogs=true
+ProtectControlGroups=true
+ProtectClock=true
+ProtectHostname=true
+RestrictSUIDSGID=true
+RestrictRealtime=true
+RestrictNamespaces=true
+LockPersonality=true
+SystemCallArchitectures=native
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+CapabilityBoundingSet=
+AmbientCapabilities=
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "vip.creator.members.list",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "A creator's active members with the plan version each joined under: from Billing's subscriptions, or VIP's fresh projection (labelled) when Billing does not answer.",
+ "permissions": [
+ "vip:members"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "vip-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/creators/:ref/members"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "vip.entitlement.check",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Is a subject a member of a creator right now? Answered from VIP's short-lived projection of Billing entitlements, with a direct Billing check when the projection is stale (mode auto) or always (mode authoritative). Past valid_until plus grace the answer is \"unknown\", which never authorizes.",
+ "permissions": [
+ "vip:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "vip-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/entitlements/check",
+ "POST /api/v1/entitlements/check"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "vip.membership.checkout",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Start joining a plan on behalf of a member: VIP records the plan version chosen and hands off to OpenVibe.Billing (payment intent, or a period paid from the member's credit). Billing charges; VIP never does.",
+ "permissions": [
+ "vip:checkout"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "vip-write",
+ "events": [
+ "vip.membership.changed"
+ ],
+ "implementedBy": [
+ "POST /api/v1/checkout"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "vip.membership.status",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "A member's memberships: the plan version each was bought under, preferences, and the entitlement status (projection, confirmed with Billing when stale).",
+ "permissions": [
+ "vip:read"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "vip-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/memberships/:subject"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "vip.perk.create",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Define a perk (badge, emote, gated content, room, role) for a creator or the network, with its product bindings.",
+ "permissions": [
+ "vip:perks"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "vip-write",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/perks"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "vip.perk.list",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "public",
+ "description": "List a creator's perks (and the network perks) with product bindings. Active perks are public; the grant adds retired ones.",
+ "permissions": [
+ "vip:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "vip-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/perks"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "vip.perk.update",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Rename, describe, retire a perk or replace its product bindings. Published plan versions keep the perk snapshot they were published with.",
+ "permissions": [
+ "vip:perks"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "vip-write",
+ "events": [],
+ "implementedBy": [
+ "PATCH /api/v1/perks/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "vip.plan.archive",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Archive a plan: no new members; existing members keep their terms until Billing ends their membership.",
+ "permissions": [
+ "vip:plans"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "vip-write",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/plans/:id/archive"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "vip.plan.create",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create a membership plan (version 1) for a creator, or for the network (staff). A published plan is sold through OpenVibe.Billing as the creator's channel subscription; Billing sets the price.",
+ "permissions": [
+ "vip:plans"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "vip-write",
+ "events": [
+ "vip.plan.published"
+ ],
+ "implementedBy": [
+ "POST /api/v1/plans"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "id": "vip.plan.list",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "public",
+ "description": "List a creator's plans with their current and draft versions and the full version history. Published plans are public without a grant; the grant adds drafts and archived plans.",
+ "permissions": [
+ "vip:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "vip-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/plans",
+ "GET /api/v1/plans/:id",
+ "GET /api/v1/plans/:id/versions",
+ "GET /api/v1/creators/:ref"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "id": "vip.plan.update",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Edit a plan by creating a new immutable version (published at once when the plan is published), or publish a draft. Earlier versions and the terms members joined under are never rewritten.",
+ "permissions": [
+ "vip:plans"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "vip-write",
+ "events": [
+ "vip.plan.published"
+ ],
+ "implementedBy": [
+ "PATCH /api/v1/plans/:id",
+ "POST /api/v1/plans/:id/publish"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "id": "vip.resource.policy.evaluate",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "May subject S see resource R (under rule X)? Fails closed: no rule, a disabled rule, an entitlement VIP cannot confirm, a missing perk or any error is allow: false with the reason.",
+ "permissions": [
+ "vip:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "vip-read",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/policies/evaluate"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "vip.resource.policy.get",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Read the gated-resource rule VIP holds for a resource (EntityRef), or a creator's rules.",
+ "permissions": [
+ "vip:read"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "vip-read",
+ "events": [],
+ "implementedBy": [
+ "GET /api/v1/policies",
+ "GET /api/v1/policies/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+{
+ "id": "vip.resource.policy.set",
+ "version": "1.0",
+ "owner": "vip",
+ "status": "active",
+ "visibility": "first-party",
+ "description": "Create, replace or remove the rule gating a resource (EntityRef) for a creator: any member, members of one plan, or members whose plan version includes a perk; optionally always checked with Billing (sensitive).",
+ "permissions": [
+ "vip:policies"
+ ],
+ "resourceConstraints": [
+ "owner"
+ ],
+ "quotaClass": "vip-write",
+ "events": [],
+ "implementedBy": [
+ "POST /api/v1/policies",
+ "DELETE /api/v1/policies/:id"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+{
+ "version": "0.2.0",
+ "contractRanges": {
+ "openvibe-contracts": ">=0.14.0 <1.0.0"
+ },
+ "id": "vip",
+ "name": "OpenVibe.VIP",
+ "status": "alpha",
+ "repository": "OpenVibers/OpenVibe.VIP",
+ "domains": [
+ "openvibe.vip"
+ ],
+ "publicOrigin": "https://openvibe.vip",
+ "health": "/api/health",
+ "ready": "/api/ready",
+ "capabilities": [
+ "vip.plan.create",
+ "vip.plan.update",
+ "vip.plan.archive",
+ "vip.plan.list",
+ "vip.perk.create",
+ "vip.perk.update",
+ "vip.perk.list",
+ "vip.membership.checkout",
+ "vip.membership.status",
+ "vip.entitlement.check",
+ "vip.resource.policy.get",
+ "vip.resource.policy.set",
+ "vip.resource.policy.evaluate",
+ "vip.creator.members.list"
+ ],
+ "eventsProduced": [
+ "vip.plan.published",
+ "vip.membership.changed"
+ ],
+ "eventsConsumed": [
+ "billing.entitlement.changed",
+ "billing.subscription.canceled",
+ "billing.transaction.reversed"
+ ],
+ "namespacesOwned": [
+ "vip.*"
+ ],
+ "notes": "Memberships (ADR-012): plans, immutable versioned plan terms, perks, product bindings, member preferences and gated-resource policy. OpenVibe.Billing holds subscriptions, charges, renewals, refunds and the canonical entitlements; VIP keeps a short-lived projection of them (valid_until, authoritative fallback, 'unknown' never authorizes). Alpha: not deployed; openvibe.vip stays on its OpenVibe.Sites placeholder until the launch rule holds."
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,1501 @@
@@ -0,0 +1,1501 @@
+{
+ "name": "openvibe-vip",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-vip",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "jsonwebtoken": "^9.0.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.14.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.8",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
+ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.96.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
+ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.14.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.14.0",
+ "integrity": "sha512-U4m1Vj/gJHbRWZhiH1QT2qke/YSPH2rXPOuScguGJ3Tgy9OyEsRvjB4ebYW4UVf9qNNtfgwS2mL8kZijXH0+GA==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.2.2",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "1.3.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
+ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+{
+ "name": "openvibe-vip",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.VIP — memberships on the OpenVibe network: plans, versioned plan terms, perks and gated-resource policy. Billing holds the entitlement truth; VIP holds plans and perks.",
+ "main": "server/index.js",
+ "exports": {
+ ".": "./server/index.js",
+ "./client": "./client/vip-client.js",
+ "./package.json": "./package.json"
+ },
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js",
+ "subscribe": "node scripts/subscribe.js",
+ "import-live": "node scripts/import-live.js"
+ },
+ "keywords": ["openvibe", "vip", "memberships", "entitlements"],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenVibe.VIP",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "jsonwebtoken": "^9.0.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.14.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+/* OpenVibe.VIP — page styles. Colours follow the shared theme variables (theme-loader.js). */
+:root {
+ --vip-bg: var(--bg-primary, #0a0f1c);
+ --vip-card: var(--bg-secondary, #121a2b);
+ --vip-text: var(--text-primary, #e6edf7);
+ --vip-muted: var(--text-secondary, #9aa7bd);
+ --vip-accent: var(--accent, #3b82f6);
+ --vip-on-accent: var(--on-accent, #ffffff);
+ --vip-border: var(--border, rgba(255, 255, 255, .1));
+ --vip-bad: #f87171;
+ --vip-good: #34d399;
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--vip-bg); color: var(--vip-text); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
+a { color: var(--vip-accent); }
+.site-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; padding: 12px 16px; border-bottom: 1px solid var(--vip-border); }
+.ov-has-navbar .site-head { display: none; }
+.site-head .brand { font-weight: 700; color: inherit; text-decoration: none; }
+.site-head nav { display: flex; gap: 14px; flex: 1; }
+.site-head nav a { color: var(--vip-muted); text-decoration: none; }
+.site-head nav a[aria-current] { color: var(--vip-text); }
+.page { max-width: 880px; margin: 0 auto; padding: 24px 16px 48px; overflow-wrap: anywhere; }
+h1 { font-size: 1.8rem; margin: .2em 0 .5em; }
+h2 { font-size: 1.3rem; margin: .4em 0; }
+h3 { font-size: 1.05rem; margin: .8em 0 .3em; }
+.eyebrow { text-transform: uppercase; letter-spacing: .08em; font-size: .78rem; color: var(--vip-muted); margin: 0; }
+.muted { color: var(--vip-muted); }
+.small { font-size: .88rem; }
+.hero p { font-size: 1.08rem; }
+.card { background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 12px; padding: 16px 18px; margin: 16px 0; }
+.card.narrow { max-width: 560px; margin: 32px auto; }
+.button, button { display: inline-block; background: var(--vip-accent); color: var(--vip-on-accent); border: 0; border-radius: 8px; padding: 9px 16px; font: inherit; font-weight: 600; cursor: pointer; text-decoration: none; }
+.button-quiet { background: transparent; color: var(--vip-accent); border: 1px solid var(--vip-accent); }
+form label { display: block; margin: 10px 0; }
+form input:not([type=checkbox]):not([type=hidden]), form textarea, form select {
+ display: block; width: 100%; margin-top: 4px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--vip-border);
+ background: var(--vip-bg); color: var(--vip-text); font: inherit; }
+form label.check { display: flex; align-items: center; gap: 8px; }
+form.inline { display: inline-block; margin: 4px 6px 4px 0; }
+form.join { display: grid; gap: 4px; max-width: 420px; }
+fieldset { border: 1px solid var(--vip-border); border-radius: 8px; margin: 12px 0; }
+details > summary { cursor: pointer; font-weight: 600; margin: 6px 0; }
+.notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }
+.notice-error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .5); }
+.pill { display: inline-block; border: 1px solid var(--vip-border); border-radius: 999px; padding: 0 10px; font-size: .85rem; color: var(--vip-muted); }
+.pill-on { border-color: var(--vip-good); color: var(--vip-good); }
+.price b { font-size: 1.15rem; }
+.creator-list { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
+.creator-list a { display: flex; flex-direction: column; background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: 10px; padding: 12px 14px; color: inherit; text-decoration: none; }
+.perks, .benefits, .versions, .rules, .perk-admin { padding-left: 1.2em; }
+.perk-admin { list-style: none; padding: 0; }
+table.members { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }
+table.members th, table.members td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--vip-border); }
+code { background: rgba(255, 255, 255, .06); padding: 1px 5px; border-radius: 4px; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+// OpenVibe.VIP — progressive touches only; every page and form works without this file.
+(function () {
+ 'use strict';
+ document.documentElement.classList.add('js');
+ document.addEventListener('DOMContentLoaded', function () {
+ // Dashboard rule form: show the plan/perk pickers only for the requirement that uses them.
+ var form = document.querySelector('form[action$="/rules"]');
+ if (!form) return;
+ var req = form.querySelector('select[name=requirement]');
+ var plan = form.querySelector('select[name=plan_id]');
+ var perk = form.querySelector('select[name=perk_key]');
+ function sync() {
+ if (plan) plan.closest('label').hidden = req.value !== 'plan';
+ if (perk) perk.closest('label').hidden = req.value !== 'perk';
+ }
+ if (req) { req.addEventListener('change', sync); sync(); }
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Import OpenVibe.Live's subscription offering into VIP (creators, the network subscriber-badge perk,
+ * one "Channel subscription" plan per creator) and file OpenVibe.Billing's active subscriptions as
+ * memberships under it. Live's own subscription rows are reconciled, never used as membership truth.
+ *
+ * node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]
+ *
+ * The Live database must be a COPY (`sqlite3 live.db ".backup live-snapshot.db"`); it is opened
+ * read-only. Run it AFTER Billing's Live import. Memberships come from Billing's API with VIP's
+ * client credentials (billing.entitlement.check), or from a read-only Billing snapshot with
+ * --billing-db. Live user ids are resolved through the Network (identity.subject.resolve).
+ * Exit code 1 when Billing could not be read for some creator (the report lists them). Safe to re-run.
+ */
+const path = require('path');
+const Database = require('better-sqlite3');
+const { loadConfig } = require('../server/config');
+const { openDb } = require('../server/db');
+const { createIdentity } = require('../server/network');
+const { createBillingClient } = require('../server/billing-client');
+const { createVipOutbox } = require('../server/events/outbox');
+const { createDomain } = require('../server/domain');
+const { importLive, billingApiSource, billingSnapshotSource } = require('../server/importer/live');
+
+const args = process.argv.slice(2);
+const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
+const flag = (name) => args.includes(`--${name}`);
+
+async function main() {
+ const livePath = opt('live-db');
+ if (!livePath) { console.error('usage: import-live.js --live-db <snapshot> [--billing-db <snapshot>] [--dry-run] [--json]'); process.exit(2); }
+ const config = loadConfig();
+ for (const p of [livePath, opt('billing-db')].filter(Boolean)) if (path.resolve(p) === path.resolve(config.dbPath)) throw new Error(`${p} is the VIP database`);
+ const live = new Database(livePath, { readonly: true, fileMustExist: true });
+ const bdb = opt('billing-db') ? new Database(opt('billing-db'), { readonly: true, fileMustExist: true }) : null;
+ const db = openDb(config.dbPath);
+ const billing = createBillingClient(config);
+ const outbox = createVipOutbox({ db, config });
+ const domain = createDomain({ db, config, outbox, billing });
+ const identity = createIdentity(config);
+ const report = await importLive(domain, {
+ live, billingSource: bdb ? billingSnapshotSource(bdb) : billingApiSource(billing), 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)' : ''} · billing source: ${report.billing_source}`);
+ console.log(` live snapshot: ${JSON.stringify(c.live)}; offering ${JSON.stringify(report.live_offering)}`);
+ console.log(` creators ${JSON.stringify(c.creators)}; plans ${JSON.stringify(c.plans)}`);
+ console.log(` memberships ${JSON.stringify(c.memberships)}; live subscriptions ${JSON.stringify(c.live_subscriptions)}`);
+ for (const h of report.holds) console.log(` held: ${JSON.stringify(h)}`);
+ for (const x of report.excluded) console.log(` excluded: ${JSON.stringify(x)}`);
+ for (const e of report.billing_errors) console.log(` billing error: ${JSON.stringify(e)}`);
+ }
+ live.close();
+ if (bdb) bdb.close();
+ db.close();
+ if (!report.ok) process.exit(1);
+}
+
+main().catch((err) => { console.error(`import failed: ${err.message}`); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Create VIP's OpenVibe.Events subscriptions for Billing's membership-relevant events:
+ *
+ * billing.entitlement.* (billing.entitlement.changed)
+ * billing.subscription.* (billing.subscription.canceled)
+ * billing.transaction.reversed (refunds and chargebacks that revoke periods)
+ *
+ * node scripts/subscribe.js [--endpoint http://127.0.0.1:4620/internal/events]
+ *
+ * Reads the environment (.env or /etc/openvibe/vip.env): EVENTS_URL, OV_NETWORK_INTERNAL_URL,
+ * OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the vip principal needs events.subscription.manage for
+ * audience openvibe.events) and VIP_EVENTS_SECRET — the delivery signing secret handed to Events here,
+ * so generate it first (`openssl rand -hex 32`). Nothing secret is printed. Existing identical
+ * subscriptions are reported, not duplicated.
+ */
+require('dotenv').config();
+const { serviceAuth } = require('openvibe-contracts');
+const { loadConfig } = require('../server/config');
+
+const PATTERNS = ['billing.entitlement.*', 'billing.subscription.*', 'billing.transaction.reversed'];
+const args = process.argv.slice(2);
+const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : d; };
+
+(async () => {
+ const config = loadConfig();
+ const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);
+ const secret = config.events.webhookSecrets[0];
+ if (!config.events.url) throw new Error('EVENTS_URL is not set');
+ if (!secret || secret.length < 32) throw new Error('VIP_EVENTS_SECRET must be set (32+ characters) before subscribing');
+ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.events', scope: 'events.subscription.manage',
+ });
+ for (const pattern of PATTERNS) {
+ const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),
+ });
+ const body = await res.json().catch(() => ({}));
+ if (res.status === 409 && body.subscription_id) { console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`); continue; }
+ if (!res.ok) throw new Error(`Events answered ${res.status} for ${pattern}: ${body.code || ''} ${body.detail || ''}`.trim());
+ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint})`);
+ }
+ console.log('Replay history per subscription with POST /api/v1/deliveries/replay { subscription_id, from_seq }.');
+})().catch((err) => { console.error(`subscribe failed: ${err.message}`); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * Who is calling /api/v1 — resolved into req.principal:
+ *
+ * { kind: 'service', sub: 'svc:live', cap: [...] }
+ * A Network client-credentials token for audience openvibe.vip. Each route checks ONE
+ * capability against the token's `cap` claim. The vip.* capabilities are proposals (not yet in
+ * an openvibe-contracts release), so the grant is matched with contracts' capabilities.grants()
+ * (exact id or a `.*` family) — the same interim rule Billing used before v0.8.0.
+ * { kind: 'user', subject: 'usr_…', username, name, avatar, role }
+ * A browser's Network user JWT, presented as `Authorization: Bearer`. Users act only on their
+ * own things (their plans, perks, rules and members as a creator; their own memberships and
+ * entitlement checks as a member). Staff roles (VIP_STAFF_ROLES) manage network plans. The API never
+ * reads cookies, so a cross-site form cannot drive it; the SSR pages carry their own
+ * anti-forgery tokens.
+ * { kind: 'anonymous' }
+ *
+ * A request that presents a token is judged on that token alone: a bad one is refused, never
+ * downgraded to anonymous.
+ */
+const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const ANON = Object.freeze({ kind: 'anonymous' });
+
+function decodePayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
+}
+
+/** A Network user token's claims → the user principal (null when it names no subject). */
+function userPrincipal(claims) {
+ if (!claims) return null;
+ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ if (!subject) return null;
+ return {
+ kind: 'user', subject, username: claims.username || null, name: claims.display_name || claims.username || null,
+ avatar: claims.avatar_url || null, role: claims.role || 'user',
+ };
+}
+
+function createApiAuth({ config, keys, userAuth }) {
+ function resolve(req) {
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return { principal: ANON };
+ const token = header.slice(7).trim();
+ const publicKey = keys.get();
+ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
+ const payload = decodePayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
+ if (!r.ok) return { error: [401, r.code, r.reason] };
+ return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti } };
+ }
+ const claims = userAuth.verify(token);
+ if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
+ const p = userPrincipal(claims);
+ if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
+ return { principal: p };
+ }
+
+ function middleware(req, res, next) {
+ const r = resolve(req);
+ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
+ req.principal = r.principal;
+ return next();
+ }
+
+ const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);
+ const isStaff = (p) => p.kind === 'user' && (config.staffRoles || []).includes(p.role);
+
+ return { middleware, resolve, granted, isStaff };
+}
+
+module.exports = { createApiAuth, userPrincipal, decodePayload, PRINCIPAL_SUB };
new file mode 100644
new file mode 100644
@@ -0,0 +1,344 @@
@@ -0,0 +1,344 @@
+'use strict';
+
+/**
+ * /api/v1 — VIP's API. Service tokens (audience openvibe.vip, one capability per route) and Network
+ * user tokens (people acting on their own things). Errors are RFC 9457 problem+json.
+ *
+ * People are SubjectRefs ({ type: 'user', id: 'usr_…' }); a creator can also be named by its VIP
+ * handle (username) or `network`. VIP never prices, charges, renews or refunds: checkout and
+ * cancellation are handed to OpenVibe.Billing, and entitlement truth is Billing's.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { VipError, fail, userSubject, isUserSubject, entityRef, bool } = require('../util');
+
+const CAP = {
+ planCreate: 'vip.plan.create',
+ planUpdate: 'vip.plan.update',
+ planArchive: 'vip.plan.archive',
+ planList: 'vip.plan.list',
+ perkCreate: 'vip.perk.create',
+ perkUpdate: 'vip.perk.update',
+ perkList: 'vip.perk.list',
+ checkout: 'vip.membership.checkout',
+ status: 'vip.membership.status',
+ entitlement: 'vip.entitlement.check',
+ policyGet: 'vip.resource.policy.get',
+ policySet: 'vip.resource.policy.set',
+ policyEvaluate: 'vip.resource.policy.evaluate',
+ members: 'vip.creator.members.list',
+};
+
+function v1Router({ domain, apiAuth }) {
+ const r = express.Router();
+ const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;
+ const granted = apiAuth.granted;
+ const isStaff = apiAuth.isStaff;
+
+ const denied = (cap) => fail(403, 'capability.denied', cap ? `${cap} not granted` : 'not allowed');
+ const needAuth = (p) => { if (p.kind === 'anonymous') fail(401, 'auth.required', 'a Network service or user token is required'); };
+
+ /** A creator named by SubjectRef, usr_ id, username or 'network'. */
+ function findCreator(ref) {
+ if (ref === 'network') return creators.network();
+ if (ref && typeof ref === 'object') return creators.bySubject(userSubject(ref, 'creator'));
+ const s = String(ref || '');
+ if (isUserSubject(s)) return creators.bySubject(s);
+ return creators.byUsername(s);
+ }
+
+ /**
+ * The creator a write acts for. Users: themselves (created on first use), or `network` for staff.
+ * Services (holding `cap`): the creator named in the body.
+ */
+ function actingCreator(req, ref, cap) {
+ const p = req.principal;
+ needAuth(p);
+ if (p.kind === 'service') {
+ if (!granted(p, cap)) denied(cap);
+ if (ref === 'network') return creators.network();
+ if (!ref) fail(422, 'vip.invalid_input', 'creator is required ({ type: "user", id } or "network")');
+ return creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'service' });
+ }
+ if (ref === 'network') {
+ if (!isStaff(p)) denied();
+ return creators.network();
+ }
+ if (ref && (typeof ref === 'object' ? ref.id : ref) !== p.subject) {
+ if (!isStaff(p)) fail(403, 'vip.not_yours', 'you can only manage your own plans');
+ return creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'staff' });
+ }
+ return creators.ensure({ subject: p.subject, username: p.username, displayName: p.name, origin: 'self' });
+ }
+
+ /** May the principal manage things owned by creator row `c`? */
+ function mayManage(p, c, cap) {
+ if (p.kind === 'service') return granted(p, cap);
+ if (p.kind !== 'user' || !c) return false;
+ if (c.kind === 'network') return isStaff(p);
+ return c.subject === p.subject || isStaff(p);
+ }
+ const requireManage = (req, c, cap) => { needAuth(req.principal); if (!mayManage(req.principal, c, cap)) denied(req.principal.kind === 'service' ? cap : null); };
+
+ /** The subject a member-side call is about: users → themselves; services (with cap) → body/query. */
+ function memberSubject(req, value, cap) {
+ const p = req.principal;
+ needAuth(p);
+ if (p.kind === 'user') {
+ if (value && (typeof value === 'object' ? value.id : value) !== p.subject) fail(403, 'vip.not_yours', 'you can only ask about yourself');
+ return p.subject;
+ }
+ if (!granted(p, cap)) denied(cap);
+ return userSubject(value, 'subject');
+ }
+
+ const presentPlanFull = (plan) => ({ ...plans.present(plan), creator: creators.present(creators.byId(plan.creator_id)) });
+ const trace = (req) => req.ov && req.ov.traceparent;
+
+ // ── Creators (public) ────────────────────────────────────
+ r.get('/creators/:ref', wrap((req, res) => {
+ const c = findCreator(req.params.ref);
+ if (!c) fail(404, 'vip.creator_not_found', 'no such creator');
+ const own = mayManage(req.principal, c, CAP.planList);
+ res.json({
+ creator: creators.present(c),
+ plans: plans.list({ creatorId: c.id, includeDrafts: own }).map((p) => plans.present(p)),
+ perks: perks.list({ creatorId: c.id, includeNetwork: false }).map((p) => perks.present(p)),
+ });
+ }));
+
+ // ── Plans ────────────────────────────────────────────────
+ r.get('/plans', wrap((req, res) => {
+ const c = findCreator(req.query.creator);
+ if (!c) fail(404, 'vip.creator_not_found', 'creator must name a creator (usr_ id, username or network)');
+ const include = String(req.query.include || '').split(',');
+ const own = mayManage(req.principal, c, CAP.planList);
+ const list = plans.list({ creatorId: c.id, includeDrafts: own && include.includes('drafts'), includeArchived: own && include.includes('archived') });
+ res.json({ creator: creators.present(c), plans: list.map((p) => plans.present(p)) });
+ }));
+ const visiblePlan = (req, id) => {
+ const plan = plans.byId(id);
+ if (!plan) fail(404, 'vip.plan_not_found', `no plan ${id}`);
+ if (plan.status !== 'published' && !mayManage(req.principal, creators.byId(plan.creator_id), CAP.planList)) fail(404, 'vip.plan_not_found', `no plan ${id}`);
+ return plan;
+ };
+ r.get('/plans/:id', wrap((req, res) => res.json({ plan: presentPlanFull(visiblePlan(req, req.params.id)) })));
+ r.get('/plans/:id/versions', wrap((req, res) => {
+ const plan = visiblePlan(req, req.params.id);
+ const own = mayManage(req.principal, creators.byId(plan.creator_id), CAP.planList);
+ const all = plans.versions(plan.id).filter((v) => own || v.published_at).map(plans.presentVersion);
+ res.json({ plan_id: plan.id, versions: all });
+ }));
+ r.post('/plans', wrap((req, res) => {
+ const b = req.body || {};
+ const c = actingCreator(req, b.creator, CAP.planCreate);
+ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ const plan = plans.create({
+ creatorId: c.id, slug: b.slug, name: b.name, description: b.description, benefits: b.benefits, perks: b.perks,
+ billingKind: b.billing_kind, publish: bool(b.publish), changeNote: b.change_note, actor, traceparent: trace(req),
+ });
+ res.status(201).json({ plan: presentPlanFull(plan) });
+ }));
+ const managedPlan = (req, cap) => {
+ const plan = plans.byId(req.params.id);
+ if (!plan) fail(404, 'vip.plan_not_found', `no plan ${req.params.id}`);
+ requireManage(req, creators.byId(plan.creator_id), cap);
+ return plan;
+ };
+ r.patch('/plans/:id', wrap((req, res) => {
+ const plan = managedPlan(req, CAP.planUpdate);
+ const b = req.body || {};
+ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ const out = plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });
+ res.json({ plan: presentPlanFull(out.plan), version: plans.presentVersion(out.version), unchanged: out.unchanged });
+ }));
+ r.post('/plans/:id/publish', wrap((req, res) => {
+ const plan = managedPlan(req, CAP.planUpdate);
+ const out = plans.publish(plan.id, { traceparent: trace(req) });
+ res.json({ plan: presentPlanFull(out.plan), replay: out.replay });
+ }));
+ r.post('/plans/:id/archive', wrap((req, res) => {
+ const plan = managedPlan(req, CAP.planArchive);
+ res.json({ plan: presentPlanFull(plans.archive(plan.id)) });
+ }));
+
+ // ── Perks ────────────────────────────────────────────────
+ r.get('/perks', wrap((req, res) => {
+ const c = findCreator(req.query.creator);
+ if (!c) fail(404, 'vip.creator_not_found', 'creator must name a creator (usr_ id, username or network)');
+ const own = mayManage(req.principal, c, CAP.perkList);
+ const list = perks.list({ creatorId: c.id, includeNetwork: bool(req.query.network, true), includeRetired: own && bool(req.query.retired) });
+ res.json({ perks: list.map((p) => perks.present(p)) });
+ }));
+ r.post('/perks', wrap((req, res) => {
+ const b = req.body || {};
+ const c = actingCreator(req, b.creator, CAP.perkCreate);
+ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ const p = perks.create({ creatorId: c.id, key: b.key, name: b.name, description: b.description, kind: b.kind, bindings: b.bindings, actor });
+ res.status(201).json({ perk: perks.present(p) });
+ }));
+ r.patch('/perks/:id', wrap((req, res) => {
+ const p = perks.byId(req.params.id);
+ if (!p) fail(404, 'vip.perk_not_found', `no perk ${req.params.id}`);
+ requireManage(req, creators.byId(p.creator_id), CAP.perkUpdate);
+ const b = req.body || {};
+ res.json({ perk: perks.present(perks.update(p.id, { name: b.name, description: b.description, kind: b.kind, status: b.status, bindings: b.bindings })) });
+ }));
+
+ // ── Checkout hand-off (Billing) ──────────────────────────
+ r.post('/checkout', wrap(async (req, res) => {
+ const b = req.body || {};
+ const member = memberSubject(req, b.subject, CAP.checkout);
+ const out = await checkout.start({
+ member, planId: b.plan_id, provider: b.provider, successUrl: b.success_url, cancelUrl: b.cancel_url,
+ autoRenew: b.auto_renew === undefined ? true : bool(b.auto_renew), traceparent: trace(req),
+ });
+ res.status(201).json(out);
+ }));
+
+ // ── Membership status ────────────────────────────────────
+ r.get('/memberships/:subject', wrap(async (req, res) => {
+ const member = memberSubject(req, req.params.subject, CAP.status);
+ const mode = ['projection', 'authoritative'].includes(req.query.mode) ? req.query.mode : 'auto';
+ const list = [];
+ for (const m of memberships.forMember(member)) {
+ const c = creators.byId(m.creator_id);
+ const ent = c && c.subject ? await entitlements.check(member, c.subject, { mode, traceparent: trace(req) }) : null;
+ list.push({ ...memberships.present(m), creator: creators.present(c), entitlement: ent, preferences: memberships.preferences(member, m.creator_id) });
+ }
+ res.json({ member: { type: 'user', id: member }, memberships: list });
+ }));
+ const creatorParam = (req) => {
+ const c = findCreator(req.params.creator);
+ if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');
+ return c;
+ };
+ r.post('/memberships/:creator/cancel', wrap(async (req, res) => {
+ if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');
+ const c = creatorParam(req);
+ res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req) }));
+ }));
+ r.put('/memberships/:creator/preferences', wrap((req, res) => {
+ if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');
+ const c = creatorParam(req);
+ const b = req.body || {};
+ res.json({ preferences: memberships.setPreferences(req.principal.subject, c.id, { showBadge: b.show_badge === undefined ? undefined : bool(b.show_badge), listed: b.listed === undefined ? undefined : bool(b.listed) }) });
+ }));
+
+ // ── Entitlement check (projection + authoritative fallback) ──
+ const entitlementCheck = wrap(async (req, res) => {
+ const src = req.method === 'GET' ? req.query : (req.body || {});
+ const member = memberSubject(req, src.subject, CAP.entitlement);
+ // A creator VIP has never seen can still have a Billing entitlement: a usr_ id is asked as is.
+ const raw = src.creator && typeof src.creator === 'object' ? src.creator.id : src.creator;
+ let creatorSubject = isUserSubject(raw) ? raw : null;
+ if (!creatorSubject) {
+ const c = findCreator(raw);
+ if (!c || !c.subject) fail(422, 'vip.invalid_input', 'creator must be a user SubjectRef, usr_ id or VIP username');
+ creatorSubject = c.subject;
+ }
+ const mode = ['projection', 'authoritative'].includes(src.mode) ? src.mode : 'auto';
+ res.json(await entitlements.check(member, creatorSubject, { mode, traceparent: trace(req) }));
+ });
+ r.get('/entitlements/check', entitlementCheck);
+ r.post('/entitlements/check', entitlementCheck);
+
+ // ── Gated-resource policy ────────────────────────────────
+ r.get('/policies', wrap((req, res) => {
+ const p = req.principal;
+ needAuth(p);
+ if (req.query.service || req.query.type || req.query.id) {
+ const resource = entityRef({ service: req.query.service, type: req.query.type, id: req.query.id });
+ const rule = policies.forResource(resource);
+ if (p.kind === 'service' && !granted(p, CAP.policyGet)) denied(CAP.policyGet);
+ if (!rule || (p.kind === 'user' && !mayManage(p, creators.byId(rule.creator_id), CAP.policyGet))) fail(404, 'vip.rule_not_found', 'no rule for that resource');
+ return res.json({ rule: policies.present(rule) });
+ }
+ const c = findCreator(req.query.creator || (p.kind === 'user' ? p.subject : null));
+ if (!c) fail(404, 'vip.creator_not_found', 'no such creator');
+ requireManage(req, c, CAP.policyGet);
+ return res.json({ rules: policies.list(c.id).map(policies.present) });
+ }));
+ r.get('/policies/:id', wrap((req, res) => {
+ const rule = policies.byId(req.params.id);
+ if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);
+ requireManage(req, creators.byId(rule.creator_id), CAP.policyGet);
+ res.json({ rule: policies.present(rule) });
+ }));
+ r.post('/policies', wrap((req, res) => {
+ const b = req.body || {};
+ const c = actingCreator(req, b.creator, CAP.policySet);
+ if (c.kind !== 'creator') fail(422, 'vip.invalid_input', 'gated resources belong to a creator');
+ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ const rule = policies.set({ creatorId: c.id, resource: b.resource, requirement: b.requirement, planId: b.plan_id, perkKey: b.perk_key, sensitive: bool(b.sensitive), actor });
+ res.status(201).json({ rule: policies.present(rule) });
+ }));
+ r.delete('/policies/:id', wrap((req, res) => {
+ const rule = policies.byId(req.params.id);
+ if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);
+ requireManage(req, creators.byId(rule.creator_id), CAP.policySet);
+ res.json({ rule: policies.present(policies.disable(rule.id)) });
+ }));
+ r.post('/policies/evaluate', wrap(async (req, res) => {
+ const b = req.body || {};
+ const p = req.principal;
+ needAuth(p);
+ let subject = null;
+ if (p.kind === 'user') {
+ if (b.subject && (typeof b.subject === 'object' ? b.subject.id : b.subject) !== p.subject) fail(403, 'vip.not_yours', 'you can only ask about yourself');
+ subject = p.subject;
+ } else {
+ if (!granted(p, CAP.policyEvaluate)) denied(CAP.policyEvaluate);
+ // No subject (a signed-out viewer) or a guest is a valid question; the answer is no.
+ const s = b.subject && typeof b.subject === 'object' ? b.subject.id : b.subject;
+ subject = s ? String(s) : null;
+ }
+ const out = await policies.evaluate({ subject, resource: b.resource, ruleId: b.rule_id, mode: b.mode === 'authoritative' ? 'authoritative' : 'auto', traceparent: trace(req) });
+ res.json(out);
+ }));
+
+ // ── Creator members ──────────────────────────────────────
+ r.get('/creators/:ref/members', wrap(async (req, res) => {
+ const c = findCreator(req.params.ref);
+ if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');
+ requireManage(req, c, CAP.members);
+ const byMember = new Map(memberships.forCreator(c.id).map((m) => [m.member_subject, m]));
+ const row = (subject, extra) => ({ member: { type: 'user', id: subject }, ...extra, membership: memberships.present(byMember.get(subject)) });
+ try {
+ // Authoritative: Billing's active subscriptions to this creator.
+ const out = await billing.listSubscriptions({ streamer: c.subject, status: 'active' });
+ const members = (out.subscriptions || []).map((s) => row(s.subscriber.id, {
+ current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, subscription_id: s.id,
+ }));
+ return res.json({ creator: creators.present(c), source: 'billing', members });
+ } catch {
+ const members = entitlements.projectedMembers(c.subject).map((pr) => row(pr.member_subject, {
+ current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, subscription_id: pr.subscription_id,
+ }));
+ return res.json({ creator: creators.present(c), source: 'projection', note: 'Billing did not answer; this list is VIP\'s projection (fresh rows only) and may be incomplete', members });
+ }
+ }));
+
+ return r;
+}
+
+/** Async-safe handler: VipError → problem+json; anything else → 500. */
+function wrap(fn) {
+ return (req, res, next) => {
+ try {
+ const p = fn(req, res, next);
+ if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
+ } catch (e) { sendError(req, res, e); }
+ };
+}
+
+function sendError(req, res, e) {
+ if (res.headersSent) return;
+ if (e instanceof VipError) {
+ return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });
+ }
+ console.error('[VIP] unexpected error:', e);
+ return http.sendProblem(res, 500, 'vip.internal', { detail: 'internal error', ctx: req.ov });
+}
+
+module.exports = { v1Router, wrap, sendError, CAP };
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+
+/**
+ * OpenVibe.VIP — memberships: plans, versioned plan terms, perks, product bindings and gated-resource
+ * policy. OpenVibe.Billing holds subscriptions, charges, renewals, refunds and the canonical
+ * entitlements (ADR-012); VIP projects them and never authorizes past the projection's validity.
+ * Express app factory; server/index.js listens, tests build their own instance.
+ *
+ * GET /api/health, /api/ready, /release.json, /metrics (loopback only)
+ * /api/v1/* API (service tokens + user tokens, see api/v1.js)
+ * POST /internal/events Billing events from OpenVibe.Events (signed webhook + inbox)
+ * /auth/* Network SSO session for the pages
+ * /, /me, /dashboard, /:username … server-rendered pages
+ *
+ * createApp({ config, db, keys, billing, outbox, now, fetchImpl, eventsFetch, log }) — all injectable.
+ */
+const path = require('path');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const { http } = require('openvibe-contracts');
+const { loadConfig } = require('./config');
+const { openDb } = require('./db');
+const { createKeyProvider, createUserAuth } = require('./network');
+const { createBillingClient } = require('./billing-client');
+const { createVipOutbox } = require('./events/outbox');
+const { consumerRouter } = require('./events/consumer');
+const { createDomain } = require('./domain');
+const { createApiAuth } = require('./api/auth');
+const { v1Router } = require('./api/v1');
+const { createSessionRoutes } = require('./web/session');
+const { createWebRoutes } = require('./web/routes');
+const { createLayout, assetVersion } = require('./web/layout');
+const { createVipReadiness } = require('./observability');
+const pages = require('./web/pages');
+
+const VERSION = require('../package.json').version;
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+
+function createApp(opts = {}) {
+ const config = opts.config || loadConfig();
+ const db = opts.db || openDb(config.dbPath);
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const log = opts.log || console;
+ const now = opts.now || (() => Date.now());
+ const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
+ const userAuth = createUserAuth(config, keys);
+ const billing = opts.billing || createBillingClient(config, { fetchImpl });
+ const outbox = opts.outbox || createVipOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
+ const domain = createDomain({ db, config, outbox, billing, now, log });
+ const apiAuth = createApiAuth({ config, keys, userAuth });
+ const release = require('openvibe-shared/release').createRelease({ service: 'vip', root: path.join(__dirname, '..') });
+ const layout = createLayout({ config, release });
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ // HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
+ // direct loopback callers only (Track O). Gauges: outbox backlog and projection staleness.
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'vip', release: release.release });
+ metrics.registry.gauge({ name: 'vip_outbox_pending', help: 'Events waiting in the outbox', collect: () => outbox.outbox.pending() });
+ metrics.registry.gauge({
+ name: 'vip_projection_rows', help: 'Entitlement projection rows by freshness', labelNames: ['state'],
+ collect: () => {
+ const t = now();
+ const r = db.prepare('SELECT SUM(CASE WHEN valid_until >= ? THEN 1 ELSE 0 END) AS fresh, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t, t);
+ return [{ labels: { state: 'fresh' }, value: r.fresh || 0 }, { labels: { state: 'stale' }, value: r.stale || 0 }];
+ },
+ });
+ app.use(http.middleware());
+ app.use((req, res, next) => {
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
+ res.setHeader('Content-Security-Policy', [
+ "default-src 'self'", "script-src 'self' 'unsafe-inline' https://openvibe.network", "style-src 'self' 'unsafe-inline' https://openvibe.network",
+ "img-src 'self' data: https:", "connect-src 'self' https://openvibe.network", "frame-src 'self' https://openvibe.network",
+ "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self' https://openvibe.network https:",
+ ].join('; '));
+ next();
+ });
+ app.use(cookieParser());
+
+ app.get('/api/health', (req, res) => res.json({ ok: true, service: 'vip', version: VERSION, release: release.release, events: outbox.status() }));
+ const readiness = createVipReadiness({ db, keys, config, outbox, now, release: release.release, fetchImpl });
+ app.get('/api/ready', readiness.handler);
+ app.get('/release.json', release.handler);
+
+ const consumer = consumerRouter({ domain, config, log });
+ app.use('/internal', consumer.router);
+ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth }));
+ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }
+
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'no-cache');
+ },
+ }));
+ app.use(createWebRoutes({ domain, config, layout, userAuth }));
+
+ app.use((req, res) => {
+ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
+ return res.status(404).type('html').send(layout.page({ title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) }));
+ });
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, next) => {
+ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });
+ log.error('[VIP] unhandled error:', err);
+ if (res.headersSent) return undefined;
+ if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'vip.internal', { ctx: req.ov });
+ return res.status(500).type('html').send(layout.page({ title: 'Error', robots: 'noindex', body: pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }) }));
+ });
+
+ Object.assign(app.locals, { config, db, domain, keys, outbox, billing, consumer, metrics });
+ return app;
+}
+
+module.exports = { createApp, VERSION };
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+
+/**
+ * OpenVibe.Billing client — Billing holds subscriptions, charges, renewals, cancellations, refunds
+ * and the canonical entitlements (ADR-012). VIP holds a client-credentials token for audience
+ * openvibe.billing, one token per capability so a missing grant only disables what needs it:
+ *
+ * billing.intent.create POST /api/v1/intents checkout hand-off (kind subscription)
+ * billing.subscription.manage POST /api/v1/subscriptions pay a period from the member's credit
+ * POST /api/v1/subscriptions/:id/cancel cancel at period end
+ * billing.entitlement.check GET /api/v1/entitlements/:subject?streamer= the authoritative check
+ * GET /api/v1/subscriptions[?streamer&subscriber&status], /:id
+ *
+ * Every POST carries an Idempotency-Key derived from VIP's own record, so a retry after a lost
+ * response returns Billing's original result instead of acting twice.
+ *
+ * Errors: a Billing problem+json becomes a BillingCallError with .status/.code; a network failure or
+ * timeout has no status (retryable, and for an entitlement check it means "unknown", never "yes").
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+class BillingCallError extends Error {
+ constructor(message, { status = null, code = null, body = null } = {}) {
+ super(message);
+ this.name = 'BillingCallError';
+ this.status = status;
+ this.code = code;
+ this.body = body;
+ }
+ get retryable() { return this.status == null || this.status >= 500 || this.status === 429 || this.status === 401 || this.code === 'billing.frozen'; }
+}
+
+const CAPS = {
+ intent: 'billing.intent.create',
+ subscription: 'billing.subscription.manage',
+ entitlement: 'billing.entitlement.check',
+};
+
+function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClients = null } = {}) {
+ const base = config.billing.url;
+ const clients = new Map();
+ const tokensFor = (cap) => {
+ if (tokenClients && tokenClients[cap]) return tokenClients[cap];
+ if (!clients.has(cap)) {
+ clients.set(cap, serviceAuth.createTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: config.billing.audience,
+ scope: cap,
+ fetchImpl,
+ }));
+ }
+ return clients.get(cap);
+ };
+
+ async function call(cap, method, path, { body, key, traceparent, retried = false } = {}) {
+ let auth;
+ try { auth = await tokensFor(cap).authHeaders(); } catch (e) { throw new BillingCallError(`token (${cap}): ${e.message}`); }
+ const headers = { Accept: 'application/json', ...auth };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ if (key) headers['Idempotency-Key'] = key;
+ if (traceparent) headers.traceparent = traceparent;
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, {
+ method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(config.billing.timeoutMs),
+ });
+ } catch (e) {
+ throw new BillingCallError(`Billing unreachable: ${e.message}`);
+ }
+ if (res.status === 401 && !retried) { tokensFor(cap).invalidate(); return call(cap, method, path, { body, key, traceparent, retried: true }); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok) {
+ const code = (data && (data.code || data.error)) || `http_${res.status}`;
+ throw new BillingCallError(`Billing ${res.status} ${code}: ${(data && data.detail) || ''}`.trim(), { status: res.status, code, body: data });
+ }
+ return data;
+ }
+ const user = (id) => ({ type: 'user', id });
+ const qs = (o) => { const p = new URLSearchParams(); for (const [k, v] of Object.entries(o)) if (v != null && v !== '') p.set(k, v); const s = p.toString(); return s ? `?${s}` : ''; };
+
+ return {
+ /** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */
+ createIntent: ({ provider, subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => call(CAPS.intent, 'POST', '/api/v1/intents', {
+ body: { provider, kind: 'subscription', subject: user(subject), streamer: user(creator), auto_renew: !!autoRenew, success_url: successUrl, cancel_url: cancelUrl },
+ key, traceparent,
+ }),
+ /** { subscription, entitlement, transaction } — one period paid from the member's credit. */
+ subscribeWithCredit: ({ subscriber, creator, autoRenew, key, traceparent }) => call(CAPS.subscription, 'POST', '/api/v1/subscriptions', {
+ body: { subscriber: user(subscriber), streamer: user(creator), source: 'credit', auto_renew: autoRenew !== false },
+ key, traceparent,
+ }),
+ cancelSubscription: ({ id, key, traceparent }) => call(CAPS.subscription, 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent }),
+ getSubscription: (id) => call(CAPS.entitlement, 'GET', `/api/v1/subscriptions/${encodeURIComponent(id)}`),
+ listSubscriptions: ({ streamer, subscriber, status } = {}) => call(CAPS.entitlement, 'GET', `/api/v1/subscriptions${qs({ streamer, subscriber, status })}`),
+ /** { active, expires_at, subscription } — the authoritative answer. */
+ entitlement: (subject, creator) => call(CAPS.entitlement, 'GET', `/api/v1/entitlements/${encodeURIComponent(subject)}${qs({ streamer: creator })}`),
+ rates: () => call(CAPS.entitlement, 'GET', '/api/v1/rates'),
+ baseUrl: () => base,
+ };
+}
+
+module.exports = { createBillingClient, BillingCallError, CAPS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+
+/**
+ * OpenVibe.VIP configuration. Everything comes from the environment (.env in development,
+ * /etc/openvibe/vip.env in production). loadConfig(env) is pure so tests build their own.
+ *
+ * VIP never prices anything and never moves money (ADR-012): prices, periods, charges, renewals,
+ * cancellations and refunds are OpenVibe.Billing's. VIP holds plans, versioned plan terms, perks
+ * and gated-resource policy.
+ */
+require('dotenv').config();
+
+const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
+const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));
+const trim = (u) => String(u || '').replace(/\/+$/, '');
+const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
+
+function loadConfig(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 4620);
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.vip' : `http://localhost:${port}`));
+ return {
+ nodeEnv,
+ isProduction,
+ port,
+ host: env.HOST || '127.0.0.1',
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
+ dbPath: env.VIP_DB_PATH || './data/vip.db',
+
+ // Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.
+ network: {
+ url: networkUrl,
+ internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
+ },
+ audience: env.VIP_AUDIENCE || 'openvibe.vip',
+ // VIP's own client (client `vip` in the Network): service tokens for Billing, Events and the
+ // Network (identity resolve, importer only), and the OAuth code flow for browser sign-in.
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'vip',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: env.OV_OAUTH_SCOPE || 'profile',
+ },
+ cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },
+ // Signs the anti-forgery tokens of the server-rendered forms (required in production).
+ formSecret: env.VIP_FORM_SECRET || (isProduction ? '' : 'dev-form-secret'),
+ // Network roles that may manage network-wide plans and see any creator's members.
+ staffRoles: list(env.VIP_STAFF_ROLES || 'admin'),
+
+ billing: {
+ url: trim(env.BILLING_URL || 'http://127.0.0.1:4600'),
+ audience: env.BILLING_AUDIENCE || 'openvibe.billing',
+ timeoutMs: int(env.VIP_BILLING_TIMEOUT_MS, 5000),
+ // Checkout providers offered on the plan pages (each must be enabled on Billing too).
+ // `credit` pays from the member's Vibes credit through Billing's subscription API.
+ providers: list(env.VIP_CHECKOUT_PROVIDERS || 'powerchat,credit'),
+ // Billing answers a PowerChat intent with a checkout_ref (pcsub:…) instead of a URL. When
+ // set, this template turns it into a link: {ref} and {cents} are filled in.
+ powerchatLinkTemplate: env.VIP_POWERCHAT_LINK_TEMPLATE || '',
+ ratesTtlMs: int(env.VIP_BILLING_RATES_TTL_MS, 5 * 60 * 1000),
+ },
+
+ // The entitlement projection (a cache of Billing, never the truth). An answer is trusted for
+ // at most maxAgeMs after VIP last heard from Billing about it, and never past the paid
+ // period's end; up to graceMs later a projection-only check still answers (flagged stale);
+ // after that VIP answers "unknown" until Billing confirms again.
+ projection: {
+ maxAgeMs: int(env.VIP_PROJECTION_MAX_AGE_MS, 15 * 60 * 1000),
+ graceMs: int(env.VIP_PROJECTION_GRACE_MS, 60 * 1000),
+ },
+
+ events: {
+ url: trim(env.EVENTS_URL || ''),
+ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
+ // Signing secret(s) of VIP's OpenVibe.Events subscriptions (billing.entitlement.*,
+ // billing.subscription.*, billing.transaction.reversed). Comma-separated for rotation.
+ webhookSecrets: list(env.VIP_EVENTS_SECRET),
+ },
+
+ jobs: {
+ enabled: env.VIP_JOBS !== 'off',
+ // Re-confirm, with Billing, projections that were used recently and are about to go stale.
+ refreshIntervalMs: int(env.VIP_REFRESH_INTERVAL_MS, 60 * 1000),
+ },
+ liveUrl: trim(env.LIVE_URL || 'https://openvibe.live'),
+ };
+}
+
+module.exports = { loadConfig };
new file mode 100644
new file mode 100644
@@ -0,0 +1,250 @@
@@ -0,0 +1,250 @@
+'use strict';
+
+/**
+ * VIP's own SQLite database (ADR-007: one database per service), WAL, created on boot, idempotent.
+ *
+ * Authority tables (the nine the charter names; VIP is their only writer):
+ * vip_creators a creator (or the network) that offers memberships
+ * vip_plans a plan: status, which Billing product it is sold as, current version
+ * vip_plan_versions IMMUTABLE plan terms, one row per edit (triggers refuse UPDATE/DELETE
+ * of anything but the one-time publish stamp)
+ * vip_perks a perk definition (creator or network scope)
+ * vip_plan_perks IMMUTABLE: which perks a plan VERSION includes (snapshot of key/name)
+ * vip_product_bindings how a perk shows up in a product (live chat badge, chat room, …)
+ * vip_member_preferences a member's preferences per creator (badge display, listing)
+ * vip_gated_resource_rules "resource R in product P needs membership / perk K of creator C"
+ * vip_migration_maps legacy source row → VIP target, with status and reason
+ *
+ * Operational tables (not authority for anything outside VIP):
+ * vip_memberships which plan VERSION a membership was bought under (VIP's record of
+ * terms; active/expired is never stored here — that is Billing's)
+ * vip_entitlement_projection a cache of Billing's entitlements, fed by Billing events and direct
+ * checks, with valid_until; never authorizes past it
+ * vip_checkouts checkout hand-offs to Billing (which version the member chose)
+ * event_outbox, idempotency_receipts openvibe-sdk outbox and inbox
+ */
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS settings (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS vip_creators (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN ('creator', 'network')),
+ subject TEXT UNIQUE,
+ username TEXT,
+ username_lc TEXT UNIQUE,
+ display_name TEXT,
+ bio TEXT,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')),
+ origin TEXT NOT NULL DEFAULT 'self' CHECK (origin IN ('self', 'service', 'import', 'staff', 'system')),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ CHECK ((kind = 'network' AND subject IS NULL) OR (kind = 'creator' AND subject IS NOT NULL))
+);
+
+CREATE TABLE IF NOT EXISTS vip_plans (
+ id TEXT PRIMARY KEY,
+ creator_id TEXT NOT NULL REFERENCES vip_creators(id),
+ slug TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
+ billing_kind TEXT CHECK (billing_kind IS NULL OR billing_kind IN ('channel_subscription')),
+ current_version_id TEXT,
+ latest_version INTEGER NOT NULL DEFAULT 0,
+ sort INTEGER NOT NULL DEFAULT 0,
+ created_by TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ archived_at TEXT,
+ UNIQUE (creator_id, slug)
+);
+-- Billing sells one channel subscription per (subscriber, creator) today, so a creator can have at
+-- most one published plan bound to it.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_plans_one_billing_product
+ ON vip_plans (creator_id, billing_kind) WHERE status = 'published' AND billing_kind IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS vip_plan_versions (
+ id TEXT PRIMARY KEY,
+ plan_id TEXT NOT NULL REFERENCES vip_plans(id),
+ version INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ benefits TEXT NOT NULL DEFAULT '[]',
+ terms TEXT NOT NULL,
+ change_note TEXT,
+ created_by TEXT,
+ created_at TEXT NOT NULL,
+ published_at TEXT,
+ UNIQUE (plan_id, version)
+);
+CREATE TRIGGER IF NOT EXISTS vip_plan_versions_no_rewrite BEFORE UPDATE ON vip_plan_versions
+WHEN NEW.id IS NOT OLD.id OR NEW.plan_id IS NOT OLD.plan_id OR NEW.version IS NOT OLD.version
+ OR NEW.name IS NOT OLD.name OR NEW.description IS NOT OLD.description OR NEW.benefits IS NOT OLD.benefits
+ OR NEW.terms IS NOT OLD.terms OR NEW.change_note IS NOT OLD.change_note OR NEW.created_by IS NOT OLD.created_by
+ OR NEW.created_at IS NOT OLD.created_at OR (OLD.published_at IS NOT NULL AND NEW.published_at IS NOT OLD.published_at)
+BEGIN SELECT RAISE(ABORT, 'vip_plan_versions are immutable: edit a plan by creating a new version'); END;
+CREATE TRIGGER IF NOT EXISTS vip_plan_versions_no_delete BEFORE DELETE ON vip_plan_versions
+BEGIN SELECT RAISE(ABORT, 'vip_plan_versions are immutable: edit a plan by creating a new version'); END;
+
+CREATE TABLE IF NOT EXISTS vip_perks (
+ id TEXT PRIMARY KEY,
+ creator_id TEXT NOT NULL REFERENCES vip_creators(id),
+ key TEXT NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ kind TEXT NOT NULL DEFAULT 'other' CHECK (kind IN ('badge', 'emote', 'gated_content', 'room', 'role', 'other')),
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),
+ created_by TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE (creator_id, key)
+);
+
+CREATE TABLE IF NOT EXISTS vip_plan_perks (
+ plan_version_id TEXT NOT NULL REFERENCES vip_plan_versions(id),
+ perk_id TEXT NOT NULL REFERENCES vip_perks(id),
+ perk_key TEXT NOT NULL,
+ perk_name TEXT NOT NULL,
+ position INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (plan_version_id, perk_id)
+);
+CREATE INDEX IF NOT EXISTS idx_vip_plan_perks_key ON vip_plan_perks (plan_version_id, perk_key);
+CREATE TRIGGER IF NOT EXISTS vip_plan_perks_no_update BEFORE UPDATE ON vip_plan_perks
+BEGIN SELECT RAISE(ABORT, 'vip_plan_perks belong to an immutable plan version'); END;
+CREATE TRIGGER IF NOT EXISTS vip_plan_perks_no_delete BEFORE DELETE ON vip_plan_perks
+BEGIN SELECT RAISE(ABORT, 'vip_plan_perks belong to an immutable plan version'); END;
+
+CREATE TABLE IF NOT EXISTS vip_product_bindings (
+ id TEXT PRIMARY KEY,
+ perk_id TEXT NOT NULL REFERENCES vip_perks(id),
+ product TEXT NOT NULL,
+ binding TEXT NOT NULL,
+ config TEXT NOT NULL DEFAULT '{}',
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'removed')),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE (perk_id, product, binding)
+);
+
+CREATE TABLE IF NOT EXISTS vip_member_preferences (
+ member_subject TEXT NOT NULL,
+ creator_id TEXT NOT NULL REFERENCES vip_creators(id),
+ show_badge INTEGER NOT NULL DEFAULT 1,
+ listed INTEGER NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (member_subject, creator_id)
+);
+
+CREATE TABLE IF NOT EXISTS vip_gated_resource_rules (
+ id TEXT PRIMARY KEY,
+ creator_id TEXT NOT NULL REFERENCES vip_creators(id),
+ resource_service TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ resource_id TEXT NOT NULL,
+ requirement TEXT NOT NULL CHECK (requirement IN ('member', 'plan', 'perk')),
+ plan_id TEXT REFERENCES vip_plans(id),
+ perk_key TEXT,
+ sensitive INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
+ created_by TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ CHECK (requirement <> 'plan' OR plan_id IS NOT NULL),
+ CHECK (requirement <> 'perk' OR perk_key IS NOT NULL)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_rules_resource
+ ON vip_gated_resource_rules (resource_service, resource_type, resource_id) WHERE status = 'active';
+
+CREATE TABLE IF NOT EXISTS vip_migration_maps (
+ source TEXT NOT NULL,
+ source_table TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ target_type TEXT,
+ target_id TEXT,
+ status TEXT NOT NULL CHECK (status IN ('imported', 'linked', 'held', 'excluded')),
+ reason TEXT,
+ run_id TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (source, source_table, source_id)
+);
+
+CREATE TABLE IF NOT EXISTS vip_memberships (
+ id TEXT PRIMARY KEY,
+ member_subject TEXT NOT NULL,
+ creator_id TEXT NOT NULL REFERENCES vip_creators(id),
+ plan_id TEXT REFERENCES vip_plans(id),
+ plan_version_id TEXT REFERENCES vip_plan_versions(id),
+ billing_subscription_id TEXT,
+ origin TEXT NOT NULL CHECK (origin IN ('checkout', 'billing', 'import')),
+ joined_at TEXT NOT NULL,
+ terms_since TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE (member_subject, creator_id)
+);
+CREATE INDEX IF NOT EXISTS idx_vip_memberships_creator ON vip_memberships (creator_id);
+
+CREATE TABLE IF NOT EXISTS vip_entitlement_projection (
+ member_subject TEXT NOT NULL,
+ creator_subject TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ active INTEGER NOT NULL,
+ expires_at TEXT,
+ cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
+ subscription_id TEXT,
+ subscription_status TEXT,
+ source TEXT NOT NULL CHECK (source IN ('event', 'billing_check', 'import')),
+ last_reason TEXT,
+ billing_event_id TEXT,
+ billing_as_of INTEGER NOT NULL,
+ synced_at INTEGER NOT NULL,
+ valid_until INTEGER NOT NULL,
+ last_used_at INTEGER,
+ PRIMARY KEY (member_subject, creator_subject, kind)
+);
+CREATE INDEX IF NOT EXISTS idx_vip_projection_creator ON vip_entitlement_projection (creator_subject, kind, active);
+
+CREATE TABLE IF NOT EXISTS vip_checkouts (
+ id TEXT PRIMARY KEY,
+ member_subject TEXT NOT NULL,
+ creator_id TEXT NOT NULL REFERENCES vip_creators(id),
+ plan_id TEXT NOT NULL REFERENCES vip_plans(id),
+ plan_version_id TEXT NOT NULL REFERENCES vip_plan_versions(id),
+ provider TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('created', 'handed_off', 'paid', 'failed', 'used')),
+ billing_intent_id TEXT,
+ billing_subscription_id TEXT,
+ checkout_url TEXT,
+ checkout_ref TEXT,
+ error TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_vip_checkouts_member ON vip_checkouts (member_subject, creator_id, created_at);
+`;
+
+function openDb(dbPath) {
+ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
+ const db = new Database(dbPath);
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+ db.pragma('busy_timeout = 5000');
+ migrate(db);
+ return db;
+}
+
+function migrate(db) {
+ db.exec(SCHEMA);
+ const now = new Date().toISOString();
+ db.prepare('INSERT OR IGNORE INTO settings (id, created_at) VALUES (1, ?)').run(now);
+ // The network itself offers network-wide plans and owns network perks.
+ db.prepare(`INSERT OR IGNORE INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)
+ VALUES ('network', 'network', NULL, NULL, NULL, 'OpenVibe', 'system', ?, ?)`).run(now, now);
+}
+
+module.exports = { openDb, migrate };
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+
+/**
+ * Checkout hand-off and cancellation — both done BY Billing (ADR-012); VIP only records which plan
+ * version the member chose so the membership Billing grants is filed under those terms.
+ *
+ * start() provider 'credit' Billing POST /subscriptions (source credit): the period is paid
+ * from the member's Vibes credit at once
+ * any other provider Billing POST /intents (kind subscription): Billing returns the
+ * provider checkout (a URL, or a PowerChat checkout_ref); the
+ * membership starts when Billing's settlement event arrives
+ * cancel() Billing POST /subscriptions/:id/cancel (at period end)
+ *
+ * Both first ask Billing directly (a sensitive action never trusts the projection). Idempotency
+ * keys come from VIP's own ids, so a retried call returns Billing's first answer.
+ */
+const { fail, prefixedId, iso } = require('../util');
+const { BillingCallError } = require('../billing-client');
+
+function billingProblem(err) {
+ if (!(err instanceof BillingCallError)) throw err;
+ const code = err.code || '';
+ if (code === 'billing.frozen') fail(503, 'vip.billing_frozen', 'payments are paused right now; please try again later');
+ if (code === 'billing.insufficient_funds') fail(409, 'vip.checkout.insufficient_credit', 'your Vibes credit does not cover this membership');
+ if (code === 'billing.provider_disabled' || code === 'billing.invalid_input') fail(409, 'vip.checkout.provider_unavailable', 'that payment method is not available right now');
+ if (code === 'billing.self_dealing') fail(422, 'vip.checkout.self', 'you cannot join your own plan');
+ if (err.status === 403 || err.status === 401) fail(503, 'vip.billing_unavailable', 'VIP is not allowed to reach Billing for this yet');
+ fail(503, 'vip.billing_unavailable', 'Billing is not answering right now; nothing was charged');
+}
+
+function createCheckout({ db, now, config, billing, creators, plans, entitlements }) {
+ const byId = (id) => db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;
+
+ async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent }) {
+ const plan = plans.byId(String(planId || ''));
+ if (!plan || plan.status !== 'published') fail(404, 'vip.plan_not_found', 'no published plan with that id');
+ if (!plan.billing_kind) fail(409, 'vip.checkout.unavailable', 'this plan is not sold through Billing yet');
+ const creator = creators.byId(plan.creator_id);
+ if (!creator || creator.status !== 'active' || !creator.subject) fail(409, 'vip.checkout.unavailable', 'this creator is not taking new members');
+ if (creator.subject === member) fail(422, 'vip.checkout.self', 'you cannot join your own plan');
+ const p = String(provider || '').toLowerCase();
+ if (!config.billing.providers.includes(p)) fail(422, 'vip.checkout.provider_unavailable', `provider must be one of ${config.billing.providers.join(', ') || '(none configured)'}`);
+
+ // Sensitive: ask Billing, not the projection. An active member renews through Billing, not here.
+ let current;
+ try { current = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }
+ if (current && current.active && current.expires_at && Date.parse(current.expires_at) > now()) {
+ fail(409, 'vip.already_member', 'you are already a member; your membership renews through Billing');
+ }
+
+ const at = iso(now());
+ const id = prefixedId('vco', now());
+ db.prepare(`INSERT INTO vip_checkouts (id, member_subject, creator_id, plan_id, plan_version_id, provider, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, 'created', ?, ?)`).run(id, member, creator.id, plan.id, plan.current_version_id, p, at, at);
+ const mark = (fields) => {
+ const cols = Object.keys(fields);
+ db.prepare(`UPDATE vip_checkouts SET ${cols.map((c) => `${c} = @${c}`).join(', ')}, updated_at = @updated_at WHERE id = @id`).run({ ...fields, updated_at: iso(now()), id });
+ };
+
+ if (p === 'credit') {
+ let out;
+ try {
+ out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, autoRenew, key: `vip:checkout:${id}`, traceparent });
+ } catch (e) { mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
+ const sub = out.subscription || {};
+ const ent = out.entitlement || {};
+ mark({ status: 'paid', billing_subscription_id: sub.id || null });
+ entitlements.apply({
+ member, creator: creator.subject, active: !!ent.active, expiresAt: ent.expires_at || sub.current_period_end || null,
+ cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id || null, subscriptionStatus: sub.status || null,
+ reason: 'granted', source: 'billing_check', asOf: now(), checkoutId: id, traceparent,
+ });
+ return { checkout: present(byId(id)), membership_started: !!ent.active, checkout_url: null, checkout_ref: null };
+ }
+
+ let out;
+ try {
+ out = await billing.createIntent({
+ provider: p, subject: member, creator: creator.subject, autoRenew,
+ successUrl: successUrl || `${config.baseUrl}/me?joined=${encodeURIComponent(creator.username || '')}`,
+ cancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,
+ key: `vip:checkout:${id}`, traceparent,
+ });
+ } catch (e) { mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
+ const intent = out.intent || {};
+ let url = out.checkout_url || null;
+ if (!url && intent.checkout_ref && config.billing.powerchatLinkTemplate) {
+ url = config.billing.powerchatLinkTemplate.replace('{ref}', encodeURIComponent(intent.checkout_ref)).replace('{cents}', String(intent.amount_cents || ''));
+ }
+ // Only ever send a member to an http(s) page (never javascript:/data: from a bad answer).
+ if (url && !/^https?:\/\//i.test(url)) url = null;
+ mark({ status: 'handed_off', billing_intent_id: intent.id || null, checkout_url: url, checkout_ref: intent.checkout_ref || null });
+ return { checkout: present(byId(id)), membership_started: false, checkout_url: url, checkout_ref: intent.checkout_ref || null, amount_cents: intent.amount_cents || null };
+ }
+
+ /** Cancel at period end, through Billing. Only the member themself. */
+ async function cancel({ member, creatorId, traceparent }) {
+ const creator = creators.byId(creatorId);
+ if (!creator || !creator.subject) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);
+ let row;
+ try { row = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }
+ if (!row || !row.active || !row.subscription_id) fail(404, 'vip.membership_not_found', 'there is no active membership to cancel');
+ if (row.cancel_at_period_end) return { already: true, expires_at: row.expires_at };
+ let sub;
+ try {
+ const s = await billing.getSubscription(row.subscription_id);
+ sub = s.subscription;
+ } catch (e) { billingProblem(e); }
+ if (!sub || !sub.subscriber || sub.subscriber.id !== member) fail(403, 'vip.not_yours', 'that membership is not yours');
+ let out;
+ try {
+ out = await billing.cancelSubscription({ id: sub.id, key: `vip:cancel:${sub.id}:${sub.current_period_end || 'open'}`, traceparent });
+ } catch (e) { billingProblem(e); }
+ const s = (out && out.subscription) || {};
+ entitlements.applyCanceled({
+ member, creator: creator.subject, subscriptionId: s.id || sub.id, subscriptionStatus: s.status || 'active',
+ currentPeriodEnd: s.current_period_end || null, asOf: now(), traceparent, source: 'billing_check',
+ });
+ return { already: false, expires_at: row.expires_at, provider_sync: out && out.provider_sync };
+ }
+
+ function present(c) {
+ if (!c) return null;
+ return {
+ id: c.id, member: { type: 'user', id: c.member_subject }, creator_id: c.creator_id, plan_id: c.plan_id, plan_version_id: c.plan_version_id,
+ provider: c.provider, status: c.status, billing_intent_id: c.billing_intent_id, billing_subscription_id: c.billing_subscription_id,
+ checkout_url: c.checkout_url, checkout_ref: c.checkout_ref, created_at: c.created_at,
+ };
+ }
+
+ return { start, cancel, byId, present };
+}
+
+module.exports = { createCheckout, billingProblem };
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+
+/**
+ * vip_creators: who offers memberships. A creator is a canonical Network user subject (never a
+ * product's integer id); the row's username is a cached handle for the public page /:username,
+ * refreshed whenever the creator signs in. The network itself is the creator `network` (staff
+ * manage its plans and network-wide perks).
+ */
+const { fail, prefixedId, iso, text } = require('../util');
+
+const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
+
+function createCreators({ db, now }) {
+ const byId = (id) => db.prepare('SELECT * FROM vip_creators WHERE id = ?').get(id) || null;
+ const bySubject = (s) => db.prepare('SELECT * FROM vip_creators WHERE subject = ?').get(s) || null;
+ const byUsername = (u) => (u ? db.prepare('SELECT * FROM vip_creators WHERE username_lc = ?').get(String(u).toLowerCase()) || null : null);
+ const network = () => byId('network');
+
+ /**
+ * The creator row for a subject, created on first use. username/displayName (from the creator's
+ * own Network token, the importer or the identity service) refresh the cached handle; a handle
+ * another row still holds (a rename) is taken over, the stale row keeps no handle.
+ */
+ function ensure({ subject, username = null, displayName = null, origin = 'self' }) {
+ if (!subject) fail(422, 'vip.invalid_subject', 'a creator needs a subject');
+ const at = iso(now());
+ const handle = username && USERNAME_RE.test(String(username)) ? String(username) : null;
+ let row = bySubject(subject);
+ if (handle) {
+ const holder = byUsername(handle);
+ if (holder && holder.subject !== subject) db.prepare('UPDATE vip_creators SET username = NULL, username_lc = NULL, updated_at = ? WHERE id = ?').run(at, holder.id);
+ }
+ if (!row) {
+ const id = prefixedId('vcr', now());
+ db.prepare(`INSERT INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)
+ VALUES (?, 'creator', ?, ?, ?, ?, ?, ?, ?)`).run(id, subject, handle, handle ? handle.toLowerCase() : null, displayName ? String(displayName).slice(0, 80) : null, origin, at, at);
+ return byId(id);
+ }
+ const changes = {};
+ if (handle && handle !== row.username) changes.username = handle;
+ if (displayName && String(displayName).slice(0, 80) !== row.display_name) changes.display_name = String(displayName).slice(0, 80);
+ if (Object.keys(changes).length) {
+ db.prepare(`UPDATE vip_creators SET username = COALESCE(?, username), username_lc = COALESCE(?, username_lc), display_name = COALESCE(?, display_name), updated_at = ? WHERE id = ?`)
+ .run(changes.username || null, changes.username ? changes.username.toLowerCase() : null, changes.display_name || null, at, row.id);
+ row = byId(row.id);
+ }
+ return row;
+ }
+
+ function update(id, { displayName, bio }) {
+ const row = byId(id);
+ if (!row) fail(404, 'vip.creator_not_found', `no creator ${id}`);
+ const name = displayName !== undefined ? text(displayName, 'display_name', 80) : row.display_name;
+ const about = bio !== undefined ? text(bio, 'bio', 2000) : row.bio;
+ db.prepare('UPDATE vip_creators SET display_name = ?, bio = ?, updated_at = ? WHERE id = ?').run(name, about, iso(now()), id);
+ return byId(id);
+ }
+
+ /** Creators with at least one published plan, for the directory and the sitemap. */
+ function listPublic({ limit = 200 } = {}) {
+ return db.prepare(`SELECT c.* FROM vip_creators c WHERE c.status = 'active' AND c.kind = 'creator' AND c.username IS NOT NULL
+ AND EXISTS (SELECT 1 FROM vip_plans p WHERE p.creator_id = c.id AND p.status = 'published')
+ ORDER BY COALESCE(c.display_name, c.username) COLLATE NOCASE LIMIT ?`).all(limit);
+ }
+
+ function present(c) {
+ if (!c) return null;
+ return {
+ id: c.id, kind: c.kind, subject: c.subject ? { type: 'user', id: c.subject } : null,
+ username: c.username, display_name: c.display_name || c.username || (c.kind === 'network' ? 'OpenVibe' : null),
+ bio: c.bio || null, status: c.status,
+ };
+ }
+
+ return { byId, bySubject, byUsername, network, ensure, update, listPublic, present };
+}
+
+module.exports = { createCreators };
new file mode 100644
new file mode 100644
@@ -0,0 +1,201 @@
@@ -0,0 +1,201 @@
+'use strict';
+
+/**
+ * The entitlement projection: a short-lived cache of OpenVibe.Billing's entitlements (Billing holds
+ * the truth, ADR-012), fed by Billing's events (billing.entitlement.changed,
+ * billing.subscription.canceled, billing.transaction.reversed) and by direct authoritative checks.
+ *
+ * A stale projection can never authorize indefinitely:
+ * valid_until = min(paid period end, last word from Billing + maxAge) (a "no" is kept maxAge)
+ * now <= valid_until the projection answers
+ * valid_until < now <= valid_until+grace mode 'projection' answers, flagged stale; mode 'auto' asks
+ * Billing first and only falls back to the stale answer when
+ * Billing cannot be reached
+ * later "unknown" (active: false) until Billing confirms again
+ * Mode 'authoritative' (sensitive actions) always asks Billing and answers "unknown" when it cannot.
+ *
+ * Ordering: every row remembers the Billing time it reflects (billing_as_of). An older event that
+ * would grant is ignored; an older event that would revoke puts the row in doubt (valid_until = 0),
+ * so the next check goes to Billing — out-of-order delivery can deny briefly, never grant wrongly.
+ */
+const { iso, userRef } = require('../util');
+
+const KIND = 'channel_subscription';
+
+function createEntitlements({ db, now, config, billing, outbox, memberships, plans, log = console }) {
+ const { maxAgeMs, graceMs } = config.projection;
+ const getRow = (member, creator, kind = KIND) => db.prepare('SELECT * FROM vip_entitlement_projection WHERE member_subject = ? AND creator_subject = ? AND kind = ?').get(member, creator, kind) || null;
+
+ function validUntil(at, active, expiresAt) {
+ const cap = at + maxAgeMs;
+ if (!active) return cap;
+ const end = expiresAt ? Date.parse(expiresAt) : NaN;
+ return Number.isFinite(end) ? Math.min(cap, end) : at;
+ }
+
+ function emitChanged(row, prev, { reason, membership }, traceparent) {
+ const v = membership && membership.plan_version_id ? plans.version(membership.plan_version_id) : null;
+ outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {
+ member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
+ active: !!row.active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
+ previous: prev ? { active: !!prev.active, expires_at: prev.expires_at, cancel_at_period_end: !!prev.cancel_at_period_end } : null,
+ reason: reason || null, source: row.source, billing_event_id: row.billing_event_id || null,
+ membership_id: membership ? membership.id : null, plan_id: membership ? membership.plan_id : null,
+ plan_version_id: membership ? membership.plan_version_id : null, plan_version: v ? v.version : null,
+ subscription_id: row.subscription_id || null,
+ }, { traceparent });
+ }
+
+ /**
+ * Apply what Billing said about (member, creator). Runs in a transaction (joins the caller's).
+ * input: { member, creator, kind?, active, expiresAt, cancelAtPeriodEnd?, subscriptionId?,
+ * subscriptionStatus?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),
+ * origin?, checkoutId?, traceparent? }
+ */
+ function apply(input) {
+ return db.transaction(() => {
+ const kind = input.kind || KIND;
+ const prev = getRow(input.member, input.creator, kind);
+ const at = now();
+ if (prev && input.asOf < prev.billing_as_of) {
+ if (!input.active && prev.active) {
+ db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(input.member, input.creator, kind);
+ return { outcome: 'doubt', changed: false };
+ }
+ return { outcome: 'stale_ignored', changed: false };
+ }
+ const next = {
+ member_subject: input.member, creator_subject: input.creator, kind,
+ active: input.active ? 1 : 0, expires_at: input.expiresAt || null,
+ cancel_at_period_end: input.cancelAtPeriodEnd !== undefined ? (input.cancelAtPeriodEnd ? 1 : 0) : (prev ? prev.cancel_at_period_end : 0),
+ subscription_id: input.subscriptionId !== undefined ? input.subscriptionId : (prev ? prev.subscription_id : null),
+ subscription_status: input.subscriptionStatus !== undefined ? input.subscriptionStatus : (prev ? prev.subscription_status : null),
+ source: input.source, last_reason: input.reason || null, billing_event_id: input.eventId || null,
+ billing_as_of: input.asOf, synced_at: at, valid_until: validUntil(at, input.active, input.expiresAt),
+ last_used_at: prev ? prev.last_used_at : null,
+ };
+ db.prepare(`INSERT INTO vip_entitlement_projection (member_subject, creator_subject, kind, active, expires_at, cancel_at_period_end, subscription_id,
+ subscription_status, source, last_reason, billing_event_id, billing_as_of, synced_at, valid_until, last_used_at)
+ VALUES (@member_subject, @creator_subject, @kind, @active, @expires_at, @cancel_at_period_end, @subscription_id, @subscription_status,
+ @source, @last_reason, @billing_event_id, @billing_as_of, @synced_at, @valid_until, @last_used_at)
+ ON CONFLICT (member_subject, creator_subject, kind) DO UPDATE SET active = excluded.active, expires_at = excluded.expires_at,
+ cancel_at_period_end = excluded.cancel_at_period_end, subscription_id = excluded.subscription_id,
+ subscription_status = excluded.subscription_status, source = excluded.source, last_reason = excluded.last_reason,
+ billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,
+ valid_until = excluded.valid_until`).run(next);
+ const { membership } = memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });
+ // A first look that finds no membership is not a change anyone needs to hear about.
+ const changed = prev
+ ? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end)
+ : !!next.active;
+ if (changed) emitChanged(next, prev, { reason: input.reason, membership }, input.traceparent);
+ return { outcome: changed ? 'changed' : 'unchanged', changed, row: getRow(input.member, input.creator, kind), membership };
+ })();
+ }
+
+ /** A cancellation scheduled at Billing: the period still runs; only the flag changes. */
+ function applyCanceled({ member, creator, subscriptionId, subscriptionStatus, currentPeriodEnd, eventId, asOf, traceparent, source = 'event' }) {
+ return db.transaction(() => {
+ const prev = getRow(member, creator);
+ if (!prev) return { outcome: 'no_projection', changed: false };
+ if (asOf < prev.billing_as_of) return { outcome: 'stale_ignored', changed: false };
+ const active = subscriptionStatus === 'active' ? !!prev.active : false;
+ return apply({
+ member, creator, active, expiresAt: active ? (prev.expires_at || currentPeriodEnd) : prev.expires_at, cancelAtPeriodEnd: true,
+ subscriptionId, subscriptionStatus, reason: 'cancel_scheduled', source, eventId, asOf, traceparent,
+ });
+ })();
+ }
+
+ /** Something happened that Billing will describe in detail (a refund): stop trusting the row. */
+ function doubt(member, creator, kind = KIND) {
+ return db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(member, creator, kind).changes > 0;
+ }
+
+ function answer(row, source, { stale = false, membership } = {}) {
+ const t = now();
+ const active = !!row.active && !!row.expires_at && Date.parse(row.expires_at) > t;
+ return {
+ member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
+ status: active ? 'active' : 'inactive', active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
+ source, stale, valid_until: iso(Math.max(0, row.valid_until)), checked_at: iso(t),
+ membership: membership ? memberships.present(membership) : null,
+ };
+ }
+ function unknown(member, creator, reason) {
+ return {
+ member: userRef(member), creator: userRef(creator), kind: KIND, status: 'unknown', active: false, expires_at: null,
+ cancel_at_period_end: false, source: 'none', stale: false, valid_until: null, checked_at: iso(now()), reason, membership: null,
+ };
+ }
+
+ const membershipOf = (member, creatorSubject) => {
+ const row = db.prepare('SELECT m.* FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?').get(member, creatorSubject);
+ return row || null;
+ };
+
+ /** Ask Billing and project the answer. Throws BillingCallError when Billing cannot answer. */
+ async function authoritative(member, creator, { traceparent } = {}) {
+ const e = await billing.entitlement(member, creator);
+ const sub = e && e.subscription;
+ const out = apply({
+ member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,
+ cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
+ subscriptionStatus: sub ? sub.status : undefined, reason: 'checked', source: 'billing_check', asOf: now(), traceparent,
+ });
+ return out.row || getRow(member, creator);
+ }
+
+ /**
+ * check(member, creator, { mode: 'auto' | 'projection' | 'authoritative' }) → answer.
+ * `active` is true only for status 'active'; 'unknown' never authorizes.
+ */
+ async function check(member, creator, { mode = 'auto', traceparent } = {}) {
+ if (member === creator) return { ...unknown(member, creator, 'self'), status: 'inactive', reason: 'a creator is not a member of their own plans' };
+ const t = now();
+ let row = getRow(member, creator);
+ if (row) db.prepare('UPDATE vip_entitlement_projection SET last_used_at = ? WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(t, member, creator, KIND);
+ const fresh = row && t <= row.valid_until;
+ const withinGrace = row && t <= row.valid_until + graceMs;
+ if (mode === 'projection') {
+ if (fresh) return answer(row, 'projection', { membership: membershipOf(member, creator) });
+ if (withinGrace) return answer(row, 'projection', { stale: true, membership: membershipOf(member, creator) });
+ return unknown(member, creator, row ? 'projection_expired' : 'no_projection');
+ }
+ if (mode === 'auto' && fresh) return answer(row, 'projection', { membership: membershipOf(member, creator) });
+ try {
+ row = await authoritative(member, creator, { traceparent });
+ return answer(row, 'billing', { membership: membershipOf(member, creator) });
+ } catch (err) {
+ log.warn(`[VIP] authoritative entitlement check failed (${member} → ${creator}): ${err.message}`);
+ if (mode === 'auto' && withinGrace) return { ...answer(row, 'projection', { stale: true, membership: membershipOf(member, creator) }), reason: 'billing_unavailable' };
+ return unknown(member, creator, 'billing_unavailable');
+ }
+ }
+
+ /**
+ * Keep projections that products are actually using confirmed before they go stale (job).
+ * Rows unused for an hour simply expire into "unknown" and are re-checked on demand.
+ */
+ async function refreshDue({ limit = 50 } = {}) {
+ const t = now();
+ const rows = db.prepare(`SELECT member_subject, creator_subject FROM vip_entitlement_projection
+ WHERE kind = ? AND last_used_at >= ? AND valid_until <= ? ORDER BY valid_until LIMIT ?`).all(KIND, t - 3600_000, t + 120_000, limit);
+ let ok = 0; let failed = 0;
+ for (const r of rows) {
+ try { await authoritative(r.member_subject, r.creator_subject); ok++; } catch { failed++; }
+ }
+ return { checked: rows.length, ok, failed };
+ }
+
+ /** Members of a creator according to the projection (fresh rows only; never past valid_until). */
+ function projectedMembers(creator) {
+ const t = now();
+ return db.prepare(`SELECT * FROM vip_entitlement_projection WHERE creator_subject = ? AND kind = ? AND active = 1 AND valid_until >= ? AND expires_at > ?
+ ORDER BY expires_at DESC`).all(creator, KIND, t, iso(t));
+ }
+
+ return { apply, applyCanceled, doubt, check, authoritative, refreshDue, projectedMembers, getRow, KIND };
+}
+
+module.exports = { createEntitlements, KIND };
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+
+/** The VIP domain: every module shares one database handle, one clock and one outbox. */
+const { createCreators } = require('./creators');
+const { createPerks } = require('./perks');
+const { createPlans } = require('./plans');
+const { createMemberships } = require('./memberships');
+const { createEntitlements } = require('./entitlements');
+const { createPolicies } = require('./policies');
+const { createCheckout } = require('./checkout');
+
+function createDomain({ db, config, outbox, billing, now = () => Date.now(), log = console }) {
+ const creators = createCreators({ db, now });
+ const perks = createPerks({ db, now });
+ const plans = createPlans({ db, now, outbox, creators, perks });
+ const memberships = createMemberships({ db, now, creators, plans });
+ const entitlements = createEntitlements({ db, now, config, billing, outbox, memberships, plans, log });
+ const policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });
+ const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements });
+ const tx = (fn) => db.transaction(fn)();
+ return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, entitlements, policies, checkout };
+}
+
+module.exports = { createDomain };
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+
+/**
+ * vip_memberships: VIP's record of WHICH PLAN VERSION a membership was bought under, plus member
+ * preferences. Whether the membership is active is never stored here — that is Billing's
+ * entitlement (projected in vip_entitlement_projection, re-checked with Billing when stale).
+ *
+ * Version rules (plan terms are never rewritten):
+ * - a new membership (first grant, or a grant after the previous one lapsed) takes the version of
+ * the member's own checkout hand-off when there is one, else the creator's current purchasable
+ * version (a subscription bought elsewhere, e.g. on Live before its cutover);
+ * - a renewal keeps the version the membership already has, however often the plan was edited;
+ * - a membership Billing grants for a creator with no VIP plan is recorded with no plan.
+ */
+const { prefixedId, iso, json } = require('../util');
+
+const CHECKOUT_WINDOW_MS = 14 * 86_400_000;
+
+function createMemberships({ db, now, creators, plans }) {
+ const get = (member, creatorId) => db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? AND creator_id = ?').get(member, creatorId) || null;
+ const byId = (id) => db.prepare('SELECT * FROM vip_memberships WHERE id = ?').get(id) || null;
+
+ function pendingCheckout(member, creatorId) {
+ const since = iso(now() - CHECKOUT_WINDOW_MS);
+ return db.prepare(`SELECT * FROM vip_checkouts WHERE member_subject = ? AND creator_id = ? AND status IN ('handed_off', 'paid')
+ AND created_at >= ? ORDER BY created_at DESC LIMIT 1`).get(member, creatorId, since) || null;
+ }
+
+ /** Record the version a membership is under. Inside the projection's transaction. */
+ function onProjection({ prev, next, reason, origin = null, checkoutId = null }) {
+ const creator = creators.bySubject(next.creator_subject) || creators.ensure({ subject: next.creator_subject, origin: 'system' });
+ const member = next.member_subject;
+ let m = get(member, creator.id);
+ const at = iso(now());
+ if (!next.active) return { membership: m, creator };
+ // A membership that exists keeps its version unless this is a NEW purchase after a lapse:
+ // Billing says 'granted' after we saw it inactive, or the member's own checkout settled.
+ const pending = checkoutId ? null : pendingCheckout(member, creator.id);
+ const lapsed = prev && !prev.active;
+ const newPurchase = !!checkoutId || (lapsed && (reason === 'granted' || (reason !== 'renewed' && !!pending)));
+ if (m && !newPurchase) {
+ if (next.subscription_id && next.subscription_id !== m.billing_subscription_id) {
+ db.prepare('UPDATE vip_memberships SET billing_subscription_id = ?, updated_at = ? WHERE id = ?').run(next.subscription_id, at, m.id);
+ m = get(member, creator.id);
+ }
+ return { membership: m, creator };
+ }
+ // A new membership, or a new purchase after a lapse: which terms did the member buy?
+ let planId = null;
+ let versionId = null;
+ let how = origin || 'billing';
+ const checkout = checkoutId ? db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(checkoutId) : pending;
+ if (checkout) {
+ planId = checkout.plan_id; versionId = checkout.plan_version_id; how = 'checkout';
+ db.prepare("UPDATE vip_checkouts SET status = 'used', billing_subscription_id = COALESCE(billing_subscription_id, ?), updated_at = ? WHERE id = ?").run(next.subscription_id || null, at, checkout.id);
+ } else {
+ const plan = plans.purchasable(creator.id, next.kind === 'channel_subscription' ? 'channel_subscription' : next.kind);
+ if (plan) { planId = plan.id; versionId = plan.current_version_id; }
+ }
+ if (m) {
+ db.prepare(`UPDATE vip_memberships SET plan_id = ?, plan_version_id = ?, billing_subscription_id = COALESCE(?, billing_subscription_id), origin = ?,
+ terms_since = ?, updated_at = ? WHERE id = ?`).run(planId, versionId, next.subscription_id || null, how, at, at, m.id);
+ } else {
+ db.prepare(`INSERT INTO vip_memberships (id, member_subject, creator_id, plan_id, plan_version_id, billing_subscription_id, origin, joined_at, terms_since, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(prefixedId('vms', now()), member, creator.id, planId, versionId, next.subscription_id || null, how, at, at, at);
+ }
+ return { membership: get(member, creator.id), creator };
+ }
+
+ function preferences(member, creatorId) {
+ const p = db.prepare('SELECT * FROM vip_member_preferences WHERE member_subject = ? AND creator_id = ?').get(member, creatorId);
+ return { show_badge: p ? !!p.show_badge : true, listed: p ? !!p.listed : false };
+ }
+ function setPreferences(member, creatorId, { showBadge, listed }) {
+ const cur = preferences(member, creatorId);
+ const sb = showBadge === undefined ? cur.show_badge : !!showBadge;
+ const li = listed === undefined ? cur.listed : !!listed;
+ db.prepare(`INSERT INTO vip_member_preferences (member_subject, creator_id, show_badge, listed, updated_at) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (member_subject, creator_id) DO UPDATE SET show_badge = excluded.show_badge, listed = excluded.listed, updated_at = excluded.updated_at`)
+ .run(member, creatorId, sb ? 1 : 0, li ? 1 : 0, iso(now()));
+ return preferences(member, creatorId);
+ }
+
+ const forMember = (member) => db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? ORDER BY joined_at DESC').all(member);
+ const forCreator = (creatorId) => db.prepare('SELECT * FROM vip_memberships WHERE creator_id = ? ORDER BY joined_at DESC').all(creatorId);
+
+ /** The perk keys a membership's version grants (its own snapshot, never the plan's latest). */
+ function perkKeys(m) {
+ if (!m || !m.plan_version_id) return [];
+ return plans.versionPerks(m.plan_version_id).map((p) => p.perk_key);
+ }
+
+ function present(m) {
+ if (!m) return null;
+ const v = m.plan_version_id ? plans.version(m.plan_version_id) : null;
+ return {
+ id: m.id, member: { type: 'user', id: m.member_subject }, creator_id: m.creator_id,
+ plan_id: m.plan_id, plan_version: v ? { id: v.id, version: v.version, name: v.name, terms: json(v.terms, {}) } : null,
+ perks: perkKeys(m), origin: m.origin, joined_at: m.joined_at, terms_since: m.terms_since,
+ };
+ }
+
+ return { get, byId, onProjection, pendingCheckout, preferences, setPreferences, forMember, forCreator, perkKeys, present };
+}
+
+module.exports = { createMemberships };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+
+/**
+ * vip_perks and vip_product_bindings. A perk is a benefit a creator (or the network) defines once
+ * and includes in plan versions; a product binding says how a product recognises it (Live's chat
+ * subscriber badge, a Chat room, a Community role, a gated Blog post, …).
+ *
+ * Editing a perk's name or bindings never rewrites a plan version: each version keeps its own
+ * snapshot of the perk keys and names it was published with (vip_plan_perks).
+ */
+const { fail, prefixedId, iso, text, slug, json } = require('../util');
+
+const KINDS = ['badge', 'emote', 'gated_content', 'room', 'role', 'other'];
+const PRODUCT_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const BINDING_RE = /^[a-z][a-z0-9_.]{1,63}$/;
+
+function createPerks({ db, now }) {
+ const byId = (id) => db.prepare('SELECT * FROM vip_perks WHERE id = ?').get(id) || null;
+ const byKey = (creatorId, key) => db.prepare('SELECT * FROM vip_perks WHERE creator_id = ? AND key = ?').get(creatorId, key) || null;
+
+ function cleanBindings(list) {
+ if (list == null) return null;
+ if (!Array.isArray(list) || list.length > 20) fail(422, 'vip.invalid_input', 'bindings must be a list of at most 20 { product, binding, config? }');
+ const seen = new Set();
+ return list.map((b, i) => {
+ if (!b || !PRODUCT_RE.test(String(b.product || ''))) fail(422, 'vip.invalid_input', `bindings[${i}].product must be a service id such as live, chat or community`);
+ if (!BINDING_RE.test(String(b.binding || ''))) fail(422, 'vip.invalid_input', `bindings[${i}].binding must be an id such as chat_badge or room_access`);
+ const config = b.config == null ? {} : b.config;
+ if (typeof config !== 'object' || Array.isArray(config) || JSON.stringify(config).length > 2000) fail(422, 'vip.invalid_input', `bindings[${i}].config must be an object under 2 KB`);
+ const k = `${b.product}:${b.binding}`;
+ if (seen.has(k)) fail(422, 'vip.invalid_input', `bindings[${i}] repeats ${k}`);
+ seen.add(k);
+ return { product: String(b.product), binding: String(b.binding), config };
+ });
+ }
+
+ function setBindings(perkId, list) {
+ const at = iso(now());
+ const keep = new Set(list.map((b) => `${b.product}:${b.binding}`));
+ for (const b of db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active'").all(perkId)) {
+ if (!keep.has(`${b.product}:${b.binding}`)) db.prepare("UPDATE vip_product_bindings SET status = 'removed', updated_at = ? WHERE id = ?").run(at, b.id);
+ }
+ const up = db.prepare(`INSERT INTO vip_product_bindings (id, perk_id, product, binding, config, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
+ ON CONFLICT (perk_id, product, binding) DO UPDATE SET config = excluded.config, status = 'active', updated_at = excluded.updated_at`);
+ for (const b of list) up.run(prefixedId('vpb', now()), perkId, b.product, b.binding, JSON.stringify(b.config), at, at);
+ }
+
+ function create({ creatorId, key, name, description, kind = 'other', bindings = [], actor = null }) {
+ const k = slug(key || name, 'key');
+ if (byKey(creatorId, k)) fail(409, 'vip.perk_exists', `a perk with key ${k} already exists`);
+ if (!KINDS.includes(kind)) fail(422, 'vip.invalid_input', `kind must be one of ${KINDS.join(', ')}`);
+ const clean = cleanBindings(bindings) || [];
+ const at = iso(now());
+ const id = prefixedId('vpk', now());
+ db.transaction(() => {
+ db.prepare(`INSERT INTO vip_perks (id, creator_id, key, name, description, kind, status, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creatorId, k, text(name, 'name', 80, { required: true }), text(description, 'description', 500), kind, actor, at, at);
+ setBindings(id, clean);
+ })();
+ return byId(id);
+ }
+
+ function update(perkId, { name, description, kind, status, bindings }) {
+ const p = byId(perkId);
+ if (!p) fail(404, 'vip.perk_not_found', `no perk ${perkId}`);
+ if (kind !== undefined && !KINDS.includes(kind)) fail(422, 'vip.invalid_input', `kind must be one of ${KINDS.join(', ')}`);
+ if (status !== undefined && !['active', 'retired'].includes(status)) fail(422, 'vip.invalid_input', 'status must be active or retired');
+ const clean = cleanBindings(bindings);
+ db.transaction(() => {
+ db.prepare('UPDATE vip_perks SET name = ?, description = ?, kind = ?, status = ?, updated_at = ? WHERE id = ?').run(
+ name !== undefined ? text(name, 'name', 80, { required: true }) : p.name,
+ description !== undefined ? text(description, 'description', 500) : p.description,
+ kind !== undefined ? kind : p.kind, status !== undefined ? status : p.status, iso(now()), perkId);
+ if (clean) setBindings(perkId, clean);
+ })();
+ return byId(perkId);
+ }
+
+ /** Perks usable in a creator's plans: their own plus the network's. */
+ function list({ creatorId, includeNetwork = true, includeRetired = false }) {
+ const owners = includeNetwork && creatorId !== 'network' ? [creatorId, 'network'] : [creatorId];
+ return db.prepare(`SELECT * FROM vip_perks WHERE creator_id IN (${owners.map(() => '?').join(',')}) ${includeRetired ? '' : "AND status = 'active'"}
+ ORDER BY creator_id = 'network', name COLLATE NOCASE`).all(...owners);
+ }
+
+ /** Resolve perk ids or keys for a plan of creatorId: own or network perks, active only. */
+ function resolveForPlan(creatorId, refs) {
+ if (refs == null) return null;
+ if (!Array.isArray(refs) || refs.length > 30) fail(422, 'vip.invalid_input', 'perks must be a list of at most 30 perk ids or keys');
+ const out = [];
+ const seen = new Set();
+ for (const r of refs) {
+ const s = String(r || '');
+ let p = s.startsWith('vpk_') ? byId(s) : (byKey(creatorId, s) || byKey('network', s));
+ if (p && p.creator_id !== creatorId && p.creator_id !== 'network') p = null;
+ if (!p || p.status !== 'active') fail(422, 'vip.perk_not_found', `no active perk ${s} for this creator`);
+ if (!seen.has(p.id)) { seen.add(p.id); out.push(p); }
+ }
+ return out;
+ }
+
+ const bindingsOf = (perkId) => db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active' ORDER BY product, binding").all(perkId);
+
+ function present(p, { withBindings = true } = {}) {
+ if (!p) return null;
+ return {
+ id: p.id, key: p.key, name: p.name, description: p.description || null, kind: p.kind, status: p.status,
+ scope: p.creator_id === 'network' ? 'network' : 'creator', creator_id: p.creator_id,
+ bindings: withBindings ? bindingsOf(p.id).map((b) => ({ product: b.product, binding: b.binding, config: json(b.config, {}) })) : undefined,
+ updated_at: p.updated_at,
+ };
+ }
+
+ return { byId, byKey, create, update, list, resolveForPlan, bindingsOf, present, KINDS };
+}
+
+module.exports = { createPerks };
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
+'use strict';
+
+/**
+ * vip_plans, vip_plan_versions, vip_plan_perks.
+ *
+ * Plans are versioned. Terms (name, description, benefits, perks, the Billing product) live in
+ * immutable vip_plan_versions rows; an edit ALWAYS creates version N+1 and never touches an earlier
+ * row (SQLite triggers refuse it). A published plan's edit is published at once and becomes what new
+ * members buy; members who joined earlier keep the version they bought (vip_memberships).
+ *
+ * Prices are not plan terms here: Billing prices and charges a subscription, and records what each
+ * period cost. A plan says which Billing product it is sold as (billing_kind), and Billing sells one
+ * channel subscription per (member, creator) today, so a creator has at most one published plan
+ * bound to it. A plan with no Billing product (a network plan today) can be published as terms, but
+ * has no checkout until Billing sells it.
+ */
+const crypto = require('crypto');
+const { fail, prefixedId, iso, text, slug, slugify, json } = require('../util');
+
+const BILLING_KINDS = ['channel_subscription'];
+
+function createPlans({ db, now, outbox, creators, perks }) {
+ const byId = (id) => db.prepare('SELECT * FROM vip_plans WHERE id = ?').get(id) || null;
+ const version = (id) => db.prepare('SELECT * FROM vip_plan_versions WHERE id = ?').get(id) || null;
+ const latestVersion = (planId) => db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC LIMIT 1').get(planId) || null;
+ const versions = (planId) => db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC').all(planId);
+ const versionPerks = (versionId) => db.prepare('SELECT * FROM vip_plan_perks WHERE plan_version_id = ? ORDER BY position').all(versionId);
+
+ function cleanBenefits(v) {
+ if (v == null) return null;
+ const arr = Array.isArray(v) ? v : String(v).split('\n');
+ const out = arr.map((b) => text(b, 'benefit', 200)).filter(Boolean);
+ if (out.length > 20) fail(422, 'vip.invalid_input', 'at most 20 benefits');
+ return out;
+ }
+
+ /** Insert version N+1 of a plan from complete terms. Inside a transaction. */
+ function insertVersion(plan, { name, description, benefits, perkRows, changeNote, actor, publish }) {
+ const n = plan.latest_version + 1;
+ const at = iso(now());
+ const id = prefixedId('vpv', now());
+ const perkSnap = perkRows.map((p) => ({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator' }));
+ const terms = { name, description, benefits, perks: perkSnap, billing_kind: plan.billing_kind, price: plan.billing_kind ? 'set and charged by OpenVibe.Billing' : null };
+ terms.digest = crypto.createHash('sha256').update(JSON.stringify([terms.name, terms.description, terms.benefits, terms.perks, terms.billing_kind])).digest('hex').slice(0, 16);
+ db.prepare(`INSERT INTO vip_plan_versions (id, plan_id, version, name, description, benefits, terms, change_note, created_by, created_at, published_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, plan.id, n, name, description, JSON.stringify(benefits), JSON.stringify(terms), changeNote || null, actor, at, publish ? at : null);
+ const ins = db.prepare('INSERT INTO vip_plan_perks (plan_version_id, perk_id, perk_key, perk_name, position) VALUES (?, ?, ?, ?, ?)');
+ perkRows.forEach((p, i) => ins.run(id, p.id, p.key, p.name, i));
+ db.prepare(`UPDATE vip_plans SET latest_version = ?, current_version_id = CASE WHEN ? THEN ? ELSE current_version_id END, updated_at = ? WHERE id = ?`)
+ .run(n, publish ? 1 : 0, id, at, plan.id);
+ return version(id);
+ }
+
+ function emitPublished(plan, v, traceparent) {
+ const creator = creators.byId(plan.creator_id);
+ outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {
+ plan_id: plan.id, slug: plan.slug, version: v.version, version_id: v.id,
+ creator: creators.present(creator), billing_kind: plan.billing_kind,
+ terms: json(v.terms, {}), published_at: v.published_at,
+ }, { visibility: 'public', traceparent });
+ }
+
+ function publishConflict(e) {
+ if (e && /UNIQUE constraint failed: vip_plans\.creator_id, vip_plans\.billing_kind/.test(e.message)) {
+ fail(409, 'vip.plan.billing_product_taken', 'this creator already has a published plan sold as a channel subscription; Billing sells one per member and creator today, so archive the other plan first');
+ }
+ throw e;
+ }
+
+ function create({ creatorId, slug: s, name, description, benefits, perks: perkRefs, billingKind, publish = false, changeNote, actor = null, traceparent }) {
+ const creator = creators.byId(creatorId);
+ if (!creator) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);
+ const kind = billingKind === undefined ? (creator.kind === 'network' ? null : 'channel_subscription') : (billingKind || null);
+ if (kind && !BILLING_KINDS.includes(kind)) fail(422, 'vip.invalid_input', `billing_kind must be one of ${BILLING_KINDS.join(', ')} or null`);
+ if (kind && creator.kind === 'network') fail(422, 'vip.invalid_input', 'Billing has no network membership product yet; a network plan has billing_kind null');
+ const cleanName = text(name, 'name', 80, { required: true });
+ const planSlug = s ? slug(s) : slugify(cleanName);
+ if (db.prepare('SELECT 1 FROM vip_plans WHERE creator_id = ? AND slug = ?').get(creatorId, planSlug)) fail(409, 'vip.plan_exists', `a plan ${planSlug} already exists`);
+ const perkRows = perks.resolveForPlan(creatorId, perkRefs || []);
+ const at = iso(now());
+ const id = prefixedId('vpl', now());
+ try {
+ return db.transaction(() => {
+ db.prepare(`INSERT INTO vip_plans (id, creator_id, slug, status, billing_kind, current_version_id, latest_version, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)`).run(id, creatorId, planSlug, publish ? 'published' : 'draft', kind, actor, at, at);
+ const v = insertVersion(byId(id), {
+ name: cleanName, description: text(description, 'description', 2000), benefits: cleanBenefits(benefits) || [],
+ perkRows, changeNote: changeNote || 'created', actor, publish,
+ });
+ if (publish) emitPublished(byId(id), v, traceparent);
+ return byId(id);
+ })();
+ } catch (e) { return publishConflict(e); }
+ }
+
+ /** An edit: version N+1 from the latest terms plus the changes. Published plans publish it. */
+ function update(planId, { name, description, benefits, perks: perkRefs, changeNote, actor = null, traceparent }) {
+ const plan = byId(planId);
+ if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);
+ if (plan.status === 'archived') fail(409, 'vip.plan_archived', 'an archived plan cannot be edited');
+ const last = latestVersion(planId);
+ const next = {
+ name: name !== undefined ? text(name, 'name', 80, { required: true }) : last.name,
+ description: description !== undefined ? text(description, 'description', 2000) : last.description,
+ benefits: benefits !== undefined ? cleanBenefits(benefits) : json(last.benefits, []),
+ };
+ const perkRows = perkRefs !== undefined ? perks.resolveForPlan(plan.creator_id, perkRefs) : versionPerks(last.id).map((pp) => perks.byId(pp.perk_id)).filter((p) => p && p.status === 'active');
+ const same = next.name === last.name && next.description === last.description && JSON.stringify(next.benefits) === last.benefits
+ && JSON.stringify(perkRows.map((p) => p.id)) === JSON.stringify(versionPerks(last.id).map((pp) => pp.perk_id));
+ if (same) return { plan, version: last, unchanged: true };
+ const publish = plan.status === 'published';
+ return db.transaction(() => {
+ const v = insertVersion(plan, { ...next, perkRows, changeNote: text(changeNote, 'change_note', 300), actor, publish });
+ if (publish) emitPublished(byId(planId), v, traceparent);
+ return { plan: byId(planId), version: v, unchanged: false };
+ })();
+ }
+
+ function publish(planId, { traceparent } = {}) {
+ const plan = byId(planId);
+ if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);
+ if (plan.status === 'published') return { plan, replay: true };
+ if (plan.status === 'archived') fail(409, 'vip.plan_archived', 'an archived plan cannot be published again; create a new plan');
+ try {
+ return db.transaction(() => {
+ const v = latestVersion(planId);
+ const at = iso(now());
+ db.prepare('UPDATE vip_plan_versions SET published_at = ? WHERE id = ? AND published_at IS NULL').run(at, v.id);
+ db.prepare("UPDATE vip_plans SET status = 'published', current_version_id = ?, updated_at = ? WHERE id = ?").run(v.id, at, planId);
+ emitPublished(byId(planId), version(v.id), traceparent);
+ return { plan: byId(planId), replay: false };
+ })();
+ } catch (e) { return publishConflict(e); }
+ }
+
+ /** No new members; existing members keep their terms until Billing ends their membership. */
+ function archive(planId) {
+ const plan = byId(planId);
+ if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);
+ if (plan.status === 'archived') return plan;
+ const at = iso(now());
+ db.prepare("UPDATE vip_plans SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(at, at, planId);
+ return byId(planId);
+ }
+
+ function list({ creatorId, includeDrafts = false, includeArchived = false }) {
+ const st = ['published'];
+ if (includeDrafts) st.push('draft');
+ if (includeArchived) st.push('archived');
+ return db.prepare(`SELECT * FROM vip_plans WHERE creator_id = ? AND status IN (${st.map(() => '?').join(',')}) ORDER BY sort, created_at`).all(creatorId, ...st);
+ }
+
+ /** What a new member of this creator buys through Billing's product `kind`. */
+ const purchasable = (creatorId, kind = 'channel_subscription') => db.prepare("SELECT * FROM vip_plans WHERE creator_id = ? AND billing_kind = ? AND status = 'published'").get(creatorId, kind) || null;
+
+ function presentVersion(v) {
+ if (!v) return null;
+ return {
+ id: v.id, version: v.version, name: v.name, description: v.description || null, benefits: json(v.benefits, []),
+ perks: versionPerks(v.id).map((p) => ({ id: p.perk_id, key: p.perk_key, name: p.perk_name })),
+ terms: json(v.terms, {}), change_note: v.change_note || null, created_at: v.created_at, published_at: v.published_at || null,
+ };
+ }
+
+ function present(plan, { withVersions = false } = {}) {
+ if (!plan) return null;
+ const current = plan.current_version_id ? version(plan.current_version_id) : null;
+ const latest = latestVersion(plan.id);
+ return {
+ id: plan.id, creator_id: plan.creator_id, slug: plan.slug, status: plan.status, billing_kind: plan.billing_kind,
+ purchasable: plan.status === 'published' && !!plan.billing_kind,
+ current_version: presentVersion(current),
+ draft_version: latest && (!current || latest.version > current.version) ? presentVersion(latest) : null,
+ versions: withVersions ? versions(plan.id).map(presentVersion) : undefined,
+ created_at: plan.created_at, updated_at: plan.updated_at, archived_at: plan.archived_at || null,
+ };
+ }
+
+ return { byId, version, latestVersion, versions, versionPerks, create, update, publish, archive, list, purchasable, present, presentVersion, BILLING_KINDS };
+}
+
+module.exports = { createPlans };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+
+/**
+ * vip_gated_resource_rules: "resource R of product P is for members of creator C" — optionally for
+ * members of one plan, or for members whose plan VERSION includes perk K. A product stores the
+ * resource; VIP stores only the typed reference (EntityRef) and the rule.
+ *
+ * evaluate() FAILS CLOSED: no rule, a disabled rule, a suspended creator, an entitlement Billing
+ * cannot confirm ("unknown"), a missing perk or any error → allow: false with the reason. Rules
+ * marked sensitive always ask Billing directly (never the projection).
+ */
+const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../util');
+
+function createPolicies({ db, now, creators, plans, perks, memberships, entitlements }) {
+ const byId = (id) => db.prepare('SELECT * FROM vip_gated_resource_rules WHERE id = ?').get(id) || null;
+ const forResource = (r) => db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND status = 'active'`)
+ .get(r.service, r.type, r.id) || null;
+
+ /** Create or replace the active rule for a resource. */
+ function set({ creatorId, resource, requirement = 'member', planId = null, perkKey = null, sensitive = false, actor = null }) {
+ const creator = creators.byId(creatorId);
+ if (!creator || creator.kind !== 'creator') fail(404, 'vip.creator_not_found', 'gated resources belong to a creator');
+ const r = entityRef(resource);
+ if (!['member', 'plan', 'perk'].includes(requirement)) fail(422, 'vip.invalid_input', 'requirement must be member, plan or perk');
+ if (requirement === 'plan') {
+ const p = plans.byId(String(planId || ''));
+ if (!p || p.creator_id !== creatorId) fail(422, 'vip.plan_not_found', 'plan_id must be one of this creator\'s plans');
+ } else planId = null;
+ if (requirement === 'perk') {
+ perkKey = text(perkKey, 'perk_key', 48, { required: true });
+ if (!perks.byKey(creatorId, perkKey) && !perks.byKey('network', perkKey)) fail(422, 'vip.perk_not_found', `no perk ${perkKey} for this creator`);
+ } else perkKey = null;
+ const existing = forResource(r);
+ if (existing && existing.creator_id !== creatorId) fail(409, 'vip.rule_conflict', 'another creator already gates this resource');
+ const at = iso(now());
+ const id = prefixedId('vgr', now());
+ db.transaction(() => {
+ if (existing) db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(at, existing.id);
+ db.prepare(`INSERT INTO vip_gated_resource_rules (id, creator_id, resource_service, resource_type, resource_id, requirement, plan_id, perk_key, sensitive, status, created_by, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creatorId, r.service, r.type, r.id, requirement, planId, perkKey, sensitive ? 1 : 0, actor, at, at);
+ })();
+ return byId(id);
+ }
+
+ function disable(id) {
+ const rule = byId(id);
+ if (!rule) fail(404, 'vip.rule_not_found', `no rule ${id}`);
+ db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(iso(now()), id);
+ return byId(id);
+ }
+
+ const list = (creatorId) => db.prepare("SELECT * FROM vip_gated_resource_rules WHERE creator_id = ? AND status = 'active' ORDER BY created_at DESC").all(creatorId);
+
+ function present(rule) {
+ if (!rule) return null;
+ const c = creators.byId(rule.creator_id);
+ return {
+ id: rule.id, creator: c && c.subject ? { type: 'user', id: c.subject } : null, creator_id: rule.creator_id,
+ resource: { service: rule.resource_service, type: rule.resource_type, id: rule.resource_id },
+ requirement: rule.requirement, plan_id: rule.plan_id || null, perk_key: rule.perk_key || null,
+ sensitive: !!rule.sensitive, status: rule.status, updated_at: rule.updated_at,
+ };
+ }
+
+ const deny = (reason, extra = {}) => ({ allow: false, reason, ...extra });
+
+ /**
+ * May `subject` see `resource`? { allow, reason, rule, entitlement }. `ruleId` pins the rule the
+ * product believes applies (a mismatch denies). mode: 'auto' (default) or 'authoritative'.
+ */
+ async function evaluate({ subject, resource, ruleId = null, mode = 'auto', traceparent }) {
+ let r;
+ try { r = entityRef(resource); } catch { return deny('invalid_resource'); }
+ const rule = ruleId ? byId(String(ruleId)) : forResource(r);
+ if (!rule) return deny('no_rule');
+ if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: present(rule) });
+ if (rule.status !== 'active') return deny('rule_disabled', { rule: present(rule) });
+ const creator = creators.byId(rule.creator_id);
+ if (!creator || creator.status !== 'active' || !creator.subject) return deny('creator_unavailable', { rule: present(rule) });
+ if (!subject) return deny('not_signed_in', { rule: present(rule) });
+ if (!isUserSubject(subject)) return deny('not_a_member', { rule: present(rule) });
+ // The creator always sees their own gated resources.
+ if (subject === creator.subject) return { allow: true, reason: 'owner', rule: present(rule), entitlement: null };
+ let ent;
+ try {
+ ent = await entitlements.check(subject, creator.subject, { mode: rule.sensitive ? 'authoritative' : (mode === 'authoritative' ? 'authoritative' : 'auto'), traceparent });
+ } catch { return deny('entitlement_unknown', { rule: present(rule) }); }
+ if (ent.status === 'unknown') return deny('entitlement_unknown', { rule: present(rule), entitlement: ent });
+ if (!ent.active) return deny('not_a_member', { rule: present(rule), entitlement: ent });
+ const m = memberships.get(subject, creator.id);
+ if (rule.requirement === 'plan' && (!m || m.plan_id !== rule.plan_id)) return deny('plan_required', { rule: present(rule), entitlement: ent });
+ if (rule.requirement === 'perk' && !memberships.perkKeys(m).includes(rule.perk_key)) return deny('perk_missing', { rule: present(rule), entitlement: ent });
+ return { allow: true, reason: 'member', rule: present(rule), entitlement: ent };
+ }
+
+ return { byId, forResource, set, disable, list, present, evaluate };
+}
+
+module.exports = { createPolicies };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+
+/**
+ * Billing → VIP: POST /internal/events, the endpoint of VIP's OpenVibe.Events subscriptions
+ * (scripts/subscribe.js): billing.entitlement.*, billing.subscription.*, billing.transaction.reversed.
+ *
+ * billing.entitlement.changed the projection takes Billing's { active, expires_at, subscription }
+ * (granted, renewed, canceled, expired, refund, chargeback, …)
+ * billing.subscription.canceled cancel at period end: the flag changes, the period still runs
+ * billing.transaction.reversed a reversal that revoked subscription periods puts the pair's
+ * projection in doubt at once (the next check asks Billing), even
+ * before — or without — the matching entitlement event
+ *
+ * Exactly once: the openvibe-sdk inbox claims (consumer, event_id) in the same SQLite transaction as
+ * the change. Order: rows remember the Billing time they reflect; see domain/entitlements.js.
+ * The signature (X-OpenVibe-Signature, HMAC-SHA256 of the raw body with VIP_EVENTS_SECRET) is
+ * verified with openvibe-sdk's parseDelivery. Only events whose source is `billing` are applied.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { parseDelivery, createInbox } = require('openvibe-sdk/events');
+const { isUserSubject } = require('../util');
+
+const CONSUMER = 'vip-billing';
+
+function consumerRouter({ domain, config, log = console }) {
+ const router = express.Router();
+ const inbox = createInbox(domain.db, { now: domain.now });
+ inbox.ensureSchema();
+ const { entitlements } = domain;
+
+ function handle(event) {
+ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
+ const asOf = Date.parse(event.timestamp);
+ if (!Number.isFinite(asOf)) return 'ignored:bad_timestamp';
+ if (event.event_type === 'billing.entitlement.changed') {
+ const member = p.subject && p.subject.id;
+ const creator = p.streamer && p.streamer.id;
+ if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
+ if (p.kind && p.kind !== entitlements.KIND) return 'ignored:kind';
+ const sub = p.subscription || null;
+ return entitlements.apply({
+ member, creator, active: !!p.active, expiresAt: p.expires_at || null,
+ cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
+ subscriptionStatus: sub ? sub.status : undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,
+ }).outcome;
+ }
+ if (event.event_type === 'billing.subscription.canceled') {
+ const s = p.subscription || {};
+ const member = s.subscriber && s.subscriber.id;
+ const creator = s.streamer && s.streamer.id;
+ if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
+ return entitlements.applyCanceled({
+ member, creator, subscriptionId: s.id || null, subscriptionStatus: s.status || null,
+ currentPeriodEnd: s.current_period_end || null, eventId: event.event_id, asOf,
+ }).outcome;
+ }
+ if (event.event_type === 'billing.transaction.reversed') {
+ if (!(Number(p.entitlements_revoked) > 0)) return 'ignored:no_entitlement';
+ // The reversal runs streamer → subscriber (from/to of the original swapped).
+ const member = p.to_subject;
+ const creator = p.from_subject;
+ if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
+ return entitlements.doubt(member, creator) ? 'doubt' : 'no_projection';
+ }
+ return 'ignored:type';
+ }
+
+ /** Apply one envelope (also used by tests). Returns { duplicate, outcome }. */
+ function apply(event) {
+ const r = inbox.once(CONSUMER, event.event_id, () => (event.source !== 'billing' ? 'ignored:source' : handle(event)));
+ return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
+ }
+
+ router.post('/events', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
+ const secrets = config.events.webhookSecrets;
+ if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });
+ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
+ let delivery = null;
+ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }
+ if (!delivery) return http.sendProblem(res, 401, 'vip.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
+ const event = delivery.event;
+ if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
+ return http.sendProblem(res, 400, 'vip.bad_delivery', { detail: 'body must be { event: <envelope>, seq }', ctx: req.ov });
+ }
+ let out;
+ try {
+ out = apply(event);
+ } catch (e) {
+ // Not acknowledged: Events retries it, and the inbox claim rolled back with the change.
+ log.error(`[VIP] event ${event.event_id} (${event.event_type}) failed:`, e.message);
+ return http.sendProblem(res, 500, 'vip.event_failed', { detail: 'processing failed; it will be retried', ctx: req.ov });
+ }
+ res.status(200).json({ event_id: event.event_id, duplicate: out.duplicate, outcome: out.outcome });
+ });
+
+ return { router, apply, CONSUMER };
+}
+
+module.exports = { consumerRouter, CONSUMER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+
+/**
+ * VIP → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ *
+ * vip.plan.published a plan version became what new members buy (publish, or an edit of a
+ * published plan); payload carries the version's full terms
+ * vip.membership.changed a member's standing with a creator changed, as projected from Billing
+ * (granted, renewed, cancel scheduled, expired, refunded, …); payload
+ * names the plan version the membership was bought under
+ *
+ * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only
+ * if its change committed. Envelopes are validated against events.event-envelope@1 first. The relay
+ * publishes with VIP's service token (events.event.publish, audience openvibe.events) only when
+ * EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise rows wait in event_outbox.
+ */
+const { validate } = require('openvibe-contracts');
+const { createClient } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+
+const ACTOR = { type: 'service', id: 'vip' };
+
+function createVipOutbox({ db, config, fetchImpl, now = () => Date.now(), log = console }) {
+ const enabled = !!(config.events.url && config.oauth.clientSecret);
+ const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };
+ if (fetchImpl) clientOpts.fetch = fetchImpl;
+ if (enabled) {
+ clientOpts.tokenProvider = createServiceTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),
+ });
+ } else {
+ clientOpts.getToken = async () => { throw new Error('events relay disabled (EVENTS_URL / OV_OAUTH_CLIENT_SECRET unset)'); };
+ }
+ const events = createEventsClient(createClient(clientOpts), { source: 'vip' });
+ let lastError = null;
+ const outbox = createOutbox(db, {
+ events,
+ intervalMs: config.events.intervalMs,
+ now,
+ onError: (err) => {
+ const msg = err && err.message;
+ if (msg !== lastError) log.warn('[VIP] event publish failed (will retry):', msg);
+ lastError = msg;
+ },
+ });
+ outbox.ensureSchema();
+
+ /** Inside the caller's transaction. */
+ function emit(eventType, subject, payload, { visibility = 'internal', priority = 'important', traceparent } = {}) {
+ const env = events.prepare({ event_type: eventType, actor: ACTOR, subject, payload, visibility, priority }, { now: now() });
+ const v = validate('events.event-envelope@1', env);
+ if (!v.valid) throw new Error(`outbox: invalid envelope for ${eventType}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
+ return outbox.enqueue(env, { traceparent });
+ }
+
+ return {
+ emit,
+ outbox,
+ enabled,
+ start() { if (enabled) outbox.start(); },
+ stop: () => outbox.stop(),
+ kick() { if (enabled) outbox.kick(); },
+ status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
+ };
+}
+
+module.exports = { createVipOutbox };
new file mode 100644
new file mode 100644
@@ -0,0 +1,270 @@
@@ -0,0 +1,270 @@
+'use strict';
+
+/**
+ * Import OpenVibe.Live's subscription OFFERING into VIP, from a SNAPSHOT COPY of Live's database
+ * (opened read-only). Memberships come from OpenVibe.Billing's entitlements (Billing already
+ * imported Live's subscriptions, ADR-012), never from Live's rows.
+ *
+ * What Live offers today: every channel can be subscribed to, monthly, at the site-wide price in
+ * site_settings (sub_price_usd, now Billing's rate), and a subscriber gets a star "Subscriber" badge
+ * (Live chat, the PowerChat overlay relay, the stream AI's context flag). So:
+ *
+ * 1. perk one network perk `subscriber-badge` with product bindings for those three places
+ * and OpenVibe.Chat's badge
+ * 2. creators every Live user who is a streamer (role streamer / global_mod / admin) or has ever
+ * been subscribed to → a VIP creator for their Network subject (resolve-batch). A Live
+ * user with no subject yet is HELD (vip_migration_maps), never dropped; a later run
+ * picks it up
+ * 3. plans per creator, plan `channel-subscription` v1 "Channel subscription" (published, sold
+ * as Billing's channel_subscription) with the badge perk — unless the creator already
+ * has a published plan sold that way (then that plan is linked, nothing is created)
+ * 4. members per creator, Billing's subscriptions (billing.entitlement.check): each ACTIVE one
+ * becomes a VIP membership under the plan version above and a projection row from
+ * Billing's entitlement; inactive ones are recorded excluded with the reason
+ * 5. live rows every row of Live's `subscriptions` table is reconciled: linked to the VIP
+ * membership / Billing subscription for the same pair, or held with the reason
+ *
+ * Idempotent: every source row is keyed in vip_migration_maps; a re-run creates nothing twice,
+ * never edits a plan a creator has changed since, and releases what was held when it can.
+ * --dry-run does everything inside a transaction that is rolled back.
+ */
+const { iso } = require('../util');
+
+const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
+const columns = (db, t) => (tableExists(db, t) ? db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name) : []);
+const CREATOR_ROLES = ['streamer', 'global_mod', 'admin'];
+const PLAN_SLUG = 'channel-subscription';
+const PERK_KEY = 'subscriber-badge';
+const ACTOR = 'svc:vip-import';
+
+class DryRun extends Error {}
+
+/** Billing through its API (VIP's service token, billing.entitlement.check). */
+function billingApiSource(billing) {
+ return {
+ name: 'billing api',
+ listSubscriptions: async (creator) => ((await billing.listSubscriptions({ streamer: creator })).subscriptions || []),
+ entitlement: (member, creator) => billing.entitlement(member, creator),
+ };
+}
+
+/** Billing from a read-only SNAPSHOT of billing.db (operator tool, for dry runs without a token). */
+function billingSnapshotSource(bdb, { now = () => Date.now() } = {}) {
+ const subs = bdb.prepare('SELECT * FROM subscriptions WHERE streamer = ? ORDER BY created_at');
+ return {
+ name: 'billing snapshot',
+ listSubscriptions: async (creator) => subs.all(creator).map((s) => ({
+ id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, status: s.status,
+ auto_renew: !!s.auto_renew, cancel_at_period_end: !!s.cancel_at_period_end, current_period_end: s.current_period_end, legacy_live_id: s.legacy_live_id ?? null,
+ })),
+ entitlement: async (member, creator) => {
+ const at = iso(now());
+ const rows = bdb.prepare(`SELECT starts_at, ends_at FROM entitlements WHERE subject = ? AND kind = 'channel_subscription' AND scope = ?
+ AND revoked_at IS NULL AND ends_at > ? ORDER BY starts_at`).all(member, creator, at);
+ const active = rows.some((r) => r.starts_at <= at);
+ const sub = bdb.prepare('SELECT * FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(member, creator);
+ return {
+ active, expires_at: active ? rows.reduce((m, r) => (r.ends_at > m ? r.ends_at : m), '') : null,
+ subscription: sub ? { id: sub.id, status: sub.status, auto_renew: !!sub.auto_renew, cancel_at_period_end: !!sub.cancel_at_period_end } : null,
+ };
+ },
+ };
+}
+
+async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRun = false, log = console }) {
+ const { db, creators, perks, plans, entitlements } = domain;
+ const started = domain.now();
+ const runId = `vimp_${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,
+ live_offering: null,
+ counts: {
+ creators: { imported: 0, unchanged: 0, held: 0, released: 0 }, plans: { created: 0, linked: 0, unchanged: 0 },
+ memberships: { imported: 0, unchanged: 0, excluded: 0 }, live_subscriptions: { linked: 0, held: 0, unchanged: 0 },
+ },
+ holds: [], excluded: [], billing_errors: [],
+ };
+
+ // ── Read the snapshot ────────────────────────────────────
+ const hasUsers = tableExists(live, 'users');
+ const userCols = columns(live, 'users');
+ const users = hasUsers ? live.prepare(`SELECT id, username, display_name${userCols.includes('role') ? ', role' : ", 'user' AS role"} FROM users`).all() : [];
+ const byLiveId = new Map(users.map((u) => [String(u.id), u]));
+ const subCols = columns(live, 'subscriptions');
+ const liveSubs = subCols.length ? live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];
+ if (tableExists(live, 'site_settings')) {
+ const get = (k) => { const r = live.prepare('SELECT value FROM site_settings WHERE key = ?').get(k); return r ? r.value : null; };
+ report.live_offering = {
+ sub_price_usd: get('sub_price_usd'), sub_streamer_share_pct: get('sub_streamer_share_pct'), sub_site_route_fee_pct: get('sub_site_route_fee_pct'),
+ note: 'recorded for reference only: OpenVibe.Billing prices and charges subscriptions; VIP plan terms carry no price',
+ };
+ }
+ const creatorIds = new Set(users.filter((u) => CREATOR_ROLES.includes(u.role)).map((u) => String(u.id)));
+ for (const s of liveSubs) if (s.streamer_id != null) creatorIds.add(String(s.streamer_id));
+ const allIds = new Set(creatorIds);
+ for (const s of liveSubs) if (s.subscriber_id != null) allIds.add(String(s.subscriber_id));
+ report.counts.live = { users: users.length, creators: creatorIds.size, subscriptions: liveSubs.length };
+
+ // ── Identities ───────────────────────────────────────────
+ const map = allIds.size ? await resolveLiveUsers([...allIds]) : new Map();
+ const subjectOf = (id) => { const v = id == null ? null : map.get(String(id)); return v ? (typeof v === 'string' ? v : v.subject) : null; };
+ const handleOf = (id) => { const v = map.get(String(id)); const u = byLiveId.get(String(id)); return (v && typeof v === 'object' && v.username) || (u && u.username) || null; };
+
+ // ── Billing (async, before the transaction) ──────────────
+ const billingSubs = new Map(); // creator subject → [subscription]
+ const ents = new Map(); // `${member}:${creator}` → entitlement
+ for (const id of creatorIds) {
+ const s = subjectOf(id);
+ if (!s || billingSubs.has(s)) continue;
+ if (!billingSource) { billingSubs.set(s, null); continue; }
+ try {
+ const list = await billingSource.listSubscriptions(s);
+ billingSubs.set(s, list);
+ for (const sub of list) {
+ if (sub.status !== 'active') continue;
+ ents.set(`${sub.subscriber.id}:${s}`, await billingSource.entitlement(sub.subscriber.id, s));
+ }
+ } catch (e) {
+ billingSubs.set(s, null);
+ report.billing_errors.push({ creator: s, error: e.message });
+ }
+ }
+
+ const mapRow = db.prepare('SELECT * FROM vip_migration_maps WHERE source = ? AND source_table = ? AND source_id = ?');
+ const upsertMap = db.prepare(`INSERT INTO vip_migration_maps (source, source_table, source_id, target_type, target_id, status, reason, run_id, created_at, updated_at)
+ VALUES (@source, @table, @id, @target_type, @target_id, @status, @reason, @run, @at, @at)
+ ON CONFLICT (source, source_table, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id, status = excluded.status,
+ reason = excluded.reason, run_id = excluded.run_id, updated_at = excluded.updated_at`);
+ const record = (source, table, id, fields) => upsertMap.run({ source, table, id: String(id), target_type: null, target_id: null, reason: null, run: runId, at, ...fields });
+
+ const run = () => {
+ // ── 1. The network perk ──────────────────────────────
+ let perk = perks.byKey('network', PERK_KEY);
+ if (!perk) {
+ perk = perks.create({
+ creatorId: 'network', key: PERK_KEY, name: 'Subscriber badge', kind: 'badge', actor: ACTOR,
+ description: 'A star badge next to your name in the creator\'s chat, as channel subscribers had on OpenVibe.Live.',
+ bindings: [
+ { product: 'live', binding: 'chat_badge', config: { badge: 'subscriber' } },
+ { product: 'live', binding: 'powerchat_overlay', config: { is_subscriber: true } },
+ { product: 'live', binding: 'ai_context', config: { flag: 'subscriber' } },
+ { product: 'chat', binding: 'badge', config: { badge: 'subscriber' } },
+ ],
+ });
+ record('live', 'offering', 'subscriber_badge', { target_type: 'perk', target_id: perk.id, status: 'imported' });
+ }
+
+ // ── 2 + 3. Creators and their plan ───────────────────
+ for (const id of [...creatorIds].sort((a, b) => Number(a) - Number(b))) {
+ const prev = mapRow.get('live', 'users', id);
+ const subject = subjectOf(id);
+ const u = byLiveId.get(id);
+ if (!subject) {
+ const reason = `Live user ${id}${u ? ` (${u.username})` : ''} has no Network subject yet`;
+ record('live', 'users', id, { status: 'held', reason });
+ report.counts.creators.held++; report.holds.push({ live_user: Number(id), reason });
+ continue;
+ }
+ const existed = creators.bySubject(subject);
+ const c = creators.ensure({ subject, username: handleOf(id), displayName: u ? u.display_name : null, origin: existed ? existed.origin : 'import' });
+ if (prev && prev.status !== 'held') report.counts.creators.unchanged++;
+ else { report.counts.creators[prev ? 'released' : 'imported']++; }
+ record('live', 'users', id, { target_type: 'creator', target_id: c.id, status: 'imported', reason: prev && prev.status === 'held' ? 'released from hold' : null });
+
+ const offer = mapRow.get('live', 'offering', `channel_subscription:${id}`);
+ if (offer && offer.target_id && plans.byId(offer.target_id)) { report.counts.plans.unchanged++; continue; }
+ const own = plans.purchasable(c.id);
+ if (own) {
+ record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: own.id, status: 'linked', reason: 'the creator already publishes a plan sold as the channel subscription' });
+ report.counts.plans.linked++;
+ continue;
+ }
+ const existingSlug = db.prepare('SELECT * FROM vip_plans WHERE creator_id = ? AND slug = ?').get(c.id, PLAN_SLUG);
+ if (existingSlug) {
+ record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: existingSlug.id, status: 'linked', reason: `plan ${PLAN_SLUG} already exists (status ${existingSlug.status})` });
+ report.counts.plans.linked++;
+ continue;
+ }
+ const name = c.display_name || c.username || 'this creator';
+ const plan = plans.create({
+ creatorId: c.id, slug: PLAN_SLUG, name: 'Channel subscription',
+ description: `A monthly subscription to ${name}'s channel, carried over from OpenVibe.Live.`,
+ benefits: [`A subscriber badge next to your name in ${name}'s chat`],
+ perks: [PERK_KEY], billingKind: 'channel_subscription', publish: true, changeNote: 'imported from OpenVibe.Live', actor: ACTOR,
+ });
+ record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: plan.id, status: 'imported' });
+ report.counts.plans.created++;
+ }
+
+ // ── 4. Memberships from Billing ──────────────────────
+ for (const [creatorSubject, list] of billingSubs) {
+ if (!list) continue;
+ for (const sub of list) {
+ const member = sub.subscriber && sub.subscriber.id;
+ const prev = mapRow.get('billing', 'subscriptions', sub.id);
+ if (sub.status !== 'active') {
+ if (!prev) {
+ const reason = `Billing subscription is ${sub.status}: no current membership to file terms for`;
+ record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });
+ report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });
+ } else report.counts.memberships.unchanged++;
+ continue;
+ }
+ const e = ents.get(`${member}:${creatorSubject}`) || { active: false, expires_at: null };
+ const out = entitlements.apply({
+ member, creator: creatorSubject, active: !!e.active, expiresAt: e.expires_at || null,
+ cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id, subscriptionStatus: sub.status,
+ reason: 'imported', source: 'import', origin: 'import', asOf: domain.now(),
+ });
+ const m = out.membership;
+ if (!e.active || !m) {
+ const reason = 'Billing lists the subscription as active but its entitlement is not active now';
+ record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });
+ report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });
+ continue;
+ }
+ if (prev && prev.status === 'imported' && prev.target_id === m.id) report.counts.memberships.unchanged++;
+ else report.counts.memberships.imported++;
+ record('billing', 'subscriptions', sub.id, { target_type: 'membership', target_id: m.id, status: 'imported' });
+ }
+ }
+
+ // ── 5. Live's subscriptions rows ─────────────────────
+ const membershipFor = db.prepare('SELECT m.id FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?');
+ for (const s of liveSubs) {
+ const prev = mapRow.get('live', 'subscriptions', String(s.id));
+ const member = subjectOf(s.subscriber_id);
+ const creator = subjectOf(s.streamer_id);
+ let fields;
+ if (!member || !creator) {
+ fields = { status: 'held', reason: `${!member ? `subscriber (Live user ${s.subscriber_id})` : `creator (Live user ${s.streamer_id})`} has no Network subject yet` };
+ } else {
+ const m = membershipFor.get(member, creator);
+ const bsub = (billingSubs.get(creator) || []).find((x) => x.subscriber && x.subscriber.id === member);
+ if (m) fields = { status: 'linked', target_type: 'membership', target_id: m.id };
+ else if (bsub) fields = { status: 'linked', target_type: 'billing_subscription', target_id: bsub.id, reason: `Billing holds it (status ${bsub.status}); no current membership` };
+ else if (billingSubs.get(creator) === null) fields = { status: 'held', reason: 'Billing was not reachable for this creator in this run' };
+ else fields = { status: 'held', reason: 'Billing has no subscription for this pair (run Billing\'s Live import first)' };
+ }
+ if (prev && prev.status === fields.status && prev.target_id === (fields.target_id || null)) { report.counts.live_subscriptions.unchanged++; continue; }
+ record('live', 'subscriptions', s.id, fields);
+ report.counts.live_subscriptions[fields.status === 'held' ? 'held' : 'linked']++;
+ if (fields.status === 'held') report.holds.push({ live_subscription: s.id, reason: fields.reason });
+ }
+ if (dryRun) throw new DryRun();
+ };
+
+ try {
+ db.transaction(run)();
+ } catch (e) {
+ if (!(e instanceof DryRun)) throw e;
+ }
+ report.finished_at = iso(domain.now());
+ report.ok = report.billing_errors.length === 0;
+ if (!billingSource) report.note = 'no Billing source: memberships were not imported';
+ if (log && log.debug) log.debug(report);
+ return report;
+}
+
+module.exports = { importLive, billingApiSource, billingSnapshotSource, PLAN_SLUG, PERK_KEY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+
+/**
+ * OpenVibe.VIP — process entry. `node server/index.js`
+ * Listens on PORT (4620) behind nginx (openvibe.vip); see deploy/.
+ *
+ * Background jobs (VIP_JOBS=off disables them): re-confirming with Billing the projections products
+ * are using before they go stale, pruning sent outbox rows, and the events outbox relay when
+ * EVENTS_URL is set.
+ */
+const { loadConfig } = require('./config');
+const { createApp } = require('./app');
+
+const config = loadConfig();
+if (config.isProduction && !config.formSecret) {
+ console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');
+ process.exit(1);
+}
+const app = createApp({ config });
+const { domain, keys, outbox, metrics } = app.locals;
+keys.start();
+
+const timers = [];
+if (config.jobs.enabled) {
+ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };
+ every(config.jobs.refreshIntervalMs, () => domain.entitlements.refreshDue());
+ every(6 * 3600 * 1000, () => outbox.outbox.prune());
+ outbox.start();
+}
+
+const server = app.listen(config.port, config.host, () => {
+ console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);
+});
+server.keepAliveTimeout = 65_000;
+
+function shutdown(signal) {
+ console.log(`[VIP] ${signal} — closing`);
+ timers.forEach(clearInterval);
+ outbox.stop();
+ keys.stop();
+ if (metrics && metrics.stop) metrics.stop();
+ server.close(() => { try { domain.db.close(); } catch { /* */ } process.exit(0); });
+ setTimeout(() => process.exit(0), 5000).unref();
+}
+process.on('SIGTERM', () => shutdown('SIGTERM'));
+process.on('SIGINT', () => shutdown('SIGINT'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+
+/**
+ * OpenVibe.Network client (the same shape as OpenVibe.Tips and OpenVibe.Community).
+ *
+ * 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.vip) and the browser's Network user JWT, both offline.
+ * identity Live user ids → canonical subjects via POST /internal/identity/resolve-batch
+ * (capability identity.subject.resolve, audience openvibe.network) — the importer only.
+ */
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+const jwt = require('jsonwebtoken');
+const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+
+const BATCH = 500;
+
+function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
+ let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
+ let timer = null;
+ async function load() {
+ const url = `${config.network.internalUrl}/api/.well-known/jwks`;
+ try {
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) throw new Error(`JWKS ${res.status}`);
+ const body = await res.json();
+ const jwk = (body.keys || []).find((k) => k.kty === 'RSA');
+ if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
+ else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });
+ else throw new Error('JWKS contained no keys');
+ return pem;
+ } catch (e) {
+ log.warn(`[VIP] Network key not loaded from ${url}: ${e.message}`);
+ return null;
+ }
+ }
+ function start() {
+ if (config.network.publicKey || timer) return;
+ const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
+ retry();
+ timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);
+ timer.unref();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+ return { get: () => pem, load, start, stop };
+}
+
+/**
+ * The OAuth client of the browser session layer plus offline verification of the Network user
+ * JWT (RS256, issuer = the Network's public URL).
+ */
+function createUserAuth(config, keys) {
+ const client = new OpenVibeAuthClient({
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: config.oauth.redirectUri,
+ publicKey: null,
+ authBase: config.network.url,
+ internalBase: config.network.internalUrl,
+ });
+ /** Claims of a valid user token, or null. Service tokens are never user tokens. */
+ function verify(token) {
+ if (!token) return null;
+ const key = keys.get();
+ if (!key) return null;
+ let claims;
+ try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }
+ if (!claims || typeof claims !== 'object') return null;
+ if (typeof claims.sub === 'string' && /^(svc|app|mod):/.test(claims.sub)) return null;
+ if (claims.actor_type === 'service') return null;
+ return claims;
+ }
+ return { client, verify };
+}
+
+function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
+ const base = config.network.internalUrl;
+ let tokens = null;
+ const tokenClient = () => {
+ if (!tokens) {
+ tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,
+ });
+ }
+ return tokens;
+ };
+
+ async function post(body, retried = false) {
+ const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokenClient().authHeaders()) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(10000),
+ });
+ if (res.status === 401 && !retried) { tokenClient().invalidate(); return post(body, true); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
+ return data.results;
+ }
+
+ /** Live user ids → Map(String(id) → { subject, username } | null). */
+ async function resolveLiveUsers(liveIds) {
+ const out = new Map();
+ const all = [...new Set(liveIds.map(String))];
+ for (let i = 0; i < all.length; i += BATCH) {
+ const chunk = all.slice(i, i + BATCH);
+ const results = await post({ system: 'live', type: 'user', ids: chunk });
+ for (const k of chunk) {
+ const p = results[k];
+ out.set(k, p && p.subject && p.subject.type === 'user' ? { subject: p.subject.id, username: p.username || null } : null);
+ }
+ }
+ return out;
+ }
+ return { resolveLiveUsers };
+}
+
+module.exports = { createKeyProvider, createUserAuth, createIdentity };
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+/**
+ * Track O: truthful readiness for GET /api/ready (openvibe-shared/ready).
+ *
+ * db required a real query on VIP's SQLite (the schema is there and answers)
+ * network_jwks optional the Network signing key has loaded. Without it public plan pages still
+ * render, but no service or user token can be verified (API calls and
+ * sign-in answer 503), so it degrades rather than fails
+ * billing optional Billing answers /api/health. Without it the projection still answers
+ * while fresh; checkout, cancel, members lists and authoritative checks
+ * fail closed (never "yes")
+ *
+ * `details` reports the outbox and the projection's size and staleness (counts only, no subjects).
+ * Request metrics come from openvibe-shared/metrics in app.js; gauges for the outbox and projection
+ * are registered there too.
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+
+function createVipReadiness({ db, keys, config, outbox, now, release = null, fetchImpl = globalThis.fetch }) {
+ return createReadiness({
+ service: 'vip',
+ release,
+ checks: [
+ { name: 'db', required: true, check: () => db.prepare('SELECT COUNT(*) AS n FROM vip_creators').get().n >= 1 || 'database has no network creator row' },
+ {
+ name: 'network_jwks', required: false,
+ check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified'),
+ },
+ {
+ name: 'billing', required: false, cacheMs: 15_000, timeoutMs: 2500,
+ check: async () => {
+ const res = await fetchImpl(`${config.billing.url}/api/health`, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });
+ try { await res.body?.cancel(); } catch { /* not needed */ }
+ return res.ok ? { ok: true, detail: { http_status: res.status } } : { ok: false, error: `answered HTTP ${res.status}` };
+ },
+ },
+ ],
+ details: (body) => {
+ if (body.checks.db.status !== 'ok') return {};
+ const t = now();
+ const p = db.prepare('SELECT COUNT(*) AS n, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t);
+ return {
+ events: outbox.status(),
+ billing_events_accepted: config.events.webhookSecrets.length > 0,
+ projection: { rows: p.n || 0, past_valid_until: p.stale || 0, max_age_ms: config.projection.maxAgeMs, grace_ms: config.projection.graceMs },
+ };
+ },
+ });
+}
+
+module.exports = { createVipReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+
+/** Small shared pieces: ids, time, the error type, input checks. */
+const { ids, validate } = require('openvibe-contracts');
+
+class VipError extends Error {
+ constructor(status, code, detail, extra) {
+ super(detail || code);
+ this.status = status;
+ this.code = code;
+ this.detail = detail;
+ this.extra = extra;
+ }
+}
+
+function fail(status, code, detail, extra) { throw new VipError(status, code, detail, extra); }
+
+const prefixedId = (prefix, ms = Date.now()) => `${prefix}_${ids.ulid(ms)}`;
+const iso = (ms) => new Date(ms).toISOString();
+const json = (v, d) => { if (v == null || v === '') return d; try { return JSON.parse(v); } catch { return d; } };
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+/** Optional trimmed text: null when empty, refused when longer than max. */
+function text(v, field, max, { required = false } = {}) {
+ if (v == null || v === '') { if (required) fail(422, 'vip.invalid_input', `${field} is required`); return null; }
+ const s = String(v).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim();
+ if (!s) { if (required) fail(422, 'vip.invalid_input', `${field} is required`); return null; }
+ if (s.length > max) fail(422, 'vip.text_too_long', `${field} must be at most ${max} characters`);
+ return s;
+}
+
+const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;
+function slug(v, field = 'slug') {
+ const s = String(v || '').trim().toLowerCase();
+ if (!SLUG_RE.test(s)) fail(422, 'vip.invalid_input', `${field} must be 1-48 of a-z, 0-9 and -`);
+ return s;
+}
+function slugify(v) {
+ const s = String(v || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
+ return s || 'plan';
+}
+
+/** A user SubjectRef or bare usr_ id → the subject id. */
+function userSubject(v, field = 'subject') {
+ const ref = typeof v === 'string' ? { type: 'user', id: v } : v;
+ if (!ref || !validate('identity.subject-ref@1', ref).valid || ref.type !== 'user' || !ids.isSubjectId('user', ref.id)) {
+ fail(422, 'vip.invalid_subject', `${field} must be a user SubjectRef ({ type: 'user', id: 'usr_…' })`);
+ }
+ return ref.id;
+}
+const isUserSubject = (s) => typeof s === 'string' && ids.isSubjectId('user', s);
+const userRef = (id) => (id ? { type: 'user', id } : null);
+
+/** An EntityRef { service, type, id } (common.entity-ref@1). */
+function entityRef(v, field = 'resource') {
+ if (!v || typeof v !== 'object' || !validate('common.entity-ref@1', v).valid) {
+ fail(422, 'vip.invalid_input', `${field} must be an EntityRef ({ service, type, id })`);
+ }
+ return { service: String(v.service), type: String(v.type), id: String(v.id) };
+}
+
+function bool(v, d = false) {
+ if (v == null || v === '') return d;
+ if (typeof v === 'boolean') return v;
+ return ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase());
+}
+
+module.exports = { VipError, fail, prefixedId, iso, json, esc, text, slug, slugify, userSubject, isUserSubject, userRef, entityRef, bool };
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+
+/**
+ * Anti-forgery tokens for the server-rendered forms: HMAC(VIP_FORM_SECRET, subject | hour), valid
+ * for the current and the previous hour. The session cookies are SameSite=Lax too, so a cross-site
+ * POST does not carry them; the token is the second lock.
+ */
+const crypto = require('crypto');
+
+function createForms({ secret, now = () => Date.now() }) {
+ const sign = (subject, bucket) => crypto.createHmac('sha256', String(secret)).update(`${subject}|${bucket}`).digest('base64url').slice(0, 32);
+ const bucket = () => Math.floor(now() / 3600_000);
+ function token(subject) { return secret && subject ? sign(subject, bucket()) : ''; }
+ function verify(subject, given) {
+ if (!secret || !subject || typeof given !== 'string' || given.length !== 32) return false;
+ const b = bucket();
+ return [b, b - 1].some((x) => {
+ const want = Buffer.from(sign(subject, x));
+ const got = Buffer.from(given);
+ return want.length === got.length && crypto.timingSafeEqual(want, got);
+ });
+ }
+ return { token, verify };
+}
+
+module.exports = { createForms };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+
+/**
+ * Page shell for every server-rendered page: full <head> SEO (title, description, canonical,
+ * robots, Open Graph), the shared OpenVibe chrome (inline critical canvas + app icon from
+ * openvibe-shared, navbar.js from the Network, the SSR footer and a <noscript> navigation), this
+ * site's stylesheet and its small progressive script. Everything is useful without JavaScript.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const appIcon = require('openvibe-shared/app-icon');
+const chrome = require('openvibe-shared/chrome-ssr');
+
+const SITE_NAME = 'OpenVibe.VIP';
+const NETWORK_URL = 'https://openvibe.network';
+const DEFAULT_DESCRIPTION = 'Memberships on the OpenVibe network: join a creator\'s plan, see the terms you joined under and the perks that come with it on every OpenVibe site.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+const _hashes = new Map();
+function asset(rel) {
+ if (!_hashes.has(rel)) {
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing in tests */ }
+ _hashes.set(rel, v);
+ }
+ return `/${rel}?v=${_hashes.get(rel)}`;
+}
+const assetVersion = (rel) => { asset(rel); return _hashes.get(rel); };
+
+const NAV_LINKS = [
+ { label: 'Memberships', href: '/me' },
+ { label: 'Dashboard', href: '/dashboard' },
+];
+
+function createLayout({ config, release }) {
+ const abs = (p) => (/^https?:\/\//i.test(p) ? p : `${config.baseUrl}${p.startsWith('/') ? '' : '/'}${p}`);
+
+ /**
+ * o: title, description, canonicalPath, robots ('index,follow' | 'noindex,nofollow'), body,
+ * active ('home'|'memberships'|'dashboard'|'creator'), ogImage, jsonLd (array), viewer
+ */
+ function page(o) {
+ const title = o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — memberships across OpenVibe`;
+ const description = (o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300);
+ const canonical = abs(o.canonicalPath || '/');
+ const robots = o.robots || 'index,follow';
+ const nav = {
+ service: 'vip', apiBase: NETWORK_URL,
+ links: NAV_LINKS.map((l) => ({ ...l, active: o.active === l.label.toLowerCase() })),
+ history: { type: 'page', title: o.title || SITE_NAME },
+ silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: `/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}`,
+ };
+ const jsonLd = (o.jsonLd || []).map((x) => `<script type="application/ld+json">${JSON.stringify(x).replace(/</g, '\\u003c')}</script>`).join('\n');
+ const who = o.viewer
+ ? `<span class="who">Signed in as <b>${esc(o.viewer.name || o.viewer.username || 'you')}</b> · <a href="/auth/logout?next=/">Sign out</a></span>`
+ : `<a class="who" href="/auth/login?next=${encodeURIComponent(o.canonicalPath || '/')}">Sign in</a>`;
+ return `<!DOCTYPE html>
+<html lang="en" data-page="${esc(o.active || 'page')}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(title)}</title>
+<meta name="description" content="${esc(description)}">
+<link rel="canonical" href="${esc(canonical)}">
+<meta name="robots" content="${esc(robots)}">
+<meta name="referrer" content="strict-origin-when-cross-origin">
+<meta property="og:site_name" content="${SITE_NAME}">
+<meta property="og:type" content="${o.ogType || 'website'}">
+<meta property="og:title" content="${esc(o.title || SITE_NAME)}">
+<meta property="og:description" content="${esc(description)}">
+<meta property="og:url" content="${esc(canonical)}">
+${o.ogImage ? `<meta property="og:image" content="${esc(o.ogImage)}">` : ''}
+<meta name="twitter:card" content="summary">
+${appIcon.headTags({ site: 'vip' })}
+${release ? release.metaTag() : ''}
+<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
+<link rel="stylesheet" href="${asset('css/vip.css')}">
+${jsonLd}
+<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
+<script src="${asset('js/vip.js')}" defer></script>
+</head>
+<body>
+<div id="navbar-mount"></div>
+${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: NAV_LINKS })}
+<header class="site-head"><a class="brand" href="/">${SITE_NAME}</a><nav>${NAV_LINKS.map((l) => `<a href="${l.href}"${o.active === l.label.toLowerCase() ? ' aria-current="page"' : ''}>${l.label}</a>`).join('')}</nav>${who}</header>
+<main id="main" class="page">
+${o.body || ''}
+</main>
+${chrome.footer({ service: 'vip', variant: 'full' })}
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeNavbar) { OpenVibeNavbar.init(window.__OV_PAGE.navbar); document.documentElement.classList.add('ov-has-navbar'); } } catch (e) { /* the SSR header stays */ }
+});
+</script>
+</body>
+</html>`;
+ }
+
+ return { page, abs };
+}
+
+module.exports = { createLayout, esc, asset, assetVersion, SITE_NAME, DEFAULT_DESCRIPTION };
new file mode 100644
new file mode 100644
@@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
+'use strict';
+
+/**
+ * Page bodies (HTML strings) for the server-rendered routes. Everything here works without
+ * JavaScript: forms post to the server, which redirects back with a notice.
+ *
+ * Copy rules: say what a plan includes and what Billing charges; never "free", "$0" or "no ads", and
+ * no begging copy.
+ */
+const { esc } = require('../util');
+
+const fmtDate = (s) => {
+ if (!s) return '';
+ const d = new Date(s);
+ return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
+};
+const money = (cents) => `$${(Number(cents) / 100).toFixed(2)}`;
+const csrf = (t) => `<input type="hidden" name="_csrf" value="${esc(t)}">`;
+
+function notice(q) {
+ if (!q) return '';
+ if (q.error) return `<p class="notice notice-error" role="alert">${esc(q.error)}</p>`;
+ if (q.ok) return `<p class="notice" role="status">${esc(q.ok)}</p>`;
+ return '';
+}
+
+function priceLine(rates) {
+ const sub = rates && rates.subscription;
+ if (!sub || !sub.price_cents) return '<p class="price muted">OpenVibe.Billing sets the price; it is shown at checkout.</p>';
+ return `<p class="price"><b>${money(sub.price_cents)}</b> every ${esc(sub.period_days)} days, charged and renewed by OpenVibe.Billing.</p>`;
+}
+
+function perkList(perks) {
+ if (!perks || !perks.length) return '';
+ return `<ul class="perks">${perks.map((p) => `<li><span class="perk-name">${esc(p.name)}</span></li>`).join('')}</ul>`;
+}
+function benefitList(benefits) {
+ if (!benefits || !benefits.length) return '';
+ return `<ul class="benefits">${benefits.map((b) => `<li>${esc(b)}</li>`).join('')}</ul>`;
+}
+
+function errorPage({ status, title, message }) {
+ return `<section class="card narrow"><p class="eyebrow">${esc(status)}</p><h1>${esc(title)}</h1><p>${esc(message)}</p><p><a href="/">OpenVibe.VIP home</a></p></section>`;
+}
+
+function home({ creators }) {
+ const list = creators.length
+ ? `<ul class="creator-list">${creators.map((c) => `<li><a href="/${encodeURIComponent(c.username)}"><b>${esc(c.display_name || c.username)}</b><span class="muted">@${esc(c.username)}</span></a></li>`).join('')}</ul>`
+ : '<p class="muted">No creator has published a plan here yet.</p>';
+ return `<section class="hero">
+<h1>Memberships across OpenVibe</h1>
+<p>Join a creator's plan once and your membership is recognised on every OpenVibe site that honours it: a badge in chat, members-only posts, rooms and more, as each plan lists.</p>
+<p class="muted">Payments, renewals, cancellations and refunds are handled by OpenVibe.Billing. VIP keeps the plans, the exact terms you joined under, and the perks that come with them.</p>
+<p><a class="button" href="/me">Your memberships</a> <a class="button button-quiet" href="/dashboard">Offer a plan</a></p>
+</section>
+<section><h2>Creators with plans</h2>${list}</section>`;
+}
+
+/** The creator's public plan page. */
+function creatorPage({ creator, plans, viewer, member, rates, providers, token, query, isOwner }) {
+ const name = creator.display_name || creator.username;
+ const status = member && member.status === 'active'
+ ? `<div class="card member-box"><p><b>You are a member.</b> ${member.cancel_at_period_end ? `Your membership ends on ${fmtDate(member.expires_at)}.` : `Paid through ${fmtDate(member.expires_at)}.`}</p>
+${member.membership && member.membership.plan_version ? `<p class="muted">You joined under ${esc(member.membership.plan_version.name)} v${esc(member.membership.plan_version.version)}. Those terms stay yours while the membership lasts.</p>` : ''}<p><a href="/me">Manage your memberships</a></p></div>`
+ : member && member.status === 'unknown'
+ ? '<div class="card member-box"><p class="muted">We could not confirm your membership with OpenVibe.Billing just now. Nothing about it has changed; try again shortly.</p></div>'
+ : '';
+ const planCards = plans.length ? plans.map((p) => {
+ const v = p.current_version;
+ const join = !p.purchasable
+ ? '<p class="muted">This plan is not sold through Billing yet.</p>'
+ : isOwner ? '<p class="muted">This is your plan.</p>'
+ : member && member.status === 'active' ? ''
+ : viewer
+ ? `<form method="post" action="/${encodeURIComponent(creator.username)}/join" class="join">${csrf(token)}<input type="hidden" name="plan_id" value="${esc(p.id)}">
+<label>Pay with <select name="provider">${providers.map((x) => `<option value="${esc(x)}">${esc(x === 'credit' ? 'your Vibes credit' : x === 'powerchat' ? 'PowerChat' : x)}</option>`).join('')}</select></label>
+<label class="check"><input type="checkbox" name="auto_renew" value="1" checked> Renew automatically</label>
+<button type="submit">Join ${esc(v.name)}</button></form>`
+ : `<p><a class="button" href="/auth/login?next=${encodeURIComponent(`/${creator.username}`)}">Sign in to join</a></p>`;
+ return `<article class="card plan" id="plan-${esc(p.slug)}">
+<h2>${esc(v.name)}</h2>
+${v.description ? `<p>${esc(v.description)}</p>` : ''}
+${benefitList(v.benefits)}
+${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}
+${p.purchasable ? priceLine(rates) : ''}
+${join}
+<p class="muted small">Version ${esc(v.version)} · published ${fmtDate(v.published_at)} · <a href="/${encodeURIComponent(creator.username)}/plans/${encodeURIComponent(p.slug)}">terms history</a></p>
+</article>`;
+ }).join('') : '<p class="muted">No plan is published yet.</p>';
+ return `<section class="creator-head"><p class="eyebrow">Membership</p><h1>${esc(name)}</h1><p class="muted">@${esc(creator.username)}</p>
+${creator.bio ? `<p>${esc(creator.bio)}</p>` : ''}</section>
+${notice(query)}
+${status}
+<section class="plans">${planCards}</section>`;
+}
+
+/** Public terms history of one plan: every published version, unchanged. */
+function planHistory({ creator, plan, versions }) {
+ return `<section><p class="eyebrow"><a href="/${encodeURIComponent(creator.username)}">${esc(creator.display_name || creator.username)}</a></p>
+<h1>Terms history</h1>
+<p class="muted">Each edit of a plan is a new version. Members keep the version they joined under; earlier versions are never rewritten.</p>
+${versions.map((v) => `<article class="card version"><h2>${esc(v.name)} <span class="muted">v${esc(v.version)}</span></h2>
+<p class="muted small">Published ${fmtDate(v.published_at)}${v.change_note ? ` · ${esc(v.change_note)}` : ''}</p>
+${v.description ? `<p>${esc(v.description)}</p>` : ''}${benefitList(v.benefits)}${v.perks.length ? `<h3>Perks</h3>${perkList(v.perks)}` : ''}</article>`).join('')}
+<p><a href="/${encodeURIComponent(creator.username)}#plan-${esc(plan.slug)}">Back to the plan</a></p></section>`;
+}
+
+/** The member's own status page. */
+function memberPage({ viewer, rows, token, query }) {
+ if (!viewer) {
+ return `<section class="card narrow"><h1>Your memberships</h1><p>Sign in with your OpenVibe account to see the plans you belong to and the terms you joined under.</p>
+<p><a class="button" href="/auth/login?next=%2Fme">Sign in</a></p></section>`;
+ }
+ const body = rows.length ? rows.map(({ membership: m, creator: c, entitlement: e, preferences: pref }) => {
+ const who = c ? (c.username ? `<a href="/${encodeURIComponent(c.username)}">${esc(c.display_name || c.username)}</a>` : esc(c.display_name || 'a creator')) : 'a creator';
+ const state = !e ? '<span class="pill">unknown</span>'
+ : e.status === 'active' ? `<span class="pill pill-on">active</span> ${e.cancel_at_period_end ? `ends ${fmtDate(e.expires_at)}` : `paid through ${fmtDate(e.expires_at)}`}`
+ : e.status === 'inactive' ? `<span class="pill">ended</span>${e.expires_at ? ` ${fmtDate(e.expires_at)}` : ''}`
+ : '<span class="pill">unconfirmed</span> OpenVibe.Billing did not answer just now; nothing changed';
+ const v = m.plan_version;
+ const cancel = e && e.status === 'active' && !e.cancel_at_period_end
+ ? `<form method="post" action="/me/${encodeURIComponent(m.creator_id)}/cancel">${csrf(token)}<button type="submit" class="button-quiet">Cancel at period end</button></form>` : '';
+ return `<article class="card membership"><h2>${who}</h2><p>${state}</p>
+${v ? `<p>Joined under <b>${esc(v.name)}</b> v${esc(v.version)}${v.terms && v.terms.perks && v.terms.perks.length ? ` · perks: ${v.terms.perks.map((p) => esc(p.name)).join(', ')}` : ''}</p>` : '<p class="muted">Billing records this membership; the creator has no VIP plan for it.</p>'}
+<form method="post" action="/me/${encodeURIComponent(m.creator_id)}/preferences" class="prefs">${csrf(token)}
+<label class="check"><input type="checkbox" name="show_badge" value="1"${pref.show_badge ? ' checked' : ''}> Show my member badge</label>
+<button type="submit" class="button-quiet">Save</button></form>
+${cancel}</article>`;
+ }).join('') : '<p class="muted">You have no memberships yet.</p>';
+ return `<section><h1>Your memberships</h1>${notice(query)}${body}
+<p class="muted small">Charges, renewals and refunds are OpenVibe.Billing's; the status shown here is confirmed with Billing when it is not recent.</p></section>`;
+}
+
+function checkoutPage({ creator, out }) {
+ const ref = out.checkout_ref;
+ return `<section class="card narrow"><h1>Finish joining ${esc(creator.display_name || creator.username)}</h1>
+${out.checkout_url ? `<p><a class="button" href="${esc(out.checkout_url)}" rel="noopener">Continue to payment</a></p>` : ''}
+${ref && !out.checkout_url ? `<p>Complete the payment on PowerChat with this reference: <code>${esc(ref)}</code>${out.amount_cents ? ` (${money(out.amount_cents)})` : ''}.</p>` : ''}
+<p class="muted">Your membership starts as soon as OpenVibe.Billing confirms the payment; <a href="/me">your memberships</a> shows it then.</p></section>`;
+}
+
+// ── Dashboard ────────────────────────────────────────────────
+function perkChoices(allPerks, selectedKeys) {
+ if (!allPerks.length) return '<p class="muted small">No perks yet — define one below.</p>';
+ return `<fieldset class="perk-choices"><legend>Perks</legend>${allPerks.map((p) => `<label class="check"><input type="checkbox" name="perks" value="${esc(p.key)}"${selectedKeys.includes(p.key) ? ' checked' : ''}> ${esc(p.name)}${p.scope === 'network' ? ' <span class="muted small">(network)</span>' : ''}</label>`).join('')}</fieldset>`;
+}
+
+function planEditor({ plan, allPerks, token, base }) {
+ const v = plan.draft_version || plan.current_version;
+ const keys = v ? v.perks.map((p) => p.key) : [];
+ const versions = (plan.versions || []).map((x) => `<li>v${esc(x.version)} · ${esc(x.name)} · ${x.published_at ? `published ${fmtDate(x.published_at)}` : 'draft'}${x.change_note ? ` · ${esc(x.change_note)}` : ''}${x.perks.length ? ` · perks: ${x.perks.map((p) => esc(p.key)).join(', ')}` : ''}</li>`).join('');
+ const actions = [
+ plan.status === 'draft' ? `<form method="post" action="${base}/plans/${esc(plan.id)}/publish" class="inline">${csrf(token)}<button type="submit">Publish</button></form>` : '',
+ plan.status !== 'archived' ? `<form method="post" action="${base}/plans/${esc(plan.id)}/archive" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Archive</button></form>` : '',
+ ].join(' ');
+ return `<article class="card plan-editor"><h3>${esc(v ? v.name : plan.slug)} <span class="pill">${esc(plan.status)}</span></h3>
+<p class="muted small">${plan.billing_kind ? 'Sold through OpenVibe.Billing as a channel subscription.' : 'Not sold through Billing (terms only).'} ${plan.current_version ? `New members buy v${esc(plan.current_version.version)}.` : ''}</p>
+${plan.status !== 'archived' ? `<details><summary>Edit (creates version ${esc((plan.versions || []).length + 1)})</summary>
+<form method="post" action="${base}/plans/${esc(plan.id)}">${csrf(token)}
+<label>Name <input name="name" maxlength="80" required value="${esc(v ? v.name : '')}"></label>
+<label>Description <textarea name="description" maxlength="2000" rows="3">${esc(v && v.description ? v.description : '')}</textarea></label>
+<label>Benefits, one per line <textarea name="benefits" rows="3">${esc(v ? v.benefits.join('\n') : '')}</textarea></label>
+${perkChoices(allPerks, keys)}
+<label>What changed <input name="change_note" maxlength="300"></label>
+<p class="muted small">Members who already joined keep the version they joined under.</p>
+<button type="submit">Save as a new version</button></form></details>` : ''}
+${actions}
+<details><summary>Versions (${esc((plan.versions || []).length)})</summary><ul class="versions">${versions}</ul></details></article>`;
+}
+
+function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base }) {
+ if (!viewer) {
+ return `<section class="card narrow"><h1>Creator dashboard</h1><p>Sign in with your OpenVibe account to offer a membership plan.</p>
+<p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;
+ }
+ const title = isNetwork ? 'Network plans' : 'Your membership plans';
+ const pageLink = !isNetwork && creator.username ? `<p class="muted">Your public page: <a href="/${encodeURIComponent(creator.username)}">openvibe.vip/${esc(creator.username)}</a></p>` : '';
+ const planOptions = plans.filter((p) => p.status !== 'archived').map((p) => `<option value="${esc(p.id)}">${esc((p.current_version || p.draft_version || {}).name || p.slug)}</option>`).join('');
+ return `<section><h1>${title}</h1>${isStaff ? `<p class="muted small">${isNetwork ? '<a href="/dashboard">Your own plans</a>' : '<a href="/dashboard?as=network">Network plans (staff)</a>'}</p>` : ''}${pageLink}${notice(query)}</section>
+${isNetwork ? '' : `<section class="card"><h2>Profile</h2><form method="post" action="${base}/profile">${csrf(token)}
+<label>Display name <input name="display_name" maxlength="80" value="${esc(creator.display_name || '')}"></label>
+<label>About your memberships <textarea name="bio" maxlength="2000" rows="3">${esc(creator.bio || '')}</textarea></label>
+<button type="submit">Save</button></form></section>`}
+<section><h2>Plans</h2>${plans.length ? plans.map((p) => planEditor({ plan: p, allPerks, token, base })).join('') : '<p class="muted">No plans yet.</p>'}
+<details class="card"><summary>New plan</summary><form method="post" action="${base}/plans">${csrf(token)}
+<label>Name <input name="name" maxlength="80" required></label>
+<label>Description <textarea name="description" maxlength="2000" rows="3"></textarea></label>
+<label>Benefits, one per line <textarea name="benefits" rows="3"></textarea></label>
+${perkChoices(allPerks, [])}
+<label class="check"><input type="checkbox" name="publish" value="1"> Publish now</label>
+<p class="muted small">${isNetwork ? 'Network plans are terms only until Billing sells a network membership.' : 'A published plan is sold through OpenVibe.Billing as your channel subscription; Billing sets the price. One published plan can be sold at a time.'}</p>
+<button type="submit">Create plan</button></form></details></section>
+<section><h2>Perks</h2>
+${ownPerks.length ? `<ul class="perk-admin">${ownPerks.map((p) => `<li><details><summary><b>${esc(p.name)}</b> <span class="muted small">${esc(p.key)} · ${esc(p.kind)} · ${esc(p.status)}</span></summary>
+<form method="post" action="${base}/perks/${esc(p.id)}">${csrf(token)}
+<label>Name <input name="name" maxlength="80" value="${esc(p.name)}"></label>
+<label>Description <input name="description" maxlength="500" value="${esc(p.description || '')}"></label>
+<label>Product bindings, one "product binding" per line <textarea name="bindings" rows="2">${esc(p.bindings.map((b) => `${b.product} ${b.binding}`).join('\n'))}</textarea></label>
+<label>Status <select name="status"><option value="active"${p.status === 'active' ? ' selected' : ''}>active</option><option value="retired"${p.status === 'retired' ? ' selected' : ''}>retired</option></select></label>
+<p class="muted small">Renaming a perk does not change the terms of published versions.</p>
+<button type="submit">Save perk</button></form></details></li>`).join('')}</ul>` : '<p class="muted">No perks of your own yet.</p>'}
+<details class="card"><summary>New perk</summary><form method="post" action="${base}/perks">${csrf(token)}
+<label>Name <input name="name" maxlength="80" required></label>
+<label>Key (a-z, 0-9, -) <input name="key" maxlength="48" pattern="[a-z0-9][a-z0-9-]*"></label>
+<label>Description <input name="description" maxlength="500"></label>
+<label>Kind <select name="kind">${['badge', 'emote', 'gated_content', 'room', 'role', 'other'].map((k) => `<option>${k}</option>`).join('')}</select></label>
+<label>Product bindings, one "product binding" per line (e.g. <code>chat badge</code>) <textarea name="bindings" rows="2"></textarea></label>
+<button type="submit">Create perk</button></form></details></section>
+${isNetwork ? '' : `<section><h2>Members</h2>
+${members.length ? `<table class="members"><thead><tr><th>Member</th><th>Plan version</th><th>Paid through</th></tr></thead><tbody>${members.map((m) => `<tr><td><code>${esc(m.member.id)}</code></td><td>${m.membership && m.membership.plan_version ? `${esc(m.membership.plan_version.name)} v${esc(m.membership.plan_version.version)}` : '—'}</td><td>${fmtDate(m.current_period_end)}${m.cancel_at_period_end ? ' (ends)' : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No active members.</p>'}
+<p class="muted small">${membersSource === 'billing' ? 'From OpenVibe.Billing.' : 'OpenVibe.Billing did not answer; this is VIP\'s recent projection and may be incomplete.'}</p></section>
+<section><h2>Members-only resources</h2>
+<p class="muted small">A product (Blog, Wiki, Community, Chat, Live) asks VIP whether a viewer may see a resource you gate here; anything VIP cannot confirm is refused.</p>
+${rules.length ? `<ul class="rules">${rules.map((r) => `<li><code>${esc(r.resource.service)}/${esc(r.resource.type)}/${esc(r.resource.id)}</code> · ${esc(r.requirement)}${r.perk_key ? ` ${esc(r.perk_key)}` : ''}${r.sensitive ? ' · always checked with Billing' : ''}
+<form method="post" action="${base}/rules/${esc(r.id)}/disable" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Remove</button></form></li>`).join('')}</ul>` : ''}
+<details class="card"><summary>Gate a resource</summary><form method="post" action="${base}/rules">${csrf(token)}
+<label>Product <input name="service" required placeholder="blog" pattern="[a-z][a-z0-9-]{1,39}"></label>
+<label>Type <input name="type" required placeholder="post" pattern="[a-z][a-z0-9_]{1,39}"></label>
+<label>Id <input name="id" required maxlength="128"></label>
+<label>Who may see it <select name="requirement"><option value="member">any member</option><option value="plan">members of one plan</option><option value="perk">members whose plan includes a perk</option></select></label>
+<label>Plan <select name="plan_id"><option value="">—</option>${planOptions}</select></label>
+<label>Perk <select name="perk_key"><option value="">—</option>${allPerks.map((p) => `<option value="${esc(p.key)}">${esc(p.name)}</option>`).join('')}</select></label>
+<label class="check"><input type="checkbox" name="sensitive" value="1"> Always check with Billing (slower; for sensitive content)</label>
+<button type="submit">Save rule</button></form></details></section>`}`;
+}
+
+module.exports = { errorPage, home, creatorPage, planHistory, memberPage, checkoutPage, dashboard, fmtDate };
new file mode 100644
new file mode 100644
@@ -0,0 +1,251 @@
@@ -0,0 +1,251 @@
+'use strict';
+
+/**
+ * Server-rendered pages (useful without JavaScript):
+ *
+ * GET / directory of creators with published plans
+ * GET /me the viewer's memberships, the version each was bought under, cancel
+ * GET /dashboard[?as=network] creator dashboard: plans, versions, perks, members, gated resources
+ * GET /:username a creator's plan page (join through Billing)
+ * GET /:username/plans/:slug that plan's public terms history
+ * GET /robots.txt, /sitemap.xml
+ *
+ * Every POST needs the signed-in viewer and the form's anti-forgery token; it acts through the same
+ * domain calls as the API and redirects back with a notice (?ok= / ?error=).
+ */
+const express = require('express');
+const { VipError, bool } = require('../util');
+const { viewerMiddleware } = require('./session');
+const { createForms } = require('./forms');
+const pages = require('./pages');
+
+const RESERVED = new Set(['me', 'dashboard', 'auth', 'api', 'internal', 'css', 'js', 'terms', 'privacy', 'dmca', 'robots.txt', 'sitemap.xml', 'release.json', 'metrics', 'favicon.ico']);
+
+function createWebRoutes({ domain, config, layout, userAuth }) {
+ const router = express.Router();
+ const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;
+ const forms = createForms({ secret: config.formSecret, now: domain.now });
+ const isStaff = (v) => !!v && (config.staffRoles || []).includes(v.role);
+ router.use(viewerMiddleware(userAuth));
+ const body = express.urlencoded({ extended: false, limit: '32kb' });
+
+ let ratesCache = { at: 0, value: null };
+ async function rates() {
+ if (domain.now() - ratesCache.at < config.billing.ratesTtlMs) return ratesCache.value;
+ try { ratesCache = { at: domain.now(), value: await billing.rates() }; } catch { ratesCache = { at: domain.now() - config.billing.ratesTtlMs + 30_000, value: null }; }
+ return ratesCache.value;
+ }
+
+ const send = (res, status, o) => res.status(status).type('html').send(layout.page(o));
+ const notFound = (req, res) => send(res, 404, { title: 'Not found', robots: 'noindex', viewer: req.viewer, body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) });
+ const back = (res, path, kind, msg) => res.redirect(303, `${path}${path.includes('?') ? '&' : '?'}${kind}=${encodeURIComponent(msg)}`);
+ const list = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]).map(String).filter(Boolean);
+ const lines = (v) => String(v || '').split('\n').map((s) => s.trim()).filter(Boolean);
+
+ /** A POST handler: viewer + anti-forgery token, VipError → redirect with the message. */
+ function post(path, handler, { backTo } = {}) {
+ router.post(path, body, async (req, res, next) => {
+ const to = typeof backTo === 'function' ? backTo(req) : (backTo || req.path);
+ if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(to)}`);
+ if (!forms.verify(req.viewer.subject, req.body && req.body._csrf)) return back(res, to, 'error', 'That form expired. Please try again.');
+ try {
+ await handler(req, res, to);
+ } catch (e) {
+ if (e instanceof VipError) return back(res, to, 'error', e.detail || e.message);
+ return next(e);
+ }
+ });
+ }
+
+ router.get('/robots.txt', (req, res) => res.type('text/plain').send(`User-agent: *\nDisallow: /me\nDisallow: /dashboard\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));
+ router.get('/sitemap.xml', (req, res) => {
+ const urls = [`${config.baseUrl}/`, ...creators.listPublic({ limit: 5000 }).map((c) => `${config.baseUrl}/${encodeURIComponent(c.username)}`)];
+ res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${u.replace(/&/g, '&')}</loc></url>`).join('\n')}\n</urlset>\n`);
+ });
+
+ router.get('/', (req, res) => send(res, 200, {
+ active: 'home', viewer: req.viewer, canonicalPath: '/',
+ body: pages.home({ creators: creators.listPublic().map(creators.present) }),
+ }));
+
+ // ── The member's page ────────────────────────────────────
+ router.get('/me', async (req, res, next) => {
+ try {
+ const rows = [];
+ if (req.viewer) {
+ for (const m of memberships.forMember(req.viewer.subject)) {
+ const c = creators.byId(m.creator_id);
+ const e = c && c.subject ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
+ rows.push({ membership: memberships.present(m), creator: creators.present(c), entitlement: e, preferences: memberships.preferences(req.viewer.subject, m.creator_id) });
+ }
+ }
+ send(res, 200, { title: 'Your memberships', active: 'memberships', robots: 'noindex,nofollow', canonicalPath: '/me', viewer: req.viewer, body: pages.memberPage({ viewer: req.viewer, rows, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query }) });
+ } catch (e) { next(e); }
+ });
+ post('/me/:creatorId/cancel', async (req, res, to) => {
+ const out = await checkout.cancel({ member: req.viewer.subject, creatorId: req.params.creatorId, traceparent: req.ov.traceparent });
+ back(res, to, 'ok', out.already ? 'That membership was already set to end.' : `Cancelled. Your membership runs until ${pages.fmtDate(out.expires_at)}.`);
+ }, { backTo: '/me' });
+ post('/me/:creatorId/preferences', async (req, res, to) => {
+ const c = creators.byId(req.params.creatorId);
+ if (!c || !memberships.get(req.viewer.subject, c.id)) throw new VipError(404, 'vip.membership_not_found', 'no such membership');
+ memberships.setPreferences(req.viewer.subject, c.id, { showBadge: bool(req.body.show_badge) });
+ back(res, to, 'ok', 'Saved.');
+ }, { backTo: '/me' });
+
+ // ── Creator dashboard ────────────────────────────────────
+ /** The creator the dashboard acts for: the viewer, or the network for staff (?as=network). */
+ function dashCreator(req) {
+ const asNetwork = (req.query.as || (req.body && req.body.as)) === 'network';
+ if (asNetwork) {
+ if (!isStaff(req.viewer)) throw new VipError(403, 'vip.not_staff', 'only staff manage network plans');
+ return creators.network();
+ }
+ return creators.ensure({ subject: req.viewer.subject, username: req.viewer.username, displayName: req.viewer.name, origin: 'self' });
+ }
+
+ router.get('/dashboard', async (req, res, next) => {
+ try {
+ if (!req.viewer) return send(res, 200, { title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', viewer: null, body: pages.dashboard({ viewer: null }) });
+ let creator;
+ try { creator = dashCreator(req); } catch (e) { return send(res, 403, { title: 'Dashboard', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.errorPage({ status: 403, title: 'Not allowed', message: e.detail }) }); }
+ const isNetwork = creator.kind === 'network';
+ const planRows = plans.list({ creatorId: creator.id, includeDrafts: true, includeArchived: true }).map((p) => plans.present(p, { withVersions: true }));
+ const allPerks = perks.list({ creatorId: creator.id }).map((p) => perks.present(p, { withBindings: false }));
+ const ownPerks = perks.list({ creatorId: creator.id, includeNetwork: false, includeRetired: true }).map((p) => perks.present(p));
+ let members = [];
+ let membersSource = 'billing';
+ if (!isNetwork) {
+ const byMember = new Map(memberships.forCreator(creator.id).map((m) => [m.member_subject, m]));
+ try {
+ const out = await billing.listSubscriptions({ streamer: creator.subject, status: 'active' });
+ members = (out.subscriptions || []).map((s) => ({ member: s.subscriber, current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, membership: memberships.present(byMember.get(s.subscriber.id)) }));
+ } catch {
+ membersSource = 'projection';
+ members = entitlements.projectedMembers(creator.subject).map((pr) => ({ member: { type: 'user', id: pr.member_subject }, current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, membership: memberships.present(byMember.get(pr.member_subject)) }));
+ }
+ }
+ send(res, 200, {
+ title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,
+ body: pages.dashboard({
+ viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource,
+ rules: isNetwork ? [] : policies.list(creator.id).map(policies.present), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',
+ }),
+ });
+ } catch (e) { next(e); }
+ });
+
+ // Dashboard writes: /dashboard/... for the viewer's own creator, /dashboard/network/... for staff.
+ for (const base of ['/dashboard', '/dashboard/network']) {
+ const withAs = (req) => { if (base === '/dashboard/network') req.query.as = 'network'; };
+ const own = (req, row) => { withAs(req); const c = dashCreator(req); if (!row || row.creator_id !== c.id) throw new VipError(404, 'vip.not_found', 'not found'); return c; };
+ const opts = { backTo: () => (base === '/dashboard/network' ? '/dashboard?as=network' : '/dashboard') };
+ post(`${base}/profile`, async (req, res, to) => {
+ withAs(req);
+ const c = dashCreator(req);
+ creators.update(c.id, { displayName: req.body.display_name, bio: req.body.bio });
+ back(res, to, 'ok', 'Profile saved.');
+ }, opts);
+ post(`${base}/plans`, async (req, res, to) => {
+ withAs(req);
+ const c = dashCreator(req);
+ const plan = plans.create({ creatorId: c.id, name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), publish: bool(req.body.publish), actor: req.viewer.subject, traceparent: req.ov.traceparent });
+ back(res, to, 'ok', `Plan created${plan.status === 'published' ? ' and published' : ''}.`);
+ }, opts);
+ post(`${base}/plans/:id`, async (req, res, to) => {
+ const plan = plans.byId(req.params.id);
+ own(req, plan);
+ const out = plans.update(plan.id, { name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), changeNote: req.body.change_note, actor: req.viewer.subject, traceparent: req.ov.traceparent });
+ back(res, to, 'ok', out.unchanged ? 'Nothing changed.' : `Saved as version ${out.version.version}.`);
+ }, opts);
+ post(`${base}/plans/:id/publish`, async (req, res, to) => {
+ const plan = plans.byId(req.params.id);
+ own(req, plan);
+ plans.publish(plan.id, { traceparent: req.ov.traceparent });
+ back(res, to, 'ok', 'Published.');
+ }, opts);
+ post(`${base}/plans/:id/archive`, async (req, res, to) => {
+ const plan = plans.byId(req.params.id);
+ own(req, plan);
+ plans.archive(plan.id);
+ back(res, to, 'ok', 'Archived. Current members keep their terms until their membership ends.');
+ }, opts);
+ const bindingLines = (v) => lines(v).map((l) => { const [product, binding] = l.split(/\s+/); return { product, binding }; });
+ post(`${base}/perks`, async (req, res, to) => {
+ withAs(req);
+ const c = dashCreator(req);
+ perks.create({ creatorId: c.id, key: req.body.key || undefined, name: req.body.name, description: req.body.description, kind: req.body.kind || 'other', bindings: bindingLines(req.body.bindings), actor: req.viewer.subject });
+ back(res, to, 'ok', 'Perk created.');
+ }, opts);
+ post(`${base}/perks/:id`, async (req, res, to) => {
+ const p = perks.byId(req.params.id);
+ own(req, p);
+ perks.update(p.id, { name: req.body.name, description: req.body.description, status: req.body.status, bindings: bindingLines(req.body.bindings) });
+ back(res, to, 'ok', 'Perk saved.');
+ }, opts);
+ if (base === '/dashboard') {
+ post(`${base}/rules`, async (req, res, to) => {
+ const c = dashCreator(req);
+ policies.set({
+ creatorId: c.id, resource: { service: req.body.service, type: req.body.type, id: req.body.id }, requirement: req.body.requirement || 'member',
+ planId: req.body.plan_id || null, perkKey: req.body.perk_key || null, sensitive: bool(req.body.sensitive), actor: req.viewer.subject,
+ });
+ back(res, to, 'ok', 'Rule saved.');
+ }, opts);
+ post(`${base}/rules/:id/disable`, async (req, res, to) => {
+ const rule = policies.byId(req.params.id);
+ own(req, rule);
+ policies.disable(rule.id);
+ back(res, to, 'ok', 'Rule removed.');
+ }, opts);
+ }
+ }
+
+ // ── Creator pages ────────────────────────────────────────
+ router.get('/:username', async (req, res, next) => {
+ try {
+ if (RESERVED.has(req.params.username.toLowerCase())) return notFound(req, res);
+ const c = creators.byUsername(req.params.username);
+ if (!c || c.status !== 'active') return notFound(req, res);
+ if (c.username !== req.params.username) return res.redirect(301, `/${encodeURIComponent(c.username)}`);
+ const list = plans.list({ creatorId: c.id }).map((p) => plans.present(p));
+ const isOwner = !!(req.viewer && req.viewer.subject === c.subject);
+ const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
+ const name = c.display_name || c.username;
+ send(res, 200, {
+ title: `${name} memberships`, active: 'creator', canonicalPath: `/${c.username}`, viewer: req.viewer,
+ description: c.bio || `Membership plans from ${name} on OpenVibe.`,
+ robots: list.length ? 'index,follow' : 'noindex,follow',
+ body: pages.creatorPage({ creator: creators.present(c), plans: list, viewer: req.viewer, member, rates: list.some((p) => p.purchasable) ? await rates() : null, providers: config.billing.providers, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query, isOwner }),
+ });
+ } catch (e) { next(e); }
+ });
+ router.get('/:username/plans/:slug', (req, res) => {
+ const c = creators.byUsername(req.params.username);
+ if (!c || c.status !== 'active') return notFound(req, res);
+ const plan = plans.list({ creatorId: c.id, includeArchived: true }).find((p) => p.slug === req.params.slug);
+ if (!plan) return notFound(req, res);
+ const versions = plans.versions(plan.id).filter((v) => v.published_at).map(plans.presentVersion);
+ return send(res, 200, {
+ title: `Terms history · ${c.display_name || c.username}`, canonicalPath: `/${c.username}/plans/${plan.slug}`, viewer: req.viewer, robots: 'noindex,follow',
+ body: pages.planHistory({ creator: creators.present(c), plan, versions }),
+ });
+ });
+ post('/:username/join', async (req, res, to) => {
+ const c = creators.byUsername(req.params.username);
+ if (!c) throw new VipError(404, 'vip.creator_not_found', 'no such creator');
+ const plan = plans.byId(String(req.body.plan_id || ''));
+ if (!plan || plan.creator_id !== c.id) throw new VipError(404, 'vip.plan_not_found', 'that plan is not offered here');
+ const out = await checkout.start({
+ member: req.viewer.subject, planId: req.body.plan_id, provider: req.body.provider, autoRenew: bool(req.body.auto_renew),
+ traceparent: req.ov.traceparent,
+ });
+ if (out.membership_started) return back(res, '/me', 'ok', `Welcome — you are now a member of ${c.display_name || c.username}.`);
+ if (out.checkout_url && !out.checkout_ref) return res.redirect(303, out.checkout_url);
+ return send(res, 200, { title: 'Finish joining', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.checkoutPage({ creator: creators.present(c), out }) });
+ }, { backTo: (req) => `/${req.params.username}` });
+
+ return router;
+}
+
+module.exports = { createWebRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,213 @@
@@ -0,0 +1,213 @@
+'use strict';
+
+/**
+ * openvibe.vip — OAuth2 client session layer (identity provider: OpenVibe.Network), the same
+ * shape as OpenVibe.Community's server/auth/routes.js. Registered in the Network as client `vip`
+ * with redirect https://openvibe.vip/auth/callback.
+ *
+ * GET /auth/login → Network /oauth/authorize (?silent=1 → prompt=none; ?next= same-site path)
+ * GET /auth/callback → server-side code exchange, session cookies
+ * POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
+ * GET /auth/logout → clear cookies (best-effort refresh revoke), hint=guest
+ * GET /auth/me → the signed-in user (offline JWT verification)
+ * POST /auth/refresh → rotate tokens
+ *
+ * Cookies (host-only): ov_token (access JWT, Lax, JS-readable for the navbar), ov_refresh (httpOnly,
+ * Path=/auth), ov_sso_hint. These cookies only ever sign a person into the pages of this site;
+ * the API never reads them.
+ */
+const express = require('express');
+const crypto = require('crypto');
+const { userPrincipal } = require('../api/auth');
+
+const ACCESS_COOKIE = 'ov_token';
+const REFRESH_COOKIE = 'ov_refresh';
+const HINT_COOKIE = 'ov_sso_hint';
+const STATE_COOKIE = 'ov_oauth_state';
+const NEXT_COOKIE = 'ov_oauth_next';
+const SILENT_COOKIE = 'ov_oauth_silent';
+
+function claimsToUser(claims) {
+ if (!claims) return null;
+ const { iat, exp, aud, iss, nbf, jti, ...user } = claims; // eslint-disable-line no-unused-vars
+ return user;
+}
+
+function decodeJwtPayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try { const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); return c && typeof c === 'object' ? c : null; } catch { return null; }
+}
+
+function sanitizeNext(next, config) {
+ if (!next || typeof next !== 'string') return '/';
+ if (/^\/(?!\/|\\)/.test(next)) return next;
+ try {
+ const u = new URL(next);
+ if (u.protocol !== 'https:') return '/';
+ const allowed = [config.baseUrl, config.network.url].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);
+ if (allowed.includes(u.hostname)) return u.toString();
+ } catch { /* fall through */ }
+ return '/';
+}
+
+function withParam(target, key, value) {
+ const hashAt = target.indexOf('#');
+ const hash = hashAt >= 0 ? target.slice(hashAt) : '';
+ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;
+ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
+}
+
+/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */
+function viewerMiddleware(userAuth) {
+ return (req, _res, next) => {
+ const token = req.cookies && req.cookies[ACCESS_COOKIE];
+ const claims = token ? userAuth.verify(token) : null;
+ req.viewer = claims ? userPrincipal(claims) : null;
+ req.viewerClaims = claims ? claimsToUser(claims) : null;
+ next();
+ };
+}
+
+function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch } = {}) {
+ const router = express.Router();
+ const secure = config.cookies.secure;
+ const accessOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
+ const refreshOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
+ const flowOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
+ const hintOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });
+
+ function setSession(res, access, refresh) {
+ res.cookie(ACCESS_COOKIE, access, accessOpts());
+ if (refresh) res.cookie(REFRESH_COOKIE, refresh, refreshOpts());
+ res.cookie(HINT_COOKIE, 'account', hintOpts());
+ }
+ function clearSession(res) {
+ res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });
+ res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });
+ }
+ function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
+
+ async function tokenGrant(body) {
+ let lastErr = null;
+ for (const base of [config.network.internalUrl, config.network.url]) {
+ if (!base) continue;
+ try {
+ const res = await fetchImpl(`${base}/oauth/token`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
+ signal: AbortSignal.timeout(10_000),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
+ err.status = res.status; err.error = data.error || 'invalid_grant';
+ throw err;
+ }
+ return data;
+ } catch (err) {
+ lastErr = err;
+ if (err.status && err.status < 500) throw err;
+ }
+ }
+ throw lastErr || new Error('Network unreachable');
+ }
+
+ router.get('/login', (req, res) => {
+ const silent = !!req.query.silent && req.query.silent !== '0';
+ if (silent && userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
+ const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);
+ let target = url;
+ if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }
+ res.cookie(STATE_COOKIE, state, flowOpts());
+ const next = sanitizeNext(req.query.next, config);
+ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
+ if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
+ res.redirect(target);
+ });
+
+ router.get('/callback', async (req, res) => {
+ const { code, state, error } = req.query;
+ const next = sanitizeNext(req.cookies && req.cookies[NEXT_COOKIE], config);
+ const silent = req.cookies && req.cookies[SILENT_COOKIE] === '1';
+ if (error) {
+ clearFlow(res);
+ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
+ return res.redirect(withParam('/', 'auth_error', String(error)));
+ }
+ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
+ const expected = req.cookies && req.cookies[STATE_COOKIE];
+ clearFlow(res);
+ if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {
+ return res.status(400).type('text/plain').send('Sign-in state mismatch — please try signing in again.');
+ }
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.redirect(next);
+ } catch (err) {
+ console.error('[VIP] code exchange failed:', err.message);
+ return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
+ }
+ });
+
+ const fedcmBody = express.json({ limit: '16kb' });
+ router.post('/fedcm', fedcmBody, async (req, res) => {
+ const { token, nonce } = req.body || {};
+ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce || nonce.length > 256) {
+ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });
+ }
+ const claims = decodeJwtPayload(token);
+ const a = Buffer.from(String((claims && claims.nonce) || '')); const b = Buffer.from(nonce);
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
+ try {
+ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
+ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.json({ ok: true, user: data.user || claimsToUser(userAuth.verify(data.access_token)) });
+ } catch (err) {
+ if (err.status && err.status < 500) return res.status(401).json({ error: err.error || 'invalid_grant' });
+ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ router.get('/logout', async (req, res) => {
+ const refresh = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (refresh) {
+ try {
+ await fetchImpl(`${config.network.internalUrl}/oauth/revoke`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: refresh }),
+ signal: AbortSignal.timeout(3000),
+ });
+ } catch { /* optional */ }
+ }
+ clearSession(res);
+ res.cookie(HINT_COOKIE, 'guest', hintOpts());
+ res.redirect(sanitizeNext(req.query.next, config));
+ });
+
+ router.get('/me', (req, res) => {
+ const token = (String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null) || (req.cookies && req.cookies[ACCESS_COOKIE]);
+ const claims = userAuth.verify(token);
+ if (!claims) return res.status(401).json({ error: 'Not authenticated' });
+ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
+ });
+
+ router.post('/refresh', async (req, res) => {
+ const refresh = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (!refresh) return res.status(401).json({ error: 'No refresh token' });
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.json({ token: data.access_token, user: claimsToUser(userAuth.verify(data.access_token)) });
+ } catch (err) {
+ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }
+ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createSessionRoutes, viewerMiddleware, sanitizeNext, ACCESS_COOKIE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,231 @@
@@ -0,0 +1,231 @@
+'use strict';
+/**
+ * Checkout hand-off and cancellation through Billing, members lists, readiness/metrics, and the
+ * server-rendered pages (useful without JavaScript, forms with anti-forgery tokens).
+ */
+const assert = require('assert');
+const { boot, harness } = require('./helpers/app');
+
+const { test, run } = harness('api');
+
+(async () => {
+ const t = await boot();
+ const creator = t.network.newUser('lena', { role: 'streamer' });
+ const buyer = t.network.newUser('milo');
+ const payer = t.network.newUser('nora');
+ let plan;
+
+ test('health, release and truthful readiness', async () => {
+ const h = await t.call('GET', '/api/health', { token: null });
+ assert.strictEqual(h.json.service, 'vip');
+ const rel = await t.call('GET', '/release.json', { token: null });
+ assert.strictEqual(rel.json.service, 'vip');
+ // Billing down: VIP stays ready (the projection still answers while fresh) and says so.
+ t.billing.state.down = true;
+ const r = await t.call('GET', '/api/ready', { token: null });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.ready, true);
+ assert.strictEqual(r.json.status, 'degraded');
+ assert.deepStrictEqual(r.json.degraded, ['billing']);
+ assert.strictEqual(r.json.checks.db.required, true);
+ assert.strictEqual(r.json.checks.billing.required, false);
+ assert.strictEqual(typeof r.json.projection.rows, 'number');
+ t.billing.state.down = false;
+ });
+
+ test('/metrics answers loopback callers with golden signals and VIP gauges', async () => {
+ const m = await fetch(`${t.base}/metrics`);
+ assert.strictEqual(m.status, 200);
+ const text = await m.text();
+ assert.match(text, /vip_outbox_pending/);
+ assert.match(text, /vip_projection_rows\{state="fresh"\}/);
+ assert.match(text, /http_requests_total|http_request_duration_seconds/);
+ });
+
+ test('the creator publishes a plan', async () => {
+ plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Front row', description: 'Monthly membership', benefits: ['Badge in chat'], publish: true } })).json.plan;
+ assert.ok(plan && plan.purchasable);
+ });
+
+ test('checkout hand-off returns Billing\'s checkout (PowerChat reference / provider URL) and records the version', async () => {
+ const pc = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'powerchat' } });
+ assert.strictEqual(pc.status, 201, pc.text);
+ assert.match(pc.json.checkout_ref, /^pcsub:/);
+ assert.strictEqual(pc.json.membership_started, false);
+ assert.strictEqual(pc.json.checkout.plan_version_id, plan.current_version.id);
+ const intentCall = t.billing.calls.filter((c) => c.path === '/api/v1/intents').pop();
+ assert.strictEqual(intentCall.key, `vip:checkout:${pc.json.checkout.id}`);
+ assert.strictEqual(intentCall.body.kind, 'subscription');
+ assert.deepStrictEqual(intentCall.body.streamer, { type: 'user', id: creator.subject });
+ const st = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });
+ assert.match(st.json.checkout_url, /^https:\/\/checkout\.stripe\.test\//);
+ const bad = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'bitcoin-atm' } });
+ assert.strictEqual(bad.status, 422);
+ const self = await t.call('POST', '/api/v1/checkout', { user: creator, body: { plan_id: plan.id, provider: 'stripe' } });
+ assert.strictEqual(self.status, 422);
+ });
+
+ test('when Billing settles the handed-off checkout, the membership is filed under the version chosen then', async () => {
+ await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { benefits: ['Badge in chat', 'Monthly stream'] } }); // v2 published meanwhile
+ await t.deliverAll(t.billing.pay(buyer.subject, creator.subject, { provider: 'stripe' }).events);
+ const m = t.domain.memberships.get(buyer.subject, t.domain.creators.bySubject(creator.subject).id);
+ assert.strictEqual(m.origin, 'checkout');
+ assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 1);
+ const again = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });
+ assert.strictEqual(again.status, 409);
+ assert.strictEqual(again.json.code, 'vip.already_member');
+ });
+
+ test('paying from Vibes credit starts the membership at once, through Billing', async () => {
+ const poor = await t.call('POST', '/api/v1/checkout', { user: payer, body: { plan_id: plan.id, provider: 'credit' } });
+ assert.strictEqual(poor.status, 409);
+ assert.strictEqual(poor.json.code, 'vip.checkout.insufficient_credit');
+ t.billing.state.credit.set(payer.subject, 1000);
+ const ok = await t.call('POST', '/api/v1/checkout', { user: payer, body: { plan_id: plan.id, provider: 'credit' } });
+ assert.strictEqual(ok.status, 201, ok.text);
+ assert.strictEqual(ok.json.membership_started, true);
+ const e = await t.call('GET', `/api/v1/entitlements/check?creator=${creator.subject}&mode=projection`, { user: payer });
+ assert.strictEqual(e.json.status, 'active');
+ assert.strictEqual(e.json.membership.plan_version.version, 2);
+ // Billing's own event for the same grant arrives later and changes nothing.
+ const before = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === payer.subject).length;
+ const out = await t.deliverAll(t.billing.state.lastEvents);
+ assert.ok(['unchanged', 'stale_ignored'].includes(out[1].json.outcome), out[1].json.outcome);
+ assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === payer.subject).length, before);
+ assert.strictEqual(before, 1);
+ });
+
+ test('a service checks out on behalf of a member only with vip.membership.checkout', async () => {
+ const x = t.network.newUser('otto');
+ const no = await t.call('POST', '/api/v1/checkout', { cap: ['vip.entitlement.check'], body: { subject: { type: 'user', id: x.subject }, plan_id: plan.id, provider: 'stripe' } });
+ assert.strictEqual(no.status, 403);
+ const yes = await t.call('POST', '/api/v1/checkout', { cap: ['vip.membership.checkout'], body: { subject: { type: 'user', id: x.subject }, plan_id: plan.id, provider: 'stripe' } });
+ assert.strictEqual(yes.status, 201, yes.text);
+ });
+
+ test('Billing frozen or down: checkout is refused and nothing is claimed', async () => {
+ const x = t.network.newUser('pia');
+ t.billing.state.down = true;
+ try {
+ const r = await t.call('POST', '/api/v1/checkout', { user: x, body: { plan_id: plan.id, provider: 'stripe' } });
+ assert.strictEqual(r.status, 503);
+ assert.strictEqual(r.json.code, 'vip.billing_unavailable');
+ } finally { t.billing.state.down = false; }
+ });
+
+ test('cancel goes through Billing (member only) and keeps the paid period', async () => {
+ const cid = t.domain.creators.bySubject(creator.subject).id;
+ const svc = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { cap: ['vip.membership.checkout'] });
+ assert.strictEqual(svc.status, 403);
+ const r = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { user: buyer });
+ assert.strictEqual(r.status, 200, r.text);
+ const cancelCall = t.billing.calls.filter((c) => /\/cancel$/.test(c.path)).pop();
+ assert.match(cancelCall.key, /^vip:cancel:/);
+ const e = await t.call('GET', `/api/v1/entitlements/check?creator=${creator.subject}&mode=projection`, { user: buyer });
+ assert.strictEqual(e.json.active, true);
+ assert.strictEqual(e.json.cancel_at_period_end, true);
+ const again = await t.call('POST', `/api/v1/memberships/lena/cancel`, { user: buyer });
+ assert.strictEqual(again.json.already, true);
+ const none = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { user: t.network.newUser('quin') });
+ assert.strictEqual(none.status, 404);
+ void cid;
+ });
+
+ test('membership status: users see their own, services need vip.membership.status', async () => {
+ const own = await t.call('GET', `/api/v1/memberships/${payer.subject}`, { user: payer });
+ assert.strictEqual(own.status, 200);
+ assert.strictEqual(own.json.memberships.length, 1);
+ assert.strictEqual(own.json.memberships[0].creator.username, 'lena');
+ assert.strictEqual((await t.call('GET', `/api/v1/memberships/${payer.subject}`, { user: buyer })).status, 403);
+ assert.strictEqual((await t.call('GET', `/api/v1/memberships/${payer.subject}`, { cap: ['vip.membership.status'] })).status, 200);
+ assert.strictEqual((await t.call('GET', `/api/v1/memberships/${payer.subject}`, { cap: [] })).status, 403);
+ });
+
+ test('creator members: from Billing, or the fresh projection when Billing is down', async () => {
+ const r = await t.call('GET', `/api/v1/creators/${creator.subject}/members`, { user: creator });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.source, 'billing');
+ assert.strictEqual(r.json.members.length, 2);
+ assert.ok(r.json.members.every((m) => m.membership && m.membership.plan_version));
+ t.billing.state.down = true;
+ try {
+ const p = await t.call('GET', '/api/v1/creators/lena/members', { cap: ['vip.creator.members.list'] });
+ assert.strictEqual(p.json.source, 'projection');
+ assert.strictEqual(p.json.members.length, 2);
+ } finally { t.billing.state.down = false; }
+ assert.strictEqual((await t.call('GET', '/api/v1/creators/lena/members', { user: buyer })).status, 403);
+ });
+
+ test('public creator page works without JavaScript and without an account', async () => {
+ const p = await t.page('/lena');
+ assert.strictEqual(p.status, 200);
+ assert.match(p.text, /Front row/);
+ assert.match(p.text, /Monthly stream/);
+ assert.match(p.text, /\$4\.99<\/b> every 30 days/);
+ assert.match(p.text, /Sign in to join/);
+ assert.match(p.text, /<link rel="canonical" href="http:\/\/vip\.test\/lena">/);
+ assert.match(p.text, /openvibe\.network\/shared\/navbar\.js/);
+ assert.match(p.text, /<noscript>/);
+ assert.doesNotMatch(p.text, /\bfree\b|\$0|no ads/i);
+ const hist = await t.page(`/lena/plans/${plan.slug}`);
+ assert.strictEqual(hist.status, 200);
+ assert.match(hist.text, /v1/);
+ assert.match(hist.text, /v2/);
+ assert.strictEqual((await t.page('/LENA')).location, '/lena');
+ assert.strictEqual((await t.page('/nobody-here')).status, 404);
+ const sm = await t.page('/sitemap.xml');
+ assert.match(sm.text, /http:\/\/vip\.test\/lena/);
+ });
+
+ test('joining from the page: form with anti-forgery token → Billing credit → member page', async () => {
+ const x = t.network.newUser('rita');
+ t.billing.state.credit.set(x.subject, 600);
+ const p = await t.page('/lena', { user: x });
+ const token = t.csrf(p.text);
+ assert.ok(token);
+ const forged = await t.page('/lena/join', { user: x, method: 'POST', form: { plan_id: plan.id, provider: 'credit', auto_renew: '1' } });
+ assert.strictEqual(forged.status, 303);
+ assert.match(forged.location, /error=/);
+ const ok = await t.page('/lena/join', { user: x, method: 'POST', form: { _csrf: token, plan_id: plan.id, provider: 'credit', auto_renew: '1' } });
+ assert.strictEqual(ok.status, 303);
+ assert.match(ok.location, /^\/me\?ok=/);
+ const me = await t.page('/me', { user: x });
+ assert.match(me.text, /Front row<\/b> v2/);
+ assert.match(me.text, /Cancel at period end/);
+ const handoff = await t.page('/lena/join', { user: t.network.newUser('sam'), method: 'POST', form: { _csrf: 'x'.repeat(32), plan_id: plan.id, provider: 'stripe' } });
+ assert.match(handoff.location, /error=/);
+ });
+
+ test('dashboard: plans, versions, perks and members; forms create versions', async () => {
+ const d = await t.page('/dashboard', { user: creator });
+ assert.strictEqual(d.status, 200);
+ assert.match(d.text, /Front row/);
+ assert.match(d.text, /Versions \(2\)/);
+ assert.match(d.text, /From OpenVibe\.Billing\./);
+ const token = t.csrf(d.text);
+ const edit = await t.page(`/dashboard/plans/${plan.id}`, { user: creator, method: 'POST', form: { _csrf: token, name: 'Front row', description: 'Monthly membership', benefits: 'Badge in chat\nMonthly stream\nEarly VODs', change_note: 'early VODs' } });
+ assert.strictEqual(edit.status, 303);
+ assert.match(decodeURIComponent(edit.location), /Saved as version 3/);
+ const perk = await t.page('/dashboard/perks', { user: creator, method: 'POST', form: { _csrf: token, name: 'Early VODs', key: 'early-vods', kind: 'gated_content', bindings: 'live vod_early_access' } });
+ assert.match(decodeURIComponent(perk.location), /Perk created/);
+ const other = t.network.newUser('tess', { role: 'streamer' });
+ const steal = await t.page(`/dashboard/plans/${plan.id}`, { user: other, method: 'POST', form: { _csrf: t.csrf((await t.page('/dashboard', { user: other })).text), name: 'mine now' } });
+ assert.match(steal.location, /error=/);
+ assert.strictEqual(t.domain.plans.latestVersion(plan.id).version, 3);
+ const net = await t.page('/dashboard?as=network', { user: creator });
+ assert.strictEqual(net.status, 403);
+ const anon = await t.page('/dashboard');
+ assert.match(anon.text, /Sign in/);
+ });
+
+ test('API errors are problem+json', async () => {
+ const r = await t.call('GET', '/api/v1/plans/vpl_missing', { token: null });
+ assert.strictEqual(r.status, 404);
+ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
+ assert.strictEqual(r.json.code, 'vip.plan_not_found');
+ const bad = await t.call('GET', '/api/v1/plans', { token: 'garbage' });
+ assert.strictEqual(bad.status, 401);
+ });
+
+ await run().finally(() => t.close());
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,262 @@
@@ -0,0 +1,262 @@
+'use strict';
+/**
+ * The entitlement projection: Billing events converge cancel / renew / refund; lost events converge
+ * through valid_until and the authoritative fallback; a stale projection never authorizes past
+ * valid_until (+ grace); ordering and duplicates are safe. Injected clock throughout.
+ */
+const assert = require('assert');
+const { signDelivery } = require('openvibe-sdk/events');
+const { boot, harness } = require('./helpers/app');
+const { DAY } = require('./helpers/stubs');
+
+const { test, run } = harness('entitlements');
+const MIN = 60_000;
+
+(async () => {
+ const t = await boot();
+ const creator = t.network.newUser('dora', { role: 'streamer' });
+ const svc = { cap: ['vip.entitlement.check'] };
+ const check = async (member, mode) => (await t.call('POST', '/api/v1/entitlements/check', { ...svc, body: { subject: { type: 'user', id: member.subject }, creator: { type: 'user', id: creator.subject }, mode } })).json;
+ await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } });
+
+ test('a grant event makes the member active from the projection, without asking Billing', async () => {
+ const m = t.network.newUser('m1');
+ const [settled, ent] = t.billing.pay(m.subject, creator.subject).events;
+ assert.strictEqual((await t.deliver(settled)).json.outcome, 'ignored:type');
+ const r = await t.deliver(ent);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json.outcome, 'changed');
+ const calls = t.billing.entitlementCalls();
+ const e = await check(m);
+ assert.strictEqual(e.status, 'active');
+ assert.strictEqual(e.active, true);
+ assert.strictEqual(e.source, 'projection');
+ assert.strictEqual(e.membership.plan_version.version, 1);
+ assert.strictEqual(t.billing.entitlementCalls(), calls);
+ const changed = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);
+ assert.strictEqual(changed.length, 1);
+ assert.strictEqual(changed[0].payload.active, true);
+ assert.strictEqual(changed[0].payload.reason, 'granted');
+ assert.strictEqual(changed[0].payload.plan_version, 1);
+ });
+
+ test('a redelivered event is applied once', async () => {
+ const m = t.network.newUser('m2');
+ const ent = t.billing.pay(m.subject, creator.subject).events[1];
+ assert.strictEqual((await t.deliver(ent)).json.duplicate, false);
+ assert.strictEqual((await t.deliver(ent)).json.duplicate, true);
+ assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject).length, 1);
+ });
+
+ test('deliveries must be signed; only Billing is believed', async () => {
+ const m = t.network.newUser('m3');
+ const ent = t.billing.pay(m.subject, creator.subject).events[1];
+ assert.strictEqual((await t.deliver(ent, { secret: 'x'.repeat(48) })).status, 401);
+ const forged = { ...ent, event_id: t.billing.envelope('x.y.z', { type: 'x', id: '1' }, {}).event_id, source: 'live' };
+ assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');
+ assert.strictEqual(t.domain.entitlements.getRow(m.subject, creator.subject), null);
+ const raw = JSON.stringify({ event: ent, seq: 1 });
+ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw + ' ', t.EVENTS_SECRET) } });
+ assert.strictEqual(res.status, 401);
+ });
+
+ test('cancel: the period still runs, then the member converges to inactive', async () => {
+ const m = t.network.newUser('m4');
+ const { sub, events } = t.billing.pay(m.subject, creator.subject);
+ await t.deliverAll(events);
+ t.clock.advance(5 * DAY);
+ await t.deliverAll(t.billing.cancel(sub.id).events);
+ let e = await check(m);
+ assert.strictEqual(e.active, true);
+ assert.strictEqual(e.cancel_at_period_end, true);
+ const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);
+ assert.strictEqual(ev[ev.length - 1].payload.reason, 'cancel_scheduled');
+ t.clock.advance(26 * DAY);
+ await t.deliverAll(t.billing.expire(sub.id, 'canceled').events);
+ e = await check(m, 'projection');
+ assert.strictEqual(e.status, 'inactive');
+ assert.strictEqual(e.active, false);
+ t.clock.advance(-31 * DAY);
+ });
+
+ test('cancel with the expiry event lost: past the period end VIP asks Billing and answers inactive', async () => {
+ const m = t.network.newUser('m5');
+ const { sub, events } = t.billing.pay(m.subject, creator.subject);
+ await t.deliverAll(events);
+ await t.deliverAll(t.billing.cancel(sub.id).events);
+ t.clock.advance(31 * DAY);
+ t.billing.expire(sub.id, 'canceled'); // event never delivered
+ const calls = t.billing.entitlementCalls();
+ const e = await check(m);
+ assert.strictEqual(e.status, 'inactive');
+ assert.strictEqual(e.source, 'billing');
+ assert.strictEqual(t.billing.entitlementCalls(), calls + 1);
+ t.clock.advance(-31 * DAY);
+ });
+
+ test('renew: the renewal event extends the membership past the old period end', async () => {
+ const m = t.network.newUser('m6');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ t.clock.advance(30 * DAY - 5 * MIN);
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events); // renewed 5 minutes before the end
+ t.clock.advance(10 * MIN); // the old period is over
+ const e = await check(m, 'projection');
+ assert.strictEqual(e.active, true);
+ assert.strictEqual(e.stale, false);
+ assert.strictEqual(Date.parse(e.expires_at), Date.parse(t.billing.entitlement(m.subject, creator.subject).expires_at));
+ const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);
+ assert.strictEqual(ev[ev.length - 1].payload.reason, 'renewed');
+ t.clock.advance(-(30 * DAY + 5 * MIN));
+ });
+
+ test('renew with the event lost: the projection stops answering; Billing confirms the renewal', async () => {
+ const m = t.network.newUser('m7');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ t.clock.advance(29 * DAY);
+ await check(m, 'authoritative'); // fresh row synced on day 29
+ t.billing.pay(m.subject, creator.subject); // renewal event never delivered
+ t.clock.advance(1 * DAY + 2 * MIN); // past the old period end + grace
+ assert.strictEqual((await check(m, 'projection')).status, 'unknown');
+ const e = await check(m);
+ assert.strictEqual(e.status, 'active');
+ assert.strictEqual(e.source, 'billing');
+ assert.strictEqual((await check(m, 'projection')).status, 'active');
+ t.clock.advance(-(30 * DAY + 2 * MIN));
+ });
+
+ test('refund: the refund events revoke at once', async () => {
+ const m = t.network.newUser('m8');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ const out = await t.deliverAll(t.billing.refund(m.subject, creator.subject).events);
+ assert.deepStrictEqual(out.map((o) => o.json.outcome), ['doubt', 'changed']);
+ const e = await check(m, 'projection');
+ assert.strictEqual(e.active, false);
+ const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);
+ assert.strictEqual(ev[ev.length - 1].payload.reason, 'refund');
+ assert.strictEqual(ev[ev.length - 1].payload.active, false);
+ });
+
+ test('refund with only the reversal delivered: the projection is in doubt and Billing decides', async () => {
+ const m = t.network.newUser('m9');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ const [reversed] = t.billing.refund(m.subject, creator.subject).events;
+ await t.deliver(reversed);
+ t.clock.advance(2 * MIN);
+ assert.strictEqual((await check(m, 'projection')).status, 'unknown');
+ const e = await check(m);
+ assert.strictEqual(e.status, 'inactive');
+ assert.strictEqual(e.source, 'billing');
+ t.clock.advance(-2 * MIN);
+ });
+
+ test('refund with every event lost: the stale "yes" ends at valid_until (max age), never later', async () => {
+ const m = t.network.newUser('m10');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ t.billing.refund(m.subject, creator.subject); // nothing delivered
+ const row = t.domain.entitlements.getRow(m.subject, creator.subject);
+ assert.strictEqual(row.valid_until - row.synced_at, t.config.projection.maxAgeMs);
+ t.clock.advance(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1);
+ assert.strictEqual((await check(m, 'projection')).active, false);
+ assert.strictEqual((await check(m, 'projection')).status, 'unknown');
+ const e = await check(m);
+ assert.strictEqual(e.status, 'inactive');
+ t.clock.advance(-(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1));
+ });
+
+ test('a stale projection cannot authorize past valid_until + grace, even with Billing down', async () => {
+ const m = t.network.newUser('m11');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ t.billing.state.down = true;
+ try {
+ t.clock.advance(t.config.projection.maxAgeMs + 10_000); // stale, inside grace
+ let e = await check(m);
+ assert.strictEqual(e.status, 'active');
+ assert.strictEqual(e.stale, true);
+ assert.strictEqual(e.reason, 'billing_unavailable');
+ assert.strictEqual((await check(m, 'authoritative')).status, 'unknown');
+ t.clock.advance(t.config.projection.graceMs); // past grace
+ e = await check(m);
+ assert.strictEqual(e.status, 'unknown');
+ assert.strictEqual(e.active, false);
+ assert.strictEqual((await check(m, 'projection')).status, 'unknown');
+ // A hundred days later it is still "unknown", never "yes".
+ t.clock.advance(100 * DAY);
+ assert.strictEqual((await check(m)).active, false);
+ } finally {
+ t.billing.state.down = false;
+ t.clock.advance(-(t.config.projection.maxAgeMs + 10_000 + t.config.projection.graceMs + 100 * DAY));
+ }
+ assert.strictEqual((await check(m)).status, 'active');
+ });
+
+ test('authoritative mode always asks Billing', async () => {
+ const m = t.network.newUser('m12');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ const calls = t.billing.entitlementCalls();
+ const e = await check(m, 'authoritative');
+ assert.strictEqual(e.source, 'billing');
+ assert.strictEqual(e.active, true);
+ assert.strictEqual(t.billing.entitlementCalls(), calls + 1);
+ });
+
+ test('an unknown member with Billing down is "unknown", not "no" and never "yes"', async () => {
+ const m = t.network.newUser('m13');
+ t.billing.state.down = true;
+ try {
+ const e = await check(m);
+ assert.strictEqual(e.status, 'unknown');
+ assert.strictEqual(e.active, false);
+ } finally { t.billing.state.down = false; }
+ });
+
+ test('out of order: an older grant after a newer revocation is ignored; an older revocation after a newer grant puts the row in doubt', async () => {
+ const m = t.network.newUser('m14');
+ const grant = t.billing.pay(m.subject, creator.subject).events[1];
+ t.clock.advance(1000);
+ const revoke = t.billing.refund(m.subject, creator.subject).events[1];
+ await t.deliver(revoke);
+ assert.strictEqual((await t.deliver(grant)).json.outcome, 'stale_ignored');
+ assert.strictEqual((await check(m, 'projection')).active, false);
+
+ const m2 = t.network.newUser('m15');
+ const g1 = t.billing.pay(m2.subject, creator.subject).events[1];
+ t.clock.advance(1000);
+ const r1 = t.billing.refund(m2.subject, creator.subject).events[1];
+ t.clock.advance(1000);
+ const g2 = t.billing.pay(m2.subject, creator.subject).events[1];
+ await t.deliver(g1);
+ await t.deliver(g2);
+ assert.strictEqual((await t.deliver(r1)).json.outcome, 'doubt');
+ t.clock.advance(2 * MIN);
+ assert.strictEqual((await check(m2, 'projection')).status, 'unknown');
+ assert.strictEqual((await check(m2)).status, 'active'); // Billing: the newer grant stands
+ });
+
+ test('the refresh job re-confirms projections in use before they go stale', async () => {
+ const m = t.network.newUser('m16');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ await check(m, 'projection'); // marks it used
+ t.clock.advance(t.config.projection.maxAgeMs - MIN);
+ const out = await t.domain.entitlements.refreshDue();
+ assert.ok(out.checked >= 1 && out.ok >= 1);
+ const row = t.domain.entitlements.getRow(m.subject, creator.subject);
+ assert.strictEqual(row.source, 'billing_check');
+ assert.ok(row.valid_until > t.clock.now() + 10 * MIN);
+ });
+
+ test('a user checks only their own entitlement; services need the capability', async () => {
+ const m = t.network.newUser('m17');
+ const other = t.network.newUser('m18');
+ const own = await t.call('GET', `/api/v1/entitlements/check?creator=${creator.subject}`, { user: m });
+ assert.strictEqual(own.status, 200);
+ assert.strictEqual(own.json.member.id, m.subject);
+ const theirs = await t.call('GET', `/api/v1/entitlements/check?creator=${creator.subject}&subject=${other.subject}`, { user: m });
+ assert.strictEqual(theirs.status, 403);
+ const noCap = await t.call('POST', '/api/v1/entitlements/check', { cap: ['vip.plan.list'], body: { subject: m.subject, creator: creator.subject } });
+ assert.strictEqual(noCap.status, 403);
+ const byName = await t.call('POST', '/api/v1/entitlements/check', { ...svc, body: { subject: m.subject, creator: 'dora' } });
+ assert.strictEqual(byName.status, 200, byName.text);
+ });
+
+ await run().finally(() => t.close());
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+/**
+ * Boots VIP against the stubs on a random port with a temp database and an injected clock shared
+ * with the Billing stub. Jobs are off: tests drive refreshes explicitly.
+ *
+ * t.call(method, path, { body, user, cap, sub, token }) user → Bearer user JWT; else a service token with `cap`
+ * t.deliver(envelope) a signed OpenVibe.Events delivery to /internal/events
+ * t.page(path, { user, method, form }) an SSR page (cookie session), form posts
+ * t.clock.advance(ms)
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { signDelivery } = require('openvibe-sdk/events');
+const { startNetwork, startBilling } = require('./stubs');
+
+const EVENTS_SECRET = 'e'.repeat(48);
+
+async function boot(opts = {}) {
+ const network = await startNetwork();
+ const clock = { t: Date.now(), now() { return this.t; }, advance(ms) { this.t += ms; } };
+ clock.now = clock.now.bind(clock);
+ const billing = await startBilling(network, clock);
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vip-test-'));
+ const env = {
+ NODE_ENV: 'test',
+ VIP_DB_PATH: path.join(dir, 'vip.db'),
+ BASE_URL: 'http://vip.test',
+ OV_NETWORK_URL: network.url,
+ OV_NETWORK_INTERNAL_URL: network.url,
+ OV_NETWORK_ISSUER: network.url,
+ OV_NETWORK_PUBLIC_KEY: network.publicPem,
+ OV_OAUTH_CLIENT_ID: 'vip',
+ OV_OAUTH_CLIENT_SECRET: 'shh',
+ VIP_FORM_SECRET: 'form-secret-for-tests',
+ BILLING_URL: billing.url,
+ VIP_BILLING_TIMEOUT_MS: '1500',
+ VIP_CHECKOUT_PROVIDERS: 'powerchat,stripe,credit',
+ VIP_EVENTS_SECRET: EVENTS_SECRET,
+ VIP_JOBS: 'off',
+ VIP_PROJECTION_MAX_AGE_MS: String(15 * 60 * 1000),
+ VIP_PROJECTION_GRACE_MS: String(60 * 1000),
+ ...(opts.env || {}),
+ };
+ for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];
+ const { loadConfig } = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const config = loadConfig(env);
+ const logs = [];
+ const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')), debug() {} };
+ const app = createApp({ config, now: clock.now, log });
+ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const domain = app.locals.domain;
+
+ async function call(method, p, { body, user, cap = [], sub = 'svc:live', token, headers = {} } = {}) {
+ const h = { ...headers };
+ if (token !== null) h.Authorization = `Bearer ${token || (user ? network.signUser(user) : network.signService({ sub, cap }))}`;
+ if (body !== undefined) h['Content-Type'] = 'application/json';
+ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });
+ const text = await res.text();
+ let json = null; try { json = JSON.parse(text); } catch { /* html */ }
+ return { status: res.status, headers: res.headers, json, text };
+ }
+
+ async function deliver(event, { secret = EVENTS_SECRET, seq = 1 } = {}) {
+ const raw = JSON.stringify({ event, seq });
+ const res = await fetch(`${base}/internal/events`, {
+ method: 'POST', body: raw,
+ headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },
+ });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ }
+ async function deliverAll(events) { const out = []; for (const e of events) out.push(await deliver(e)); return out; }
+
+ /** An SSR request as a signed-in person (ov_token cookie) or anonymous. */
+ async function page(p, { user, method = 'GET', form } = {}) {
+ const headers = {};
+ if (user) headers.Cookie = `ov_token=${network.signUser(user)}`;
+ let bodyStr;
+ if (form) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; bodyStr = new URLSearchParams(form).toString(); }
+ const res = await fetch(base + p, { method, headers, body: bodyStr, redirect: 'manual' });
+ return { status: res.status, location: res.headers.get('location'), text: await res.text() };
+ }
+ const csrf = (html) => { const m = /name="_csrf" value="([^"]+)"/.exec(html); return m ? m[1] : null; };
+
+ const outboxEvents = (type) => domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type);
+
+ async function close() {
+ await new Promise((r) => server.close(r));
+ app.locals.outbox.stop();
+ await billing.close();
+ await network.close();
+ try { domain.db.close(); } catch { /* */ }
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+
+ return { app, base, config, domain, network, billing, clock, logs, call, deliver, deliverAll, page, csrf, outboxEvents, close, EVENTS_SECRET };
+}
+
+/** Tiny test harness: sequential async tests, a summary, exit code. */
+function harness(name) {
+ const tests = [];
+ const test = (title, fn) => tests.push({ title, fn });
+ async function run() {
+ let failed = 0;
+ for (const t of tests) {
+ try { await t.fn(); console.log(` ✓ ${t.title}`); } catch (e) { failed++; console.log(` ✗ ${t.title}\n${e.stack}`); }
+ }
+ console.log(`${name}: ${tests.length - failed}/${tests.length} passed`);
+ process.exit(failed ? 1 : 0);
+ }
+ return { test, run };
+}
+
+module.exports = { boot, harness, EVENTS_SECRET };
new file mode 100644
new file mode 100644
@@ -0,0 +1,254 @@
@@ -0,0 +1,254 @@
+'use strict';
+/**
+ * Stand-ins for the services VIP talks to, each on a random port with a real RS256 key pair.
+ *
+ * startNetwork() JWKS, client-credentials token endpoint (scope → cap), user JWTs
+ * (signUser), resolve-batch for the importer
+ * startBilling(net, clock) the parts of OpenVibe.Billing VIP uses — intents, credit subscriptions,
+ * cancel, subscriptions list/get, entitlements, rates, health — with
+ * Billing's rules that matter here (service token audience + capability,
+ * Idempotency-Key replay, self-dealing, one subscription per pair, periods
+ * on the injected clock), plus the envelopes Billing would publish for each
+ * change (grant, renew, cancel, expire, refund) so tests deliver them
+ */
+const http = require('http');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+const DAY = 86_400_000;
+
+function listen(server) {
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)));
+}
+function readBody(req) {
+ return new Promise((resolve) => { const c = []; req.on('data', (d) => c.push(d)); req.on('end', () => resolve(Buffer.concat(c).toString('utf8'))); });
+}
+const send = (res, status, obj) => { res.writeHead(status, { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' }); res.end(JSON.stringify(obj)); };
+
+async function startNetwork() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const legacy = {};
+ const grants = [];
+ const state = { resolveDown: false };
+ let issuer = 'http://network.test';
+ let n = 0;
+
+ function signService({ sub = 'svc:live', aud = ['openvibe.vip'], cap = [], expSec = 300 } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);
+ }
+ function signUser(u) {
+ return jwt.sign({ sub: String(++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user' },
+ privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
+ }
+ function newUser(username, { liveId = null, role = 'user' } = {}) {
+ const subject = ids.newId('user');
+ if (liveId != null) legacy[String(liveId)] = { subject, username };
+ return { subject, username, display_name: username[0].toUpperCase() + username.slice(1), role };
+ }
+ function mapLive(liveId, username) { const subject = ids.newId('user'); legacy[String(liveId)] = { subject, username }; return subject; }
+
+ const server = http.createServer(async (req, res) => {
+ const raw = await readBody(req);
+ if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
+ if (req.url === '/oauth/token' && req.method === 'POST') {
+ const body = Object.fromEntries(new URLSearchParams(raw));
+ grants.push(body);
+ if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
+ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
+ return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
+ }
+ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
+ if (!v.ok) return send(res, 401, { code: v.code });
+ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return send(res, 403, { code: 'capability.denied' });
+ if (state.resolveDown) return send(res, 503, { error: 'down' });
+ const body = JSON.parse(raw || '{}');
+ const results = {};
+ for (const id of body.ids || []) {
+ const s = body.system === 'live' ? legacy[String(id)] : null;
+ results[String(id)] = s ? { subject: { type: 'user', id: s.subject }, username: s.username } : null;
+ }
+ return send(res, 200, { results });
+ }
+ send(res, 404, { error: 'not found' });
+ });
+ const url = await listen(server);
+ issuer = url;
+ return { url, publicPem, signService, signUser, newUser, mapLive, legacy, grants, state, close: () => new Promise((r) => server.close(r)) };
+}
+
+/**
+ * Billing stub. Periods are 30 days on `clock.now()`. Each mutation returns the envelopes Billing
+ * would emit; tests deliver them (or not, to simulate loss).
+ */
+async function startBilling(network, clock) {
+ const subs = new Map(); // id → sub
+ const periods = []; // { sub_id, subject, scope, starts, ends, revoked, txn }
+ const idem = new Map();
+ const calls = [];
+ const state = { down: false, frozen: false, credit: new Map() };
+ const now = () => clock.now();
+ const iso = (ms) => new Date(ms).toISOString();
+ let seq = 0;
+ const nextId = (p) => `${p}_${ids.ulid(now())}${(++seq).toString(36)}`.slice(0, 40);
+
+ const pairKey = (a, b) => `${a}:${b}`;
+ const byPair = (a, b) => [...subs.values()].find((s) => s.subscriber === a && s.streamer === b) || null;
+ function entitlement(subject, scope) {
+ const t = now();
+ const live = periods.filter((p) => p.subject === subject && p.scope === scope && !p.revoked && p.starts <= t && p.ends > t);
+ let exp = live.length ? Math.max(...live.map((p) => p.ends)) : null;
+ if (exp) {
+ for (;;) {
+ const next = periods.filter((p) => p.subject === subject && p.scope === scope && !p.revoked && p.starts <= exp && p.ends > exp);
+ if (!next.length) break;
+ exp = Math.max(...next.map((p) => p.ends));
+ }
+ }
+ const s = byPair(subject, scope);
+ return {
+ subject: { type: 'user', id: subject }, streamer: { type: 'user', id: scope }, kind: 'channel_subscription', active: !!exp, expires_at: exp ? iso(exp) : null,
+ subscription: s ? { id: s.id, status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, provider: s.provider } : null,
+ };
+ }
+ const present = (s) => ({
+ id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, tier: 1, provider: s.provider,
+ status: s.status, auto_renew: s.auto_renew, cancel_at_period_end: s.cancel_at_period_end, price_cents: 499,
+ current_period_end: s.current_period_end, created_at: s.created_at, updated_at: s.updated_at, legacy_live_id: s.legacy_live_id ?? null,
+ });
+ function envelope(type, subject, payload) {
+ return {
+ event_id: ids.newId('event', now()), event_type: type, version: 1, source: 'billing', actor: { type: 'service', id: 'billing' },
+ timestamp: iso(now()), priority: 'important', visibility: 'internal', subject, payload,
+ };
+ }
+ const entEvent = (member, creator, reason, extra = {}) => envelope('billing.entitlement.changed', { type: 'entitlement', id: `${member}:channel_subscription:${creator}` }, { ...entitlement(member, creator), reason, ...extra });
+
+ /** Pay one period (grant or renew). Returns { sub, events }. */
+ function pay(member, creator, { provider = 'credit', autoRenew = true, legacyLiveId = null } = {}) {
+ const t = now();
+ let s = byPair(member, creator);
+ const renewal = !!(s && s.status === 'active');
+ const start = s && s.current_period_end && Date.parse(s.current_period_end) > t ? Date.parse(s.current_period_end) : t;
+ const end = start + 30 * DAY;
+ const txn = nextId('txn');
+ if (!s) {
+ s = { id: nextId('sub'), subscriber: member, streamer: creator, provider, created_at: iso(t), legacy_live_id: legacyLiveId };
+ subs.set(s.id, s);
+ }
+ Object.assign(s, { status: 'active', auto_renew: autoRenew, cancel_at_period_end: false, current_period_end: iso(end), updated_at: iso(t) });
+ periods.push({ sub_id: s.id, subject: member, scope: creator, starts: start, ends: end, revoked: false, txn });
+ return { sub: s, txn, events: [envelope('billing.transaction.settled', { type: 'transaction', id: txn }, { transaction_id: txn, type: 'subscription', from_subject: member, to_subject: creator }), entEvent(member, creator, renewal ? 'renewed' : 'granted', { transaction_id: txn })] };
+ }
+ function cancel(id) {
+ const s = subs.get(id);
+ s.cancel_at_period_end = true; s.auto_renew = false; s.updated_at = iso(now());
+ return { sub: s, events: [envelope('billing.subscription.canceled', { type: 'subscription', id }, { subscription: present(s), provider_sync: 'not_needed' })] };
+ }
+ /** The renewal sweep for one subscription whose period ended without renewal. */
+ function expire(id, status = 'canceled') {
+ const s = subs.get(id);
+ s.status = status; s.auto_renew = false; s.updated_at = iso(now());
+ return { sub: s, events: [entEvent(s.subscriber, s.streamer, status)] };
+ }
+ /** Refund the latest period's payment: revokes it. */
+ function refund(member, creator) {
+ const s = byPair(member, creator);
+ const p = [...periods].reverse().find((x) => x.sub_id === s.id && !x.revoked);
+ p.revoked = true;
+ const left = periods.filter((x) => x.sub_id === s.id && !x.revoked && x.ends > now());
+ if (!left.length) { s.status = 'expired'; s.current_period_end = iso(now()); s.auto_renew = false; }
+ const rtx = nextId('txn');
+ return {
+ sub: s,
+ events: [
+ envelope('billing.transaction.reversed', { type: 'transaction', id: rtx }, { transaction_id: rtx, type: 'refund', from_subject: creator, to_subject: member, reverses_txn: p.txn, entitlements_revoked: 1 }),
+ entEvent(member, creator, 'refund', { revoked_period: { starts_at: iso(p.starts), ends_at: iso(p.ends) } }),
+ ],
+ };
+ }
+
+ function auth(req, cap) {
+ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.billing' });
+ if (!v.ok) return [401, { code: v.code }];
+ if (cap && !(v.claims.cap || []).includes(cap)) return [403, { code: 'capability.denied', detail: `${cap} not granted` }];
+ return null;
+ }
+
+ const server = http.createServer(async (req, res) => {
+ const raw = await readBody(req);
+ const u = new URL(req.url, 'http://x');
+ const p = u.pathname;
+ calls.push({ method: req.method, path: p, query: Object.fromEntries(u.searchParams), key: req.headers['idempotency-key'] || null, body: raw ? JSON.parse(raw) : null });
+ if (p === '/api/health') return state.down ? send(res, 503, { ok: false }) : send(res, 200, { ok: true });
+ if (state.down) { req.socket.destroy(); return undefined; }
+ const need = (cap) => { const e = auth(req, cap); if (e) { send(res, e[0], e[1]); return false; } return true; };
+ const body = raw ? JSON.parse(raw) : {};
+ const replay = () => { const k = req.headers['idempotency-key']; if (k && idem.has(k)) { const r = idem.get(k); send(res, r.status, r.body); return true; } return false; };
+ const store = (status, out) => { const k = req.headers['idempotency-key']; if (k) idem.set(k, { status, body: out }); send(res, status, out); };
+
+ if (p === '/api/v1/rates' && req.method === 'GET') {
+ return send(res, 200, { currency: 'vibes-bits', subscription: { price_cents: 499, streamer_share_pct: 70, site_route_fee_pct: 10, period_days: 30 }, providers: { powerchat: true, stripe: true } });
+ }
+ if (p === '/api/v1/intents' && req.method === 'POST') {
+ if (!need('billing.intent.create')) return undefined;
+ if (replay()) return undefined;
+ if (body.subject.id === body.streamer.id) return send(res, 422, { code: 'billing.self_dealing' });
+ if (!['powerchat', 'stripe'].includes(body.provider)) return send(res, 409, { code: 'billing.provider_disabled' });
+ const id = nextId('pi');
+ const intent = { id, provider: body.provider, kind: 'subscription', subject: body.subject, streamer: body.streamer, amount_cents: body.provider === 'powerchat' ? 549 : 499, status: 'created', checkout_ref: body.provider === 'powerchat' ? `pcsub:${id}` : undefined };
+ return store(201, { intent, checkout_url: body.provider === 'stripe' ? `https://checkout.stripe.test/${id}` : null });
+ }
+ if (p === '/api/v1/subscriptions' && req.method === 'POST') {
+ if (!need('billing.subscription.manage')) return undefined;
+ if (replay()) return undefined;
+ if (state.frozen) return send(res, 503, { code: 'billing.frozen' });
+ const member = body.subscriber.id; const creator = body.streamer.id;
+ if (member === creator) return send(res, 422, { code: 'billing.self_dealing' });
+ const credit = state.credit.get(member) || 0;
+ if (credit < 499) return send(res, 409, { code: 'billing.insufficient_funds', detail: 'not enough credit' });
+ state.credit.set(member, credit - 499);
+ const out = pay(member, creator, { autoRenew: body.auto_renew !== false });
+ state.lastEvents = out.events;
+ return store(201, { subscription: present(out.sub), entitlement: entitlement(member, creator), transaction: { id: out.txn } });
+ }
+ let m = p.match(/^\/api\/v1\/subscriptions\/([^/]+)\/cancel$/);
+ if (m && req.method === 'POST') {
+ if (!need('billing.subscription.manage')) return undefined;
+ if (replay()) return undefined;
+ const s = subs.get(m[1]);
+ if (!s) return send(res, 404, { code: 'billing.subscription_not_found' });
+ const out = cancel(s.id);
+ state.lastEvents = out.events;
+ return store(200, { subscription: present(out.sub), provider_sync: 'not_needed' });
+ }
+ m = p.match(/^\/api\/v1\/subscriptions\/([^/]+)$/);
+ if (m && req.method === 'GET') {
+ if (!need('billing.entitlement.check')) return undefined;
+ const s = subs.get(m[1]);
+ if (!s) return send(res, 404, { code: 'billing.subscription_not_found' });
+ return send(res, 200, { subscription: present(s), entitlement: entitlement(s.subscriber, s.streamer) });
+ }
+ if (p === '/api/v1/subscriptions' && req.method === 'GET') {
+ if (!need('billing.entitlement.check')) return undefined;
+ const q = Object.fromEntries(u.searchParams);
+ const list = [...subs.values()].filter((s) => (!q.streamer || s.streamer === q.streamer) && (!q.subscriber || s.subscriber === q.subscriber) && (!q.status || s.status === q.status));
+ return send(res, 200, { subscriptions: list.map(present) });
+ }
+ m = p.match(/^\/api\/v1\/entitlements\/([^/]+)$/);
+ if (m && req.method === 'GET') {
+ if (!need('billing.entitlement.check')) return undefined;
+ return send(res, 200, entitlement(m[1], u.searchParams.get('streamer')));
+ }
+ return send(res, 404, { code: 'not_found' });
+ });
+ const url = await listen(server);
+ const entitlementCalls = () => calls.filter((c) => c.path.startsWith('/api/v1/entitlements/')).length;
+ return { url, subs, periods, calls, state, pay, cancel, expire, refund, entitlement, envelope, entitlementCalls, byPair, pairKey, close: () => new Promise((r) => server.close(r)) };
+}
+
+module.exports = { startNetwork, startBilling, DAY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -0,0 +1,157 @@
+'use strict';
+/**
+ * scripts/import-live.js: Live's subscription offering → creators, the subscriber-badge perk and a
+ * v1 "Channel subscription" plan; memberships from Billing (never from Live's rows); every Live row
+ * reconciled; idempotent; holds released on a later run; dry run keeps nothing.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const Database = require('better-sqlite3');
+const { boot, harness } = require('./helpers/app');
+
+const { test, run } = harness('import');
+
+function liveSnapshot() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vip-live-'));
+ const file = path.join(dir, 'live.db');
+ const db = new Database(file);
+ db.exec(`
+ CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, display_name TEXT, role TEXT DEFAULT 'user');
+ CREATE TABLE subscriptions (id INTEGER PRIMARY KEY, subscriber_id INTEGER, streamer_id INTEGER, tier INTEGER DEFAULT 1, is_active INTEGER,
+ started_at TEXT, expires_at TEXT, status TEXT, current_period_end TEXT, provider TEXT);
+ CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT);
+ INSERT INTO site_settings VALUES ('sub_price_usd', '4.99'), ('sub_streamer_share_pct', '70'), ('sub_site_route_fee_pct', '10');
+ INSERT INTO users VALUES (1, 'japaneseoldguy', 'Japanese Old Guy', 'streamer'), (2, 'viewer1', 'Viewer One', 'user'),
+ (3, 'viewer2', NULL, 'user'), (4, 'newstreamer', NULL, 'streamer'), (5, 'casual', NULL, 'user'), (6, 'selfmade', 'Self Made', 'streamer');
+ INSERT INTO subscriptions VALUES (1, 2, 1, 1, 1, '2026-09-01 10:00:00', NULL, 'active', '2026-10-01 10:00:00', 'bucks'),
+ (2, 3, 1, 1, 0, '2026-07-01 10:00:00', NULL, 'expired', '2026-08-01 10:00:00', 'stripe'),
+ (3, 2, 5, 1, 1, '2026-09-10 10:00:00', NULL, 'active', '2026-10-10 10:00:00', 'bucks');
+ `);
+ db.close();
+ return { file, dir };
+}
+
+(async () => {
+ const t = await boot();
+ const { importLive, billingApiSource } = require('../server/importer/live');
+ const { createIdentity } = require('../server/network');
+ const identity = createIdentity(t.config);
+ const snap = liveSnapshot();
+ const live = new Database(snap.file, { readonly: true, fileMustExist: true });
+
+ // Network knows every Live user but #4 (held); Billing imported Live's subscriptions already.
+ const S = {};
+ for (const [id, name] of [[1, 'japaneseoldguy'], [2, 'viewer1'], [3, 'viewer2'], [5, 'casual'], [6, 'selfmade']]) S[id] = t.network.mapLive(id, name);
+ t.billing.pay(S[2], S[1], { legacyLiveId: 1 });
+ const expired = t.billing.pay(S[3], S[1], { legacyLiveId: 2, provider: 'stripe' }).sub;
+ t.billing.expire(expired.id, 'expired');
+ t.billing.pay(S[2], S[5], { legacyLiveId: 3 });
+ // #6 already made their own plan on VIP before the import.
+ const selfmade = { subject: S[6], username: 'selfmade', role: 'streamer' };
+ const own = (await t.call('POST', '/api/v1/plans', { user: selfmade, body: { name: 'My own plan', publish: true } })).json.plan;
+
+ const doImport = (opts = {}) => importLive(t.domain, { live, billingSource: billingApiSource(t.domain.billing), resolveLiveUsers: identity.resolveLiveUsers, log: null, ...opts });
+ const snapshotCounts = () => Object.fromEntries(['vip_creators', 'vip_plans', 'vip_plan_versions', 'vip_perks', 'vip_memberships', 'vip_migration_maps', 'vip_entitlement_projection', 'event_outbox']
+ .map((x) => [x, t.domain.db.prepare(`SELECT COUNT(*) AS n FROM ${x}`).get().n]));
+
+ test('a dry run reports everything and keeps nothing', async () => {
+ const before = snapshotCounts();
+ const r = await doImport({ dryRun: true });
+ assert.strictEqual(r.dry_run, true);
+ assert.strictEqual(r.counts.plans.created, 2);
+ assert.deepStrictEqual(snapshotCounts(), before);
+ });
+
+ let first;
+ test('the first run imports the offering, files Billing memberships and reconciles every Live row', async () => {
+ first = await doImport();
+ assert.strictEqual(first.ok, true, JSON.stringify(first.billing_errors));
+ assert.strictEqual(first.live_offering.sub_price_usd, '4.99');
+ assert.deepStrictEqual(first.counts.live, { users: 6, creators: 4, subscriptions: 3 });
+ assert.deepStrictEqual(first.counts.creators, { imported: 3, unchanged: 0, held: 1, released: 0 });
+ assert.deepStrictEqual(first.counts.plans, { created: 2, linked: 1, unchanged: 0 });
+ assert.deepStrictEqual(first.counts.memberships, { imported: 2, unchanged: 0, excluded: 1 });
+ assert.deepStrictEqual(first.counts.live_subscriptions, { linked: 3, held: 0, unchanged: 0 });
+ assert.ok(first.holds.some((h) => h.live_user === 4));
+
+ const perk = t.domain.perks.byKey('network', 'subscriber-badge');
+ assert.deepStrictEqual(t.domain.perks.bindingsOf(perk.id).map((b) => `${b.product}:${b.binding}`).sort(), ['chat:badge', 'live:ai_context', 'live:chat_badge', 'live:powerchat_overlay']);
+ const jog = t.domain.creators.bySubject(S[1]);
+ assert.strictEqual(jog.username, 'japaneseoldguy');
+ assert.strictEqual(jog.display_name, 'Japanese Old Guy');
+ const plan = t.domain.plans.purchasable(jog.id);
+ assert.strictEqual(plan.slug, 'channel-subscription');
+ assert.strictEqual(plan.latest_version, 1);
+ assert.deepStrictEqual(t.domain.plans.versionPerks(plan.current_version_id).map((p) => p.perk_key), ['subscriber-badge']);
+ const terms = JSON.parse(t.domain.plans.version(plan.current_version_id).terms);
+ assert.strictEqual(terms.price, 'set and charged by OpenVibe.Billing');
+ assert.doesNotMatch(JSON.stringify(terms), /\bfree\b|\$0|no ads/i);
+
+ const m = t.domain.memberships.get(S[2], jog.id);
+ assert.strictEqual(m.origin, 'import');
+ assert.strictEqual(m.plan_version_id, plan.current_version_id);
+ assert.strictEqual(t.domain.memberships.get(S[3], jog.id), null, 'an expired Billing subscription is not a membership');
+ assert.strictEqual(t.domain.plans.purchasable(t.domain.creators.bySubject(S[6]).id).id, own.id, 'the creator\'s own plan is linked, not replaced');
+ const e = await t.call('GET', `/api/v1/entitlements/check?creator=${S[1]}&mode=projection`, { user: { subject: S[2], username: 'viewer1' } });
+ assert.strictEqual(e.json.status, 'active');
+
+ const maps = t.domain.db.prepare("SELECT status, COUNT(*) AS n FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' GROUP BY status").all();
+ assert.deepStrictEqual(maps, [{ status: 'linked', n: 3 }]);
+ const expiredRow = t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '2'").get();
+ assert.strictEqual(expiredRow.target_type, 'billing_subscription');
+ assert.strictEqual(t.outboxEvents('vip.plan.published').length, 3);
+ });
+
+ test('a second run changes nothing', async () => {
+ const before = snapshotCounts();
+ const again = await doImport();
+ assert.deepStrictEqual(snapshotCounts(), before);
+ assert.deepStrictEqual(again.counts.plans, { created: 0, linked: 0, unchanged: 3 });
+ assert.deepStrictEqual(again.counts.memberships, { imported: 0, unchanged: 3, excluded: 0 });
+ assert.deepStrictEqual(again.counts.live_subscriptions, { linked: 0, held: 0, unchanged: 3 });
+ assert.strictEqual(again.counts.creators.held, 1);
+ });
+
+ test('a creator\'s edits after the import are never overwritten', async () => {
+ const jog = t.domain.creators.bySubject(S[1]);
+ const plan = t.domain.plans.purchasable(jog.id);
+ t.domain.plans.update(plan.id, { name: 'Japanese Old Guy club', actor: S[1] });
+ await doImport();
+ assert.strictEqual(t.domain.plans.latestVersion(plan.id).version, 2);
+ assert.strictEqual(t.domain.plans.latestVersion(plan.id).name, 'Japanese Old Guy club');
+ });
+
+ test('a held creator is released once the Network knows them', async () => {
+ S[4] = t.network.mapLive(4, 'newstreamer');
+ const r = await doImport();
+ assert.strictEqual(r.counts.creators.released, 1);
+ assert.strictEqual(r.counts.creators.held, 0);
+ assert.strictEqual(r.counts.plans.created, 1);
+ assert.strictEqual(t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'users' AND source_id = '4'").get().status, 'imported');
+ });
+
+ test('Billing unreachable: the run says so, holds what it could not reconcile and never invents memberships', async () => {
+ const x = t.network.mapLive(7, 'late');
+ const db2 = new Database(snap.file);
+ db2.exec("INSERT INTO users VALUES (7, 'late', NULL, 'streamer'); INSERT INTO subscriptions VALUES (4, 2, 7, 1, 1, '2026-09-20 10:00:00', NULL, 'active', '2026-10-20 10:00:00', 'bucks');");
+ db2.close();
+ t.billing.pay(S[2], x);
+ t.billing.state.down = true;
+ let r;
+ try { r = await doImport(); } finally { t.billing.state.down = false; }
+ assert.strictEqual(r.ok, false);
+ assert.ok(r.billing_errors.length >= 1);
+ assert.strictEqual(t.domain.memberships.get(S[2], t.domain.creators.bySubject(x).id), null);
+ const row = t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get();
+ assert.strictEqual(row.status, 'held');
+ assert.match(row.reason, /Billing was not reachable/);
+ const r2 = await doImport();
+ assert.strictEqual(r2.ok, true);
+ assert.strictEqual(t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get().status, 'linked');
+ assert.ok(t.domain.memberships.get(S[2], t.domain.creators.bySubject(x).id));
+ });
+
+ await run().finally(() => { live.close(); fs.rmSync(snap.dir, { recursive: true, force: true }); return t.close(); });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+/**
+ * Plans are versioned: an edit creates a new version, historical terms are never rewritten, and a
+ * membership keeps the version it was bought under (renewals too); a new purchase after a lapse
+ * takes the current version.
+ */
+const assert = require('assert');
+const { boot, harness } = require('./helpers/app');
+const { DAY } = require('./helpers/stubs');
+
+const { test, run } = harness('plans');
+
+(async () => {
+ const t = await boot();
+ const creator = t.network.newUser('alice', { role: 'streamer' });
+ const early = t.network.newUser('bob');
+ const late = t.network.newUser('carol');
+ let plan;
+
+ test('a creator defines perks and publishes a plan (v1) with them', async () => {
+ const perk = await t.call('POST', '/api/v1/perks', { user: creator, body: { name: 'Members emotes', key: 'emotes', kind: 'emote', bindings: [{ product: 'chat', binding: 'emote_set', config: { set: 'alice' } }] } });
+ assert.strictEqual(perk.status, 201, perk.text);
+ assert.deepStrictEqual(perk.json.perk.bindings, [{ product: 'chat', binding: 'emote_set', config: { set: 'alice' } }]);
+ const perk2 = await t.call('POST', '/api/v1/perks', { user: creator, body: { name: 'Supporter role', key: 'supporter-role', kind: 'role' } });
+ assert.strictEqual(perk2.status, 201);
+ const res = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Inner circle', description: 'Monthly support', benefits: ['Emotes', 'A role'], perks: ['emotes', 'supporter-role'], publish: true } });
+ assert.strictEqual(res.status, 201, res.text);
+ plan = res.json.plan;
+ assert.strictEqual(plan.status, 'published');
+ assert.strictEqual(plan.billing_kind, 'channel_subscription');
+ assert.strictEqual(plan.current_version.version, 1);
+ assert.deepStrictEqual(plan.current_version.perks.map((p) => p.key), ['emotes', 'supporter-role']);
+ const ev = t.outboxEvents('vip.plan.published');
+ assert.strictEqual(ev.length, 1);
+ assert.strictEqual(ev[0].subject.id, plan.id);
+ assert.strictEqual(ev[0].payload.version, 1);
+ assert.strictEqual(ev[0].visibility, 'public');
+ });
+
+ test('a member who joins under v1 is filed under v1', async () => {
+ await t.deliverAll(t.billing.pay(early.subject, creator.subject).events);
+ const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);
+ assert.strictEqual(m.plan_version_id, plan.current_version.id);
+ });
+
+ test('editing a published plan creates v2, publishes it, and never touches v1', async () => {
+ const before = t.domain.plans.version(plan.current_version.id);
+ const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'Inner circle', perks: ['supporter-role'], benefits: ['A role'], change_note: 'emotes moved to a separate plan' } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json.version.version, 2);
+ assert.strictEqual(res.json.plan.current_version.version, 2);
+ const after = t.domain.plans.version(plan.current_version.id);
+ assert.deepStrictEqual(after, before, 'v1 row unchanged');
+ const versions = await t.call('GET', `/api/v1/plans/${plan.id}/versions`, { token: null });
+ assert.deepStrictEqual(versions.json.versions.map((v) => v.version), [2, 1]);
+ assert.deepStrictEqual(versions.json.versions[1].perks.map((p) => p.key), ['emotes', 'supporter-role']);
+ assert.strictEqual(t.outboxEvents('vip.plan.published').length, 2);
+ });
+
+ test('the database refuses to rewrite or delete a version', () => {
+ assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_versions SET name = 'rewritten' WHERE id = ?").run(plan.current_version.id), /immutable/);
+ assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_versions SET terms = '{}' WHERE id = ?").run(plan.current_version.id), /immutable/);
+ assert.throws(() => t.domain.db.prepare('DELETE FROM vip_plan_versions WHERE id = ?').run(plan.current_version.id), /immutable/);
+ assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_perks SET perk_name = 'x' WHERE plan_version_id = ?").run(plan.current_version.id), /immutable/);
+ });
+
+ test('an edit that changes nothing creates no version', async () => {
+ const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'Inner circle' } });
+ assert.strictEqual(res.json.unchanged, true);
+ assert.strictEqual(res.json.plan.current_version.version, 2);
+ });
+
+ test('renaming a perk does not change published terms', async () => {
+ const perk = t.domain.perks.byKey(t.domain.creators.bySubject(creator.subject).id, 'emotes');
+ await t.call('PATCH', `/api/v1/perks/${perk.id}`, { user: creator, body: { name: 'Renamed emotes' } });
+ const v1 = (await t.call('GET', `/api/v1/plans/${plan.id}/versions`, { token: null })).json.versions[1];
+ assert.strictEqual(v1.perks.find((p) => p.key === 'emotes').name, 'Members emotes');
+ });
+
+ test('old members keep old terms; new members get the new version', async () => {
+ await t.deliverAll(t.billing.pay(late.subject, creator.subject).events);
+ const cid = t.domain.creators.bySubject(creator.subject).id;
+ const mEarly = t.domain.memberships.get(early.subject, cid);
+ const mLate = t.domain.memberships.get(late.subject, cid);
+ assert.strictEqual(t.domain.plans.version(mEarly.plan_version_id).version, 1);
+ assert.strictEqual(t.domain.plans.version(mLate.plan_version_id).version, 2);
+ assert.deepStrictEqual(t.domain.memberships.perkKeys(mEarly), ['emotes', 'supporter-role']);
+ assert.deepStrictEqual(t.domain.memberships.perkKeys(mLate), ['supporter-role']);
+ const status = await t.call('GET', `/api/v1/memberships/${early.subject}`, { user: early });
+ assert.strictEqual(status.status, 200);
+ assert.strictEqual(status.json.memberships[0].plan_version.version, 1);
+ assert.strictEqual(status.json.memberships[0].entitlement.status, 'active');
+ });
+
+ test('a renewal keeps the version the membership was bought under', async () => {
+ t.clock.advance(29 * DAY);
+ await t.deliverAll(t.billing.pay(early.subject, creator.subject).events); // reason: renewed
+ const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);
+ assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 1);
+ });
+
+ test('a new purchase after the membership lapsed takes the current version', async () => {
+ const sub = t.billing.byPair(late.subject, creator.subject);
+ await t.deliverAll(t.billing.cancel(sub.id).events);
+ t.clock.advance(2 * DAY); // late's period (30 days from day 0) ended on day 30
+ await t.deliverAll(t.billing.expire(sub.id, 'canceled').events);
+ await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { benefits: ['A role', 'Monthly Q&A'], change_note: 'added Q&A' } });
+ await t.deliverAll(t.billing.pay(late.subject, creator.subject).events); // reason: granted
+ const m = t.domain.memberships.get(late.subject, t.domain.creators.bySubject(creator.subject).id);
+ assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 3);
+ });
+
+ test('Billing sells one channel subscription per creator: a second published plan is refused', async () => {
+ const res = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second', publish: true } });
+ assert.strictEqual(res.status, 409);
+ assert.strictEqual(res.json.code, 'vip.plan.billing_product_taken');
+ const draft = await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Second' } });
+ assert.strictEqual(draft.status, 201);
+ assert.strictEqual((await t.call('POST', `/api/v1/plans/${draft.json.plan.id}/publish`, { user: creator })).status, 409);
+ assert.strictEqual((await t.call('POST', `/api/v1/plans/${plan.id}/archive`, { user: creator })).status, 200);
+ const pub = await t.call('POST', `/api/v1/plans/${draft.json.plan.id}/publish`, { user: creator });
+ assert.strictEqual(pub.status, 200, pub.text);
+ });
+
+ test('archived plans keep their members on their terms and cannot be edited', async () => {
+ const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);
+ assert.strictEqual(m.plan_id, plan.id);
+ const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'x' } });
+ assert.strictEqual(res.status, 409);
+ });
+
+ test('only the creator (or a service holding the capability) manages a plan', async () => {
+ const other = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: early, body: { name: 'hijack' } });
+ assert.strictEqual(other.status, 403);
+ const svcNo = await t.call('POST', '/api/v1/plans', { cap: ['vip.plan.list'], body: { creator: { type: 'user', id: creator.subject }, name: 'x' } });
+ assert.strictEqual(svcNo.status, 403);
+ assert.strictEqual(svcNo.json.code, 'capability.denied');
+ const svc = await t.call('POST', '/api/v1/plans', { cap: ['vip.plan.create'], body: { creator: { type: 'user', id: creator.subject }, name: 'Via Live' } });
+ assert.strictEqual(svc.status, 201, svc.text);
+ assert.strictEqual(svc.json.plan.status, 'draft');
+ const anon = await t.call('POST', '/api/v1/plans', { token: null, body: { name: 'x' } });
+ assert.strictEqual(anon.status, 401);
+ });
+
+ test('network plans are staff-only and have no Billing product yet', async () => {
+ const staff = t.network.newUser('root', { role: 'admin' });
+ const no = await t.call('POST', '/api/v1/plans', { user: creator, body: { creator: 'network', name: 'Network pass' } });
+ assert.strictEqual(no.status, 403);
+ const yes = await t.call('POST', '/api/v1/plans', { user: staff, body: { creator: 'network', name: 'Network pass', publish: true } });
+ assert.strictEqual(yes.status, 201, yes.text);
+ assert.strictEqual(yes.json.plan.billing_kind, null);
+ assert.strictEqual(yes.json.plan.purchasable, false);
+ const co = await t.call('POST', '/api/v1/checkout', { user: early, body: { plan_id: yes.json.plan.id, provider: 'stripe' } });
+ assert.strictEqual(co.status, 409);
+ assert.strictEqual(co.json.code, 'vip.checkout.unavailable');
+ });
+
+ test('drafts are hidden from the public', async () => {
+ const drafts = t.domain.plans.list({ creatorId: t.domain.creators.bySubject(creator.subject).id, includeDrafts: true }).filter((p) => p.status === 'draft');
+ const res = await t.call('GET', `/api/v1/plans/${drafts[0].id}`, { token: null });
+ assert.strictEqual(res.status, 404);
+ const own = await t.call('GET', `/api/v1/plans/${drafts[0].id}`, { user: creator });
+ assert.strictEqual(own.status, 200);
+ });
+
+ await run().finally(() => t.close());
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+/**
+ * Gated-resource policy: "may subject S see resource R?" — and every doubt is a no (fail closed).
+ * Also the consumer seam (openvibe-vip/client), which turns every failure into a denial.
+ */
+const assert = require('assert');
+const { boot, harness } = require('./helpers/app');
+const { createVipClient } = require('../client/vip-client');
+
+const { test, run } = harness('policies');
+
+(async () => {
+ const t = await boot();
+ const creator = t.network.newUser('erin', { role: 'streamer' });
+ const member = t.network.newUser('finn');
+ const oldMember = t.network.newUser('gus');
+ const stranger = t.network.newUser('hal');
+ const blog = { cap: ['vip.resource.policy.evaluate'], sub: 'svc:blog' };
+ const post = { service: 'blog', type: 'post', id: '42' };
+ const evaluate = async (subject, resource = post, extra = {}) => (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject, resource, ...extra } })).json;
+ let plan;
+
+ await t.call('POST', '/api/v1/perks', { user: creator, body: { name: 'Backstage posts', key: 'backstage', kind: 'gated_content', bindings: [{ product: 'blog', binding: 'gated_post' }] } });
+ plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Backstage', perks: ['backstage'], publish: true } })).json.plan;
+ await t.deliverAll(t.billing.pay(oldMember.subject, creator.subject).events); // joins under v1 (with the perk)
+ await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { perks: [] } }); // v2 drops it
+ await t.deliverAll(t.billing.pay(member.subject, creator.subject).events); // joins under v2
+
+ test('no rule for a resource → denied', async () => {
+ const d = await evaluate(member.subject);
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'no_rule']);
+ });
+
+ test('a creator gates a resource; members pass, others are refused', async () => {
+ const r = await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: post, requirement: 'member' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual(r.json.rule.resource, post);
+ let d = await evaluate(member.subject);
+ assert.deepStrictEqual([d.allow, d.reason], [true, 'member']);
+ d = await evaluate(stranger.subject);
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'not_a_member']);
+ d = await evaluate(null);
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'not_signed_in']);
+ d = await evaluate('gst_01J0000000000000000000000Z');
+ assert.strictEqual(d.allow, false);
+ d = await evaluate(creator.subject);
+ assert.deepStrictEqual([d.allow, d.reason], [true, 'owner']);
+ });
+
+ test('a perk rule follows the version the member bought: v1 has the perk, v2 does not', async () => {
+ const res = { service: 'blog', type: 'post', id: 'backstage-1' };
+ await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'perk', perk_key: 'backstage' } });
+ assert.strictEqual((await evaluate(oldMember.subject, res)).allow, true);
+ const d = await evaluate(member.subject, res);
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'perk_missing']);
+ });
+
+ test('a plan rule admits members of that plan only', async () => {
+ const res = { service: 'wiki', type: 'page', id: 'p1' };
+ const r = await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'plan', plan_id: plan.id } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual((await evaluate(member.subject, res)).allow, true);
+ assert.strictEqual((await evaluate(stranger.subject, res)).allow, false);
+ });
+
+ test('an entitlement VIP cannot confirm is a denial (Billing down, no fresh projection)', async () => {
+ const newcomer = t.network.newUser('ivy');
+ t.billing.state.down = true;
+ try {
+ const d = await evaluate(newcomer.subject);
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'entitlement_unknown']);
+ t.clock.advance(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1);
+ const d2 = await evaluate(member.subject);
+ assert.deepStrictEqual([d2.allow, d2.reason], [false, 'entitlement_unknown']);
+ } finally {
+ t.billing.state.down = false;
+ t.clock.advance(-(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1));
+ }
+ });
+
+ test('a sensitive rule always asks Billing: with Billing down even a fresh member is refused', async () => {
+ const res = { service: 'community', type: 'space', id: 'vault' };
+ await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'member', sensitive: true } });
+ const calls = t.billing.entitlementCalls();
+ assert.strictEqual((await evaluate(member.subject, res)).allow, true);
+ assert.strictEqual(t.billing.entitlementCalls(), calls + 1);
+ t.billing.state.down = true;
+ try {
+ const d = await evaluate(member.subject, res);
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'entitlement_unknown']);
+ assert.strictEqual((await evaluate(member.subject, post)).allow, true, 'a non-sensitive rule still uses the fresh projection');
+ } finally { t.billing.state.down = false; }
+ });
+
+ test('a refunded member loses access as soon as the refund arrives', async () => {
+ const res = { service: 'blog', type: 'post', id: 'refund-check' };
+ await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res } });
+ const x = t.network.newUser('jay');
+ await t.deliverAll(t.billing.pay(x.subject, creator.subject).events);
+ assert.strictEqual((await evaluate(x.subject, res)).allow, true);
+ await t.deliverAll(t.billing.refund(x.subject, creator.subject).events);
+ assert.strictEqual((await evaluate(x.subject, res)).allow, false);
+ });
+
+ test('rule pinning, disabled rules, malformed resources and unknown rules all deny', async () => {
+ const rule = (await t.call('GET', `/api/v1/policies?service=blog&type=post&id=42`, { cap: ['vip.resource.policy.get'] })).json.rule;
+ assert.ok(rule && rule.id);
+ assert.strictEqual((await evaluate(member.subject, post, { rule_id: rule.id })).allow, true);
+ assert.strictEqual((await evaluate(member.subject, { service: 'blog', type: 'post', id: 'other' }, { rule_id: rule.id })).reason, 'rule_mismatch');
+ assert.strictEqual((await evaluate(member.subject, { service: 'blog' })).reason, 'invalid_resource');
+ assert.strictEqual((await evaluate(member.subject, post, { rule_id: 'vgr_nope' })).reason, 'no_rule');
+ const del = await t.call('DELETE', `/api/v1/policies/${rule.id}`, { user: creator });
+ assert.strictEqual(del.status, 200);
+ assert.strictEqual((await evaluate(member.subject, post, { rule_id: rule.id })).reason, 'rule_disabled');
+ assert.strictEqual((await evaluate(member.subject, post)).reason, 'no_rule');
+ });
+
+ test('only the creator sets rules; another creator cannot take over a gated resource', async () => {
+ const res = { service: 'blog', type: 'post', id: 'mine' };
+ await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res } });
+ const other = t.network.newUser('kim', { role: 'streamer' });
+ const r = await t.call('POST', '/api/v1/policies', { user: other, body: { resource: res } });
+ assert.strictEqual(r.status, 409);
+ const svcNo = await t.call('POST', '/api/v1/policies', { cap: ['vip.resource.policy.evaluate'], body: { creator: { type: 'user', id: creator.subject }, resource: res } });
+ assert.strictEqual(svcNo.status, 403);
+ const evalNo = await t.call('POST', '/api/v1/policies/evaluate', { cap: ['vip.entitlement.check'], body: { subject: member.subject, resource: res } });
+ assert.strictEqual(evalNo.status, 403);
+ const self = await t.call('POST', '/api/v1/policies/evaluate', { user: member, body: { resource: res } });
+ assert.strictEqual(self.json.allow, true);
+ const spy = await t.call('POST', '/api/v1/policies/evaluate', { user: stranger, body: { subject: member.subject, resource: res } });
+ assert.strictEqual(spy.status, 403);
+ });
+
+ test('the consumer client: allows members, and fails closed on every failure', async () => {
+ const res = { service: 'blog', type: 'post', id: 'mine' };
+ const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: ['vip.resource.policy.evaluate', 'vip.entitlement.check'] })}` }) };
+ const vip = createVipClient({ baseUrl: t.base, tokenClient });
+ assert.strictEqual((await vip.evaluate({ subject: member.subject, resource: res })).allow, true);
+ assert.strictEqual((await vip.evaluate({ subject: stranger.subject, resource: res })).allow, false);
+ assert.strictEqual(await vip.isMember(member.subject, creator.subject), true);
+ assert.strictEqual(await vip.isMember(stranger.subject, creator.subject), false);
+
+ const down = createVipClient({ baseUrl: 'http://127.0.0.1:9', tokenClient, timeoutMs: 500 });
+ const d = await down.evaluate({ subject: member.subject, resource: res });
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);
+ assert.strictEqual(await down.isMember(member.subject, creator.subject), false);
+
+ const noCap = createVipClient({ baseUrl: t.base, tokenClient: { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: [] })}` }) } });
+ assert.strictEqual((await noCap.evaluate({ subject: member.subject, resource: res })).allow, false);
+ const badToken = createVipClient({ baseUrl: t.base, getToken: async () => 'not-a-token' });
+ assert.strictEqual((await badToken.evaluate({ subject: member.subject, resource: res })).allow, false);
+ const tokenFails = createVipClient({ baseUrl: t.base, getToken: async () => { throw new Error('no grant'); } });
+ assert.strictEqual((await tokenFails.checkEntitlement({ subject: member.subject, creator: creator.subject })).status, 'unknown');
+ const garbage = createVipClient({ baseUrl: t.base, tokenClient, fetch: async () => new Response('{"allow":"yes"}', { status: 200 }) });
+ assert.strictEqual((await garbage.evaluate({ subject: member.subject, resource: res })).allow, false, 'only a literal true allows');
+ });
+
+ await run().finally(() => t.close());
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+/**
+ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails
+ * if any of them fails. They use in-process stubs of OpenVibe.Network and OpenVibe.Billing,
+ * temp databases and random ports; none needs the network or a
+ * running service.
+ *
+ * npm test # everything
+ * npm test -- plans import # only files whose name contains one of the words
+ */
+'use strict';
+const { spawn } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
+const files = fs.readdirSync(__dirname)
+ .filter((f) => f.endsWith('.test.js'))
+ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
+ .sort();
+const TIMEOUT_MS = 60000;
+
+function runOne(file) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const child = spawn(process.execPath, [path.join(__dirname, file)], {
+ cwd: path.join(__dirname, '..'),
+ env: { ...process.env, NODE_ENV: 'test' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let output = '';
+ child.stdout.on('data', (c) => { output += c; });
+ child.stderr.on('data', (c) => { output += c; });
+ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
+ child.on('close', (code, signal) => {
+ clearTimeout(timer);
+ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
+ });
+ });
+}
+
+(async () => {
+ if (!files.length) { console.error('no test files matched'); process.exit(1); }
+ const results = [];
+ for (const f of files) {
+ const r = await runOne(f);
+ results.push(r);
+ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(28)} ${String(r.ms).padStart(6)}ms`);
+ }
+ const failed = results.filter((r) => !r.ok);
+ for (const r of failed) {
+ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
+ console.log(r.output.split('\n').slice(-60).join('\n'));
+ }
+ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
+ process.exit(failed.length ? 1 : 0);
+})();