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

OP OpenVibers committed 23 Sept 2026 · 29046a16ac · 63 files, +7508 −38

Billing holds the entitlement truth (ADR-012); VIP holds plans and perks. Nine vip_* authority
tables plus vip_memberships (the version a membership was bought under), the projection and
checkout hand-offs. vip.* capabilities and the alpha service manifest are proposals in docs/.
openvibe-contracts v0.14.0, openvibe-sdk v0.2.2, openvibe-shared v1.3.0. Not deployed.

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..0ec7200
---- /dev/null
++++ b/.env.example
@@@ -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
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..026de22
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/README.md b/README.md
iindex 61f099f..a214144 100644
---- a/README.md
++++ b/README.md
@@@ -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 rule
This repository does not make the product real, and the domain keeps its placeholder page on
[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
--following exist here (plan §12.12):
--
--1. an owning runtime with health/readiness endpoints and observability;
--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
--3. server-rendered or static public routes that are useful without JavaScript;
--4. real persistence and end-to-end workflows;
--5. capability and event registration against `OpenVibe.Contracts`;
--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
--7. acceptance tests proving the advertised functionality.
++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
registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
ddiff --git a/STATUS.json b/STATUS.json
iindex fedf45a..fee64de 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/client/vip-client.js b/client/vip-client.js
nnew file mode 100644
iindex 0000000..7b96c3a
---- /dev/null
++++ b/client/vip-client.js
@@@ -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 };
ddiff --git a/deploy/nginx/openvibe.vip.conf b/deploy/nginx/openvibe.vip.conf
nnew file mode 100644
iindex 0000000..9abe00c
---- /dev/null
++++ b/deploy/nginx/openvibe.vip.conf
@@@ -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;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-vip.service b/deploy/systemd/openvibe-vip.service
nnew file mode 100644
iindex 0000000..0c99d1a
---- /dev/null
++++ b/deploy/systemd/openvibe-vip.service
@@@ -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
ddiff --git a/docs/capabilities-proposal/vip.creator.members.list.json b/docs/capabilities-proposal/vip.creator.members.list.json
nnew file mode 100644
iindex 0000000..fd04313
---- /dev/null
++++ b/docs/capabilities-proposal/vip.creator.members.list.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.entitlement.check.json b/docs/capabilities-proposal/vip.entitlement.check.json
nnew file mode 100644
iindex 0000000..aea3448
---- /dev/null
++++ b/docs/capabilities-proposal/vip.entitlement.check.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.membership.checkout.json b/docs/capabilities-proposal/vip.membership.checkout.json
nnew file mode 100644
iindex 0000000..1cfadb7
---- /dev/null
++++ b/docs/capabilities-proposal/vip.membership.checkout.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.membership.status.json b/docs/capabilities-proposal/vip.membership.status.json
nnew file mode 100644
iindex 0000000..d475d60
---- /dev/null
++++ b/docs/capabilities-proposal/vip.membership.status.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.perk.create.json b/docs/capabilities-proposal/vip.perk.create.json
nnew file mode 100644
iindex 0000000..76aab1f
---- /dev/null
++++ b/docs/capabilities-proposal/vip.perk.create.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.perk.list.json b/docs/capabilities-proposal/vip.perk.list.json
nnew file mode 100644
iindex 0000000..c5b4e98
---- /dev/null
++++ b/docs/capabilities-proposal/vip.perk.list.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.perk.update.json b/docs/capabilities-proposal/vip.perk.update.json
nnew file mode 100644
iindex 0000000..8cb7809
---- /dev/null
++++ b/docs/capabilities-proposal/vip.perk.update.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.plan.archive.json b/docs/capabilities-proposal/vip.plan.archive.json
nnew file mode 100644
iindex 0000000..5a7e879
---- /dev/null
++++ b/docs/capabilities-proposal/vip.plan.archive.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.plan.create.json b/docs/capabilities-proposal/vip.plan.create.json
nnew file mode 100644
iindex 0000000..aa71139
---- /dev/null
++++ b/docs/capabilities-proposal/vip.plan.create.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.plan.list.json b/docs/capabilities-proposal/vip.plan.list.json
nnew file mode 100644
iindex 0000000..0fb0ede
---- /dev/null
++++ b/docs/capabilities-proposal/vip.plan.list.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.plan.update.json b/docs/capabilities-proposal/vip.plan.update.json
nnew file mode 100644
iindex 0000000..773278a
---- /dev/null
++++ b/docs/capabilities-proposal/vip.plan.update.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.resource.policy.evaluate.json b/docs/capabilities-proposal/vip.resource.policy.evaluate.json
nnew file mode 100644
iindex 0000000..1110be5
---- /dev/null
++++ b/docs/capabilities-proposal/vip.resource.policy.evaluate.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.resource.policy.get.json b/docs/capabilities-proposal/vip.resource.policy.get.json
nnew file mode 100644
iindex 0000000..11e1636
---- /dev/null
++++ b/docs/capabilities-proposal/vip.resource.policy.get.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/vip.resource.policy.set.json b/docs/capabilities-proposal/vip.resource.policy.set.json
nnew file mode 100644
iindex 0000000..130c044
---- /dev/null
++++ b/docs/capabilities-proposal/vip.resource.policy.set.json
@@@ -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"
++ ]
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
nnew file mode 100644
iindex 0000000..c8b5c78
---- /dev/null
++++ b/docs/service-manifest-proposal.json
@@@ -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."
++}
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..5b46b4b
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..59982aa
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/public/css/vip.css b/public/css/vip.css
nnew file mode 100644
iindex 0000000..120094d
---- /dev/null
++++ b/public/css/vip.css
@@@ -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; }
ddiff --git a/public/js/vip.js b/public/js/vip.js
nnew file mode 100644
iindex 0000000..af9ee3a
---- /dev/null
++++ b/public/js/vip.js
@@@ -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(); }
++ });
++})();
ddiff --git a/scripts/import-live.js b/scripts/import-live.js
nnew file mode 100644
iindex 0000000..c33fc55
---- /dev/null
++++ b/scripts/import-live.js
@@@ -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); });
ddiff --git a/scripts/subscribe.js b/scripts/subscribe.js
nnew file mode 100644
iindex 0000000..41ace96
---- /dev/null
++++ b/scripts/subscribe.js
@@@ -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); });
ddiff --git a/server/api/auth.js b/server/api/auth.js
nnew file mode 100644
iindex 0000000..51d28f1
---- /dev/null
++++ b/server/api/auth.js
@@@ -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 };
ddiff --git a/server/api/v1.js b/server/api/v1.js
nnew file mode 100644
iindex 0000000..afce6f6
---- /dev/null
++++ b/server/api/v1.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..463281b
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/billing-client.js b/server/billing-client.js
nnew file mode 100644
iindex 0000000..00a84d3
---- /dev/null
++++ b/server/billing-client.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..1c458ea
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..a7735fb
---- /dev/null
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/domain/checkout.js b/server/domain/checkout.js
nnew file mode 100644
iindex 0000000..27aca5c
---- /dev/null
++++ b/server/domain/checkout.js
@@@ -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 };
ddiff --git a/server/domain/creators.js b/server/domain/creators.js
nnew file mode 100644
iindex 0000000..b3f648f
---- /dev/null
++++ b/server/domain/creators.js
@@@ -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 };
ddiff --git a/server/domain/entitlements.js b/server/domain/entitlements.js
nnew file mode 100644
iindex 0000000..eea8731
---- /dev/null
++++ b/server/domain/entitlements.js
@@@ -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 };
ddiff --git a/server/domain/index.js b/server/domain/index.js
nnew file mode 100644
iindex 0000000..b49c1f4
---- /dev/null
++++ b/server/domain/index.js
@@@ -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 };
ddiff --git a/server/domain/memberships.js b/server/domain/memberships.js
nnew file mode 100644
iindex 0000000..2c43366
---- /dev/null
++++ b/server/domain/memberships.js
@@@ -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 };
ddiff --git a/server/domain/perks.js b/server/domain/perks.js
nnew file mode 100644
iindex 0000000..cac77b2
---- /dev/null
++++ b/server/domain/perks.js
@@@ -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 };
ddiff --git a/server/domain/plans.js b/server/domain/plans.js
nnew file mode 100644
iindex 0000000..81f02e2
---- /dev/null
++++ b/server/domain/plans.js
@@@ -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 };
ddiff --git a/server/domain/policies.js b/server/domain/policies.js
nnew file mode 100644
iindex 0000000..e3609d2
---- /dev/null
++++ b/server/domain/policies.js
@@@ -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 };
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
nnew file mode 100644
iindex 0000000..d7ef004
---- /dev/null
++++ b/server/events/consumer.js
@@@ -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 };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..2b2747c
---- /dev/null
++++ b/server/events/outbox.js
@@@ -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 };
ddiff --git a/server/importer/live.js b/server/importer/live.js
nnew file mode 100644
iindex 0000000..3cbff81
---- /dev/null
++++ b/server/importer/live.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..375f953
---- /dev/null
++++ b/server/index.js
@@@ -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'));
ddiff --git a/server/network.js b/server/network.js
nnew file mode 100644
iindex 0000000..7023351
---- /dev/null
++++ b/server/network.js
@@@ -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 };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..fda2403
---- /dev/null
++++ b/server/observability.js
@@@ -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 };
ddiff --git a/server/util.js b/server/util.js
nnew file mode 100644
iindex 0000000..2370231
---- /dev/null
++++ b/server/util.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
ddiff --git a/server/web/forms.js b/server/web/forms.js
nnew file mode 100644
iindex 0000000..8f1a48f
---- /dev/null
++++ b/server/web/forms.js
@@@ -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 };
ddiff --git a/server/web/layout.js b/server/web/layout.js
nnew file mode 100644
iindex 0000000..dbd5fd6
---- /dev/null
++++ b/server/web/layout.js
@@@ -0,0 +1,108 @@
++'use strict';
++
++/**
++ * Page shell for every server-rendered page: full <head> SEO (title, description, canonical,
++ * robots, Open Graph), the shared OpenVibe chrome (inline critical canvas + app icon from
++ * openvibe-shared, navbar.js from the Network, the SSR footer and a <noscript> navigation), this
++ * site's stylesheet and its small progressive script. Everything is useful without JavaScript.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const appIcon = require('openvibe-shared/app-icon');
++const chrome = require('openvibe-shared/chrome-ssr');
++
++const SITE_NAME = 'OpenVibe.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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++const _hashes = new Map();
++function asset(rel) {
++ if (!_hashes.has(rel)) {
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing in tests */ }
++ _hashes.set(rel, v);
++ }
++ return `/${rel}?v=${_hashes.get(rel)}`;
++}
++const assetVersion = (rel) => { asset(rel); return _hashes.get(rel); };
++
++const NAV_LINKS = [
++ { label: '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 };
ddiff --git a/server/web/pages.js b/server/web/pages.js
nnew file mode 100644
iindex 0000000..b19b1dc
---- /dev/null
++++ b/server/web/pages.js
@@@ -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 };
ddiff --git a/server/web/routes.js b/server/web/routes.js
nnew file mode 100644
iindex 0000000..6e54328
---- /dev/null
++++ b/server/web/routes.js
@@@ -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, '&amp;')}</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 };
ddiff --git a/server/web/session.js b/server/web/session.js
nnew file mode 100644
iindex 0000000..028c249
---- /dev/null
++++ b/server/web/session.js
@@@ -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 };
ddiff --git a/test/api.test.js b/test/api.test.js
nnew file mode 100644
iindex 0000000..6aa3b5e
---- /dev/null
++++ b/test/api.test.js
@@@ -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());
++})();
ddiff --git a/test/entitlements.test.js b/test/entitlements.test.js
nnew file mode 100644
iindex 0000000..36633fc
---- /dev/null
++++ b/test/entitlements.test.js
@@@ -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());
++})();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
nnew file mode 100644
iindex 0000000..af7066b
---- /dev/null
++++ b/test/helpers/app.js
@@@ -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 };
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
nnew file mode 100644
iindex 0000000..30cedd0
---- /dev/null
++++ b/test/helpers/stubs.js
@@@ -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 };
ddiff --git a/test/import.test.js b/test/import.test.js
nnew file mode 100644
iindex 0000000..fe8f5b1
---- /dev/null
++++ b/test/import.test.js
@@@ -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(); });
++})();
ddiff --git a/test/plans.test.js b/test/plans.test.js
nnew file mode 100644
iindex 0000000..867e938
---- /dev/null
++++ b/test/plans.test.js
@@@ -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());
++})();
ddiff --git a/test/policies.test.js b/test/policies.test.js
nnew file mode 100644
iindex 0000000..898e948
---- /dev/null
++++ b/test/policies.test.js
@@@ -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());
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..f630c1b
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,57 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails
++ * if any of them fails. They use in-process stubs of OpenVibe.Network 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);
++})();