openvibers/openvibe.vip README.md
README.md
| 1 | # OpenVibe.VIP |
| 2 | |
| 3 | > Memberships: plans, perks and benefits recognised across every OpenVibe site. |
| 4 | |
| 5 | **Live site:** https://openvibe.vip · **API docs:** https://openvibe.services · **For AI:** https://openvibe.vip/llms.txt |
| 6 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | **Status:** alpha — **launched 2026-10-17** at [openvibe.vip](https://openvibe.vip) (openvibe-contracts 0.200.0), |
| 9 | right after the Billing cutover made OpenVibe.Billing the money ledger. Checkout is open (PowerChat and Vibes credit); |
| 10 | the 43 plans imported from Live are on sale, with no memberships sold yet. It runs on the host since 2026-09-23. |
| 11 | **Domain:** `openvibe.vip` (port 4620, service id `vip`) |
| 12 | **Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) — |
| 13 | Billing holds the entitlement truth; VIP holds plans and perks. |
| 14 | **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.3; roadmap §15.11, §29, Wave 10. |
| 15 | **License:** AGPL-3.0 (same as every OpenVibe service). |
| 16 | |
| 17 | ## Purpose |
| 18 | |
| 19 | Creator and network membership plans and perks with product bindings. A creator publishes a plan; |
| 20 | a member joins it through OpenVibe.Billing; every OpenVibe product can ask VIP whether a person is a |
| 21 | member and whether they may see a members-only resource. VIP owns plan versions, perk definitions, |
| 22 | member and creator UX and gated-resource policy; Billing remains the subscription and entitlement |
| 23 | truth. |
| 24 | |
| 25 | ## Owns |
| 26 | |
| 27 | The nine authority tables (VIP is their only writer): |
| 28 | |
| 29 | | Table | Holds | |
| 30 | |---|---| |
| 31 | | `vip_creators` | a creator (a Network `usr_` subject, with a cached handle for `/@username`; the old `/:username` 301s there) or the network itself | |
| 32 | | `vip_plans` | a plan: status (draft/published/archived), the Billing product it is sold as, its current version | |
| 33 | | `vip_plan_versions` | **immutable** plan terms, one row per edit (database triggers refuse UPDATE/DELETE except the one-time publish stamp) | |
| 34 | | `vip_perks` | perk definitions (creator or network scope): badge, emote, gated content, room, role, other | |
| 35 | | `vip_plan_perks` | **immutable**: which perks a plan *version* includes, with the key and name it was published with | |
| 36 | | `vip_product_bindings` | how a perk shows up in a product (`live chat_badge`, `chat badge`, `blog gated_post`, …) | |
| 37 | | `vip_member_preferences` | per member and creator: show the badge, be listed | |
| 38 | | `vip_gated_resource_rules` | "resource `{service, type, id}` is for members of creator C (of plan P / with perk K)", optionally *sensitive* | |
| 39 | | `vip_migration_maps` | legacy source row → VIP target, with status (`imported`, `linked`, `held`, `excluded`) and reason | |
| 40 | |
| 41 | Operational tables, authority for nothing outside VIP: `vip_memberships` (which plan **version** a |
| 42 | membership was bought under — never whether it is active), `vip_entitlement_projection` (a cache of |
| 43 | Billing entitlements with `valid_until`), `vip_checkouts` (checkout hand-offs), and the openvibe-sdk |
| 44 | `event_outbox` / `idempotency_receipts`. `vip_memberships` is a tenth table the charter did not |
| 45 | list; it is needed so that "a membership always points at the version it was bought under" has a |
| 46 | home that is not a cache. An eleventh, `vip_membership_periods` (migration 0002), is the per-period |
| 47 | charge ledger described under "Renewals, grace and refunds" below; it is a record of Billing's |
| 48 | transactions, not an authority for anything. |
| 49 | |
| 50 | ## Does not own |
| 51 | |
| 52 | - subscription contracts, prices, charges, renewals, cancellations, refunds, chargebacks and the |
| 53 | canonical entitlements — **OpenVibe.Billing**. VIP never prices anything and never moves money; |
| 54 | plan terms carry no price (`"price": "set and charged by OpenVibe.Billing"`). |
| 55 | - identities (OpenVibe.Network), the products' own resources (Blog posts, Chat rooms, …: VIP stores |
| 56 | only an EntityRef), badges' rendering (each product). |
| 57 | |
| 58 | ## Depends on |
| 59 | |
| 60 | - **OpenVibe.Billing** — checkout (`billing.intent.create`), credit-paid periods and cancel |
| 61 | (`billing.subscription.manage`), the authoritative check and subscription lists (`billing.entitlement.check`), |
| 62 | and its events `billing.entitlement.changed`, `billing.subscription.canceled`, `billing.transaction.reversed`. |
| 63 | - **OpenVibe.Network** — RS256 service and user tokens (JWKS), OAuth sign-in for the pages. |
| 64 | - **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox. |
| 65 | - **OpenVibe.Media** — the image on a members-only post (`media.object.upload`, the `vip` tenant): the bytes live in Media, VIP keeps the `med_` reference. Without a service principal the post is written without an image. |
| 66 | - **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional). |
| 67 | - **openvibe-contracts** v0.76.0, **openvibe-sdk** v0.28.0 (outbox, inbox, delivery signatures, per-actor |
| 68 | limits), **openvibe-shared** v2.2.0 (Frame, legal pages, release manifest and update metrics, metrics, |
| 69 | readiness). |
| 70 | - Consumers, through the client below: Chat (the member badge), Community (members-only spaces and |
| 71 | threads), Blog (members-only posts) and Wiki (VIP spaces and pages); Live is not wired yet. |
| 72 | - **Not** OpenVibe.Live: VIP works with Live unavailable (nothing here calls Live). |
| 73 | |
| 74 | ## Capabilities |
| 75 | |
| 76 | Implemented here (the service manifest's `capabilities`, 14 `vip.*` ids, audience `openvibe.vip`): |
| 77 | `vip.plan.create|update|archive|list`, `vip.perk.create|update|list`, `vip.membership.checkout|status`, |
| 78 | `vip.entitlement.check`, `vip.resource.policy.get|set|evaluate` and `vip.creator.members.list`. |
| 79 | |
| 80 | Called elsewhere, as the service principal `vip`: |
| 81 | |
| 82 | | Service | Grant | Why | |
| 83 | |---|---|---| |
| 84 | | OpenVibe.Billing | `billing.intent.create`, `billing.subscription.manage`, `billing.entitlement.check` | checkout, credit-paid periods and cancel, the authoritative check | |
| 85 | | OpenVibe.Events | `events.event.publish`; `events.subscription.manage` (`scripts/subscribe.js`, and at boot) | the outbox relay; the three subscriptions for Billing's events; the two account subscriptions (created at boot when missing) | |
| 86 | | OpenVibe.Network | `network.account.export.contribute`, `network.account.deletion.confirm` (granted last, once this release is live) | account export and deletion (ADR-033) | |
| 87 | |
| 88 | ## Run it |
| 89 | |
| 90 | ```bash |
| 91 | fnm exec --using=22.22.1 npm install |
| 92 | cp .env.example .env # OV_OAUTH_CLIENT_SECRET, VIP_FORM_SECRET, VIP_EVENTS_SECRET, BILLING_URL … |
| 93 | fnm exec --using=22.22.1 npm run dev # http://localhost:4620 |
| 94 | fnm exec --using=22.22.1 npm test # every test/*.test.js: stub Network + Billing, temp DBs, random ports, injected clock |
| 95 | node scripts/subscribe.js # create the three Events subscriptions for Billing's events |
| 96 | ``` |
| 97 | |
| 98 | Production: `/opt/openvibe.vip`, env `/etc/openvibe/vip.env`, unit |
| 99 | [deploy/systemd/openvibe-vip.service](deploy/systemd/openvibe-vip.service), vhost |
| 100 | [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) (installed 2026-10-17: `openvibe.vip` serves VIP). |
| 101 | The three Events subscriptions exist. |
| 102 | |
| 103 | ## Design |
| 104 | |
| 105 | ### Versioned plans |
| 106 | |
| 107 | - Creating a plan writes version 1. **Every edit writes version N+1**; nothing ever updates an earlier |
| 108 | version (triggers enforce it). An edit of a published plan is published at once and becomes what |
| 109 | new members buy (`vip.plan.published`); a draft's edits stay drafts until published. |
| 110 | - A membership records the version it was bought under: the member's own checkout hand-off, or — |
| 111 | for a subscription bought elsewhere (e.g. on Live before its cutover) — the creator's current |
| 112 | purchasable version at the moment Billing granted it. **Renewals keep that version**, however |
| 113 | often the plan changes; a new purchase after the membership lapsed takes the current version. |
| 114 | - Perk rules evaluate the perks of the member's version, not the plan's latest. |
| 115 | - Renaming a perk never changes published terms (each version keeps its snapshot). |
| 116 | - **Tiers** (since 2026-10-17, openvibe-contracts 0.201.0): a plan sold through Billing is one tier of the creator's |
| 117 | membership (`billing_tier` 1–10, chosen at creation, else the next free one). A creator publishes at most **one plan |
| 118 | per tier** (a unique index; a second is refused with `409 vip.plan.billing_product_taken`) and sets each tier's |
| 119 | monthly price on Billing (`server/domain/prices.js`, `billing.tier.manage`: the dashboard's "Set price", or |
| 120 | `price_cents` on `POST /plans` / `PATCH /plans/:id`). VIP never keeps a price. Tier 1 without a price sells at |
| 121 | Billing's default; a higher tier without one cannot be joined. A member holds one tier of a creator at a time |
| 122 | (Billing refuses a second while a period runs); a subscription made elsewhere (Live's) is tier 1, and Billing's |
| 123 | `tier` on `billing.entitlement.changed` files a membership made without a checkout under that tier's plan. |
| 124 | Network memberships still need a Billing product: a network plan (staff) can be published as terms, but checkout |
| 125 | answers `409 vip.checkout.unavailable`. |
| 126 | - Archiving stops new members; existing members keep their terms until Billing ends the membership. |
| 127 | |
| 128 | ### The entitlement projection (Billing is the truth) |
| 129 | |
| 130 | `vip_entitlement_projection` is a cache of Billing's entitlements, fed by Billing's events |
| 131 | (signed Events webhook + openvibe-sdk `createPgInbox`, exactly once) and by direct checks. |
| 132 | |
| 133 | - `valid_until = min(paid period end, last word from Billing + VIP_PROJECTION_MAX_AGE_MS)`; a "no" |
| 134 | is kept for the max age. |
| 135 | - `now ≤ valid_until`: the projection answers. Past it, mode `auto` asks Billing |
| 136 | (`GET /api/v1/entitlements/:subject?streamer=`) and re-projects; only if Billing cannot answer |
| 137 | and we are within `VIP_PROJECTION_GRACE_MS` does it return the stale answer (flagged `stale: true`). |
| 138 | - After the grace, **VIP answers `"unknown"`, which never authorizes** — a stale cache cannot |
| 139 | authorize indefinitely, whatever the paid period says. |
| 140 | - Mode `authoritative` (sensitive actions: checkout, cancel, rules marked *sensitive*) always asks |
| 141 | Billing and answers `unknown` when it cannot. |
| 142 | - Ordering: each row remembers the Billing time it reflects. An older event that would grant is |
| 143 | ignored; an older event that would revoke puts the row in doubt (`valid_until = 0`), so the next |
| 144 | check goes to Billing — out-of-order delivery can deny briefly, never grant wrongly. |
| 145 | - `billing.transaction.reversed` that revoked periods puts the pair in doubt at once, even before (or |
| 146 | without) the matching `billing.entitlement.changed`. |
| 147 | - Convergence window: seconds when events flow; when every event for a change is lost, at most |
| 148 | `VIP_PROJECTION_MAX_AGE_MS + VIP_PROJECTION_GRACE_MS` (15 min + 60 s by default), and never past |
| 149 | the paid period's end for renewals and cancellations. |
| 150 | - A background job re-confirms, with Billing, projections that products used in the last hour |
| 151 | before they go stale. |
| 152 | |
| 153 | ### Renewals, grace and refunds (Billing does them; VIP records and converges) |
| 154 | |
| 155 | - A membership renews by **Billing's own sweep**: one charge per period from the subscriber's credit, |
| 156 | idempotent on `renew:<subscription id>:<period end>`, so a retried or replayed renewal never charges |
| 157 | twice. VIP has no renewal call; it projects `billing.entitlement.changed` (`reason: renewed`), and a |
| 158 | renewal keeps the plan version the membership already has. |
| 159 | - **Charge ledger.** `vip_membership_periods` holds one row per paid period, unique on the Billing |
| 160 | transaction (`billing_transaction_id`), written from a `granted`/`renewed` entitlement event that names |
| 161 | its `transaction_id` and from the credit checkout answer. A replayed event, or the same transaction |
| 162 | again, never records a second row. `billing.transaction.reversed` marks the period it reverses as |
| 163 | `reversed` (`reversed_by` = the reversing transaction); if the reversal arrives **before** the grant |
| 164 | event, a tombstone is written and the grant that follows leaves it reversed and grants nothing. |
| 165 | Provider refunds and chargebacks (`entitlements_revoked`) and credit refunds of a subscription period |
| 166 | (`metadata.original_type = subscription`) both count; a reversal of anything else is ignored. |
| 167 | - **Renewal grace.** When a renewal charge fails Billing moves the subscription to `past_due` until |
| 168 | `grace_until`, retrying on the same key, then ends it (`grace_ended`). VIP stores both |
| 169 | (`vip_entitlement_projection.grace_until`), answers `status: inactive` with |
| 170 | `grace: { until, reason: "renewal_failed" }` while the grace runs (`null` otherwise), and **never |
| 171 | authorizes a `past_due` row**, whatever an event says. After the grace the membership row is kept and |
| 172 | inactive; a later paid renewal restores it on the same plan version. `vip.membership.changed` carries |
| 173 | `renewal_failed` / `grace_ended` as its `reason`; its payload is unchanged. |
| 174 | - **Audit.** `periods.audit()` runs with the projection refresh job: an active, fresh row that claims a |
| 175 | payment (last word `granted`/`renewed`, or the pair already has ledger rows) without a `paid` period |
| 176 | reaching its `expires_at`, or whose period was reversed, is logged, counted (`vip_period_audit_offenders`) |
| 177 | and put in doubt (`valid_until = 0`) so the next check asks Billing. Pairs VIP only knows from a Billing |
| 178 | check (imports, shadow entitlements) are not audited until a payment event names them. |
| 179 | |
| 180 | ### Checkout and cancellation (Billing does both) |
| 181 | |
| 182 | - `POST /api/v1/checkout` asks Billing first (a member cannot buy twice), records the plan version |
| 183 | chosen, and hands off: `provider: credit` → Billing `POST /subscriptions` (paid from Vibes credit, |
| 184 | membership starts at once); any other provider → Billing `POST /intents` (kind subscription), |
| 185 | returning Billing's `checkout_url` or PowerChat `checkout_ref`. The membership starts when Billing's |
| 186 | settlement event arrives. Idempotency keys come from VIP's ids (`vip:checkout:<id>`). |
| 187 | - PowerChat: Billing answers with a `checkout_ref` (`pcsub:<intent>`, routed through the site) and no URL, and VIP |
| 188 | builds the link from `VIP_POWERCHAT_LINK_TEMPLATE` (`{ref}`, `{cents}`). Production sets it since 2026-10-17 to |
| 189 | PowerChat's canonical tip link on the site's PowerChat account, as OpenVibe.Live builds it |
| 190 | (`https://<powerchat>/<site account>/tip?app_client_id=…&app_ref={ref}&app_purpose=subscription&app_amount_cents={cents}`); |
| 191 | without it the page shows the reference to pay with. |
| 192 | - Cancel is the member's own action: VIP confirms with Billing that the subscription is theirs, then |
| 193 | Billing `POST /subscriptions/:id/cancel` (at period end). |
| 194 | |
| 195 | ### Members-only posts (the creator's own posts) |
| 196 | |
| 197 | A creator writes posts for the members of one or more of their tiers (Billing's tiers of that creator, |
| 198 | `vip_plans.billing_tier`). A post carries a title, a body in Markdown (rendered to safe HTML: the |
| 199 | source can never carry markup) and an optional image (a Media object — the bytes live in |
| 200 | OpenVibe.Media under VIP's tenant, the post keeps the `med_` reference). |
| 201 | |
| 202 | Who may read the text is never stored: it is answered on every request by the entitlement check |
| 203 | (Billing's truth, projected) and the tier the member holds. The list of posts never carries a body, and |
| 204 | the body is served only after that check. So: |
| 205 | |
| 206 | * a signed-out visitor and a non-member see the title, the tier it needs and a way to join — never the text; |
| 207 | * a member of a tier reads a post written for their tier or for a lower one (a higher tier includes the lower); |
| 208 | * a member of another tier reads nothing; |
| 209 | * a hidden post is not served at all — not its title, not its text. |
| 210 | |
| 211 | A locked post offers a way in only when a tier that reads it can be joined: the "Become a member" button |
| 212 | points at the cheapest such plan. When none can be joined (the plan has no price yet, or is not sold |
| 213 | through Billing), the post says so — "This post is for the members of Tier 2, which is not open to join |
| 214 | yet." — and offers no button, because a primary button must never lead to a dead end. The line carries |
| 215 | the shared lock glyph. |
| 216 | |
| 217 | The creator reads their own posts (on their page and on the dashboard). Staff hide any creator's post, |
| 218 | the way they manage network plans; a creator may hide their own, but cannot bring back a post staff |
| 219 | hid. A post's author reference is anonymized when the creator's account is deleted (ADR-033), like |
| 220 | their plans and perks. |
| 221 | |
| 222 | ### Events (openvibe-sdk outbox, same transaction as the change) |
| 223 | |
| 224 | | Event | When | Payload | |
| 225 | |---|---|---| |
| 226 | | `vip.plan.published` (public) | a plan version becomes what new members buy | plan id, slug, version, version id, creator, billing_kind, full terms | |
| 227 | | `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 | |
| 228 | |
| 229 | ## API (`/api/v1`) |
| 230 | |
| 231 | Service tokens: Network client-credentials, audience `openvibe.vip`, one capability per route (the |
| 232 | `vip.*` ids are released in openvibe-contracts v0.19.0; the drafts stay in |
| 233 | [docs/capabilities-proposal/](docs/capabilities-proposal/); grants are matched with contracts' |
| 234 | `capabilities.grants()` — exact id or a `.*` family). User tokens: a Network user JWT; people act on their own things (their plans, |
| 235 | perks, rules, members; their own memberships and checks); staff holding `staff.site.configure` (the contracts staff map) manage |
| 236 | network plans. Errors are RFC 9457 problem+json. Creators are named by SubjectRef, `usr_` id, VIP |
| 237 | handle or `network`. |
| 238 | |
| 239 | | Method & path | Capability (services) | Users | Does | |
| 240 | |---|---|---|---| |
| 241 | | `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` | — | — | liveness; truthful readiness (db required; Network key and Billing optional → degraded); release; loopback metrics | |
| 242 | | `GET /creators/:ref` | — (public) | owner sees drafts | creator, published plans, own perks | |
| 243 | | `GET /creators/:ref/card` | — (public) | — | the membership card (below), the same as `/embed/:username/card.json` | |
| 244 | | `GET /plans?creator=&include=drafts,archived` | `vip.plan.list` for drafts | owner | plans with current/draft versions | |
| 245 | | `GET /plans/:id`, `GET /plans/:id/versions` | `vip.plan.list` for drafts | owner | a plan; its version history (published versions are public) | |
| 246 | | `POST /plans` | `vip.plan.create` (body `creator`) | self / staff for `network` | create (v1) at `tier` (else the next free one), optionally `publish` and `price_cents` (the tier's monthly price, set on Billing) | |
| 247 | | `PATCH /plans/:id` | `vip.plan.update` | owner | edit → new version; `price_cents` sets the tier's price on Billing (not a term: no new version) | |
| 248 | | `POST /plans/:id/publish` | `vip.plan.update` | owner | publish the latest version | |
| 249 | | `POST /plans/:id/archive` | `vip.plan.archive` | owner | archive | |
| 250 | | `GET /perks?creator=&network=&retired=` | `vip.perk.list` for retired | owner | perks with product bindings | |
| 251 | | `POST /perks`, `PATCH /perks/:id` | `vip.perk.create` / `vip.perk.update` | owner | define / edit a perk and its bindings | |
| 252 | | `POST /checkout` | `vip.membership.checkout` (body `subject`) | self | hand-off to Billing: `{ checkout, membership_started, checkout_url, checkout_ref }` | |
| 253 | | `GET /memberships/:subject[?mode=]` | `vip.membership.status` | self | memberships, version bought under, entitlement, preferences | |
| 254 | | `POST /memberships/:creator/cancel` | — | self only | cancel at period end through Billing | |
| 255 | | `PUT /memberships/:creator/preferences` | — | self only | `{ show_badge, listed }` | |
| 256 | | `GET\|POST /entitlements/check` `{subject, creator, mode, product?}` | `vip.entitlement.check` | self | `{ status: active\|inactive\|unknown, active, expires_at, cancel_at_period_end, source, stale, valid_until, membership }`; with `product` also `product_perks` (the member's version perks bound to that product, with each binding's config, e.g. Chat's `badge`) and `preferences` (`show_badge`) | |
| 257 | | `GET /policies?service=&type=&id=&owner=` / `?creator=` (services must name `owner`; a person defaults to themselves) | `vip.resource.policy.get` | owner | the rule for a resource / a creator's rules | |
| 258 | | `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` | |
| 259 | | `POST /policies/evaluate` `{subject, resource, owner, rule_id?, mode?, fallback?}` — `owner` = the creator the PRODUCT says owns the resource (usr_ id or `network`); only that creator's rule applies, none → `owner_required`. `fallback` `{ requirement: 'member', binding?: 'product:binding' }` = the product's default gate, used only when the owner has no rule (below) | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement, fallback? }` — **fails closed** | |
| 260 | | `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 | |
| 261 | | `GET /creators/:ref/posts` | — (public) | owner sees the hidden posts too | the posts of a creator: title, tiers, image, date — never the text | |
| 262 | | `GET /creators/:ref/posts/:id` | — (public) | owner / staff | one post; the text only for a member of its tier (or the creator, or staff) | |
| 263 | | `GET /creators/:ref/posts/:id/body` | — | the member of the tier, the creator, staff | the text: 401 signed out, 403 not a member, 404 hidden | |
| 264 | | `POST /creators/:ref/posts` | `vip.post.create` | the creator | write a post `{title, body, tiers}` for one or more of the creator's tiers | |
| 265 | | `POST /posts/:id/hide`, `POST /posts/:id/unhide` | `vip.post.moderate` | the creator, staff | hide a post (nobody reads it any more), show it again | |
| 266 | |
| 267 | The `vip.post.*` ids are not in openvibe-contracts yet, so no service can hold them: people (the |
| 268 | creator, staff) write and moderate posts without a capability. A service that wants to needs a |
| 269 | contracts entry first. |
| 270 | |
| 271 | Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`, |
| 272 | `invalid_fallback`, `creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`, |
| 273 | `plan_required`, `perk_missing`. Only `allow: true` allows. |
| 274 | |
| 275 | **The product's default gate (`fallback`).** A product that marks its own resource members-only (a Blog |
| 276 | post, a Community space or thread) does not register a VIP rule per resource. It asks with |
| 277 | `fallback: { requirement: 'member', binding: 'blog:gated_post' }`, which applies only when the owner |
| 278 | has no active rule for that resource — a rule the creator sets in VIP (plan or perk requirement, |
| 279 | sensitive) always wins. Under the fallback the viewer needs an active entitlement to the owner and, |
| 280 | when the owner defines an active perk carrying that product binding, a plan version that includes one |
| 281 | of those perks (`perk_missing` otherwise). A creator VIP has never seen is asked of Billing as is. |
| 282 | The answer has `fallback: true` and `rule: null`. |
| 283 | |
| 284 | ## Consumer seam: `openvibe-vip/client` |
| 285 | |
| 286 | A module with no dependencies for Blog, Wiki, Chat, Live and Community |
| 287 | ([client/vip-client.js](client/vip-client.js)). Every failure — VIP unreachable, a timeout, a refused |
| 288 | token, a malformed answer — is a denial, never an exception and never a "yes". |
| 289 | |
| 290 | ```js |
| 291 | const { createVipClient } = require('openvibe-vip/client'); |
| 292 | const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl: `${NETWORK}/oauth/token`, clientId, clientSecret, |
| 293 | audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' }); |
| 294 | const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens, timeoutMs: 2000 }); |
| 295 | |
| 296 | const d = await vip.evaluate({ subject: viewer ? viewer.subject : null, resource: { service: 'blog', type: 'post', id: '42' } }); |
| 297 | if (!d.allow) return renderMembersOnlyTeaser(d.reason); |
| 298 | |
| 299 | await vip.isMember(viewerSubject, creatorSubject); // boolean; false on any doubt |
| 300 | await vip.checkEntitlement({ subject, creator, mode: 'authoritative' }); // sensitive content |
| 301 | ``` |
| 302 | |
| 303 | Until the SDK grows a `vip` module, consumers pin this repository's tag tarball and require only |
| 304 | `openvibe-vip/client` (it loads nothing else), or vendor the file verbatim with the commit it was |
| 305 | copied from (Chat, Community and Blog do, in `server/vip/vip-client.js`, until a VIP tag is published); |
| 306 | moving it into openvibe-sdk is the intended home. |
| 307 | |
| 308 | ### The product cache and the convergence bound |
| 309 | |
| 310 | Every consumer puts `createVipCache` (same module) in front of the client, so the rule for how long |
| 311 | a product may keep granting after a membership ends is written once: |
| 312 | |
| 313 | ```js |
| 314 | const { createVipClient, createVipCache } = require('openvibe-vip/client'); |
| 315 | const cache = createVipCache({ vip, ttlMs: 30_000, denyTtlMs: 10_000, unavailableTtlMs: 2_000 }); |
| 316 | await cache.evaluate({ subject, resource, owner, fallback }); // Community, Blog |
| 317 | await cache.entitlement({ subject, creator, product: 'chat' }); // Chat's badge |
| 318 | cache.peekEntitlement({ subject, creator, product: 'chat' }); // never waits (a miss warms it) |
| 319 | cache.handleEvent(envelope); // vip.membership.changed, billing.entitlement.changed, … |
| 320 | ``` |
| 321 | |
| 322 | When a membership changes (Billing emits `billing.entitlement.changed`, VIP applies it and emits |
| 323 | `vip.membership.changed`), a product stops granting within: |
| 324 | |
| 325 | | Situation | Bound | |
| 326 | |---|---| |
| 327 | | the product hands the event to `cache.handleEvent` | at once (the member–creator pair is dropped; an answer in flight is not stored) | |
| 328 | | VIP has the event, the product does not | `ttlMs` (a cached "yes" lives at most that long) | |
| 329 | | every event lost, VIP's included | `VIP_PROJECTION_MAX_AGE_MS` + `ttlMs` (15 min + the product's TTL by default); with Billing down as well, add `VIP_PROJECTION_GRACE_MS`, after which VIP answers `unknown` | |
| 330 | | cancel at period end / the period ends | never past `expires_at`: a "yes" is not cached beyond the entitlement's `expires_at` or VIP's `valid_until` | |
| 331 | | VIP unreachable | `ttlMs`, then every answer is a denial (a failure never extends a "yes") | |
| 332 | |
| 333 | A "no" is cached `denyTtlMs` (a new member waits at most that long); failures `unavailableTtlMs`. |
| 334 | [test/convergence.test.js](test/convergence.test.js) proves every row against the real VIP with an |
| 335 | injected clock. The products' TTLs: Chat 60 s (a badge), Community 30 s, Blog 30 s — each product's |
| 336 | README repeats its bound and its own test drives the product through a change. |
| 337 | Live's badge mapping (`live chat_badge`, `live powerchat_overlay`, `live ai_context`) and Chat's |
| 338 | (`chat badge`) are declared as product bindings of the network perk `subscriber-badge`. |
| 339 | |
| 340 | ## Card, badge and widget (`/embed`) |
| 341 | |
| 342 | The reusable membership endpoints of roadmap §11.3, for a creator's stream page, their own site, or any |
| 343 | other product. They cover creators with at least one published plan ([server/domain/cards.js](server/domain/cards.js), |
| 344 | [server/web/embeds.js](server/web/embeds.js)): |
| 345 | |
| 346 | | Endpoint | What | Headers | |
| 347 | |---|---|---| |
| 348 | | `GET /embed/:username/card.json` (and `GET /api/v1/creators/:ref/card`) | plan names, descriptions, benefits and version (the terms new members buy), perks (key, name, kind), join and terms links, whether joining is open, Billing's price and period, the member count, the badge and widget URLs | `Access-Control-Allow-Origin: *` (no credentials), `Cache-Control: public, max-age=60` | |
| 349 | | `GET /embed/:username/badge.svg` | a two-part badge, "★ <badge perk>" and the creator's name, for an `<img>` | `image/svg+xml`, CSP `default-src 'none'; sandbox`, `Cross-Origin-Resource-Policy: cross-origin`, cached 5 min | |
| 350 | | `GET /embed/:username/widget[?theme=light\|dark]` | an HTML widget for an `<iframe>`: name, member count, up to three plans with their perks, a join link that opens openvibe.vip in a new tab | CSP `default-src 'none'`, the one inline style allowed by its hash, `form-action`/`base-uri 'none'`, `frame-ancestors` from `VIP_WIDGET_FRAME_ANCESTORS` (any site by default) | |
| 351 | |
| 352 | **Safe to embed:** |
| 353 | - The endpoints are the same for everyone. They are mounted before the cookie parser and never read a |
| 354 | cookie, token or viewer, and they set no cookie. |
| 355 | - They run no script. |
| 356 | - They show nothing private: published plans only, never drafts or archived ones; perk names, never |
| 357 | product binding configs; a member count, never who. The creator can switch the count off in the |
| 358 | dashboard. |
| 359 | - Every other VIP page keeps `frame-ancestors 'self'`. The nginx reference config serves `/embed/` |
| 360 | without the server-wide `X-Frame-Options`. |
| 361 | |
| 362 | **The member count** is Billing's: active subscriptions to the creator. It is reused for |
| 363 | `VIP_MEMBER_COUNT_TTL_MS` (5 min), with one Billing call at a time per creator. When Billing does not |
| 364 | answer, the last count is shown as `stale: true`, or no count at all. VIP never guesses from its |
| 365 | projection, which only holds members someone asked about recently. The dashboard's *Share your |
| 366 | memberships* card gives the creator the badge and widget HTML and the card URL. |
| 367 | |
| 368 | ## Pages (server-rendered, useful without JavaScript, OpenVibe Frame) |
| 369 | |
| 370 | `/` directory of creators with published plans · `/@username` a creator's plans (terms, perks, |
| 371 | Billing's price and period from `GET /api/v1/rates`, join form) and their members-only posts (the |
| 372 | title and the tier are public, the text only for a member of that tier) · `/@username/plans/:slug` public terms |
| 373 | history · `/me` your memberships, the version you joined under, cancel, badge preference · |
| 374 | `/dashboard` plans, versions, perks and bindings, members, members-only resources, the members-only |
| 375 | posts (write, hide) and, for staff, the moderation list, the badge and |
| 376 | widget to share and the member-count switch (`?as=network` for staff) · `/embed/:username/…` (above) · `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` · `/terms`, `/privacy`, `/dmca` |
| 377 | (openvibe-shared legal). Navbar from `https://openvibe.network/shared/navbar.js`, SSR footer and |
| 378 | `<noscript>` navigation from openvibe-shared. Forms carry HMAC anti-forgery tokens |
| 379 | (`VIP_FORM_SECRET`); cookies are SameSite=Lax. No feed: plans are not a publication stream. |
| 380 | |
| 381 | ### Crawlers and AI readers |
| 382 | |
| 383 | `/robots.txt` and `/sitemap.xml` come from openvibe-shared/seo (the sitemap lists the home and every |
| 384 | creator with a published plan). `/llms.txt` is the site map for language models; `/llms-full.txt` is |
| 385 | the same header followed by the full plain text of the home, every public creator page and every |
| 386 | published plan's terms (`seo.llmsFull`, built from public data only, cached an hour in memory and with |
| 387 | `Cache-Control: public, max-age=3600`). Every page's head carries an `ai-summary` meta and WebPage |
| 388 | JSON-LD through openvibe-shared/shell. |
| 389 | |
| 390 | ### IndexNow (openvibe-shared/indexnow) |
| 391 | |
| 392 | With `INDEXNOW_KEY` set (8–128 hex or alphanumeric characters, what IndexNow's own tools generate), |
| 393 | the key file is served at `/<key>.txt` as `text/plain` (before static, so a file cannot shadow it) and |
| 394 | a plan that changes tells the engines: publishing, editing a published plan and archiving ping |
| 395 | `api.indexnow.org` with the creator's page, the plan's terms page and `/sitemap.xml`; the module |
| 396 | batches and debounces (one POST per 30 s window). A draft, an unchanged edit, a replayed publish and |
| 397 | network plans (no public creator page) never ping, and a failed ping never takes a publish down. |
| 398 | Unset: off — the key route is not mounted and nothing is sent (`test/indexnow.test.js`). |
| 399 | |
| 400 | ## Account export and deletion (ADR-033) |
| 401 | |
| 402 | `network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events` and are answered by |
| 403 | `server/domain/account-data.js` (`openvibe-sdk/account-data`), outside the billing inbox, with one receipt per export |
| 404 | and deletion in `account_data_events`. |
| 405 | |
| 406 | - **A member:** the export carries their memberships, preferences, entitlements, checkouts and paid periods. The |
| 407 | deletion removes the memberships, preferences, entitlement cache and checkout hand-offs. Paid periods are money |
| 408 | records and stay, counted as retained. |
| 409 | - **A creator:** the export carries their profile, plans, plan versions and perks. Their creator row stays, because |
| 410 | plans, perks and other people's memberships reference it, but it is suspended and loses its username, display name |
| 411 | and bio. Plans, perks and gated rules lose `created_by`. Plan versions stay whole: they are the immutable terms |
| 412 | members joined under. |
| 413 | - **Billing:** payments, refunds and subscriptions are Billing's records, and its own consumer answers for them. |
| 414 | |
| 415 | ## Before and after the Billing cutover |
| 416 | |
| 417 | The cutover happened on 2026-10-17 (`BILLING_AUTHORITY=billing` in Live and Billing): checkout is open |
| 418 | (`VIP_CHECKOUT_PROVIDERS=powerchat,credit`). What held before it, kept for a rollback: |
| 419 | |
| 420 | - keep **checkout closed** (`VIP_CHECKOUT_PROVIDERS=` empty): a subscription sold through Billing |
| 421 | now would be one Live does not know about. Plan pages say joining is not open yet; the API answers |
| 422 | `422 vip.checkout.provider_unavailable`; |
| 423 | - VIP can run read-only against Billing's shadow entitlements (imported from Live), which are only as |
| 424 | current as Billing's last import; new Live subscriptions do not reach Billing (or VIP) before the cutover; |
| 425 | - Billing publishes its events only when its own `EVENTS_URL` is set. |
| 426 | |
| 427 | After the cutover: run `scripts/subscribe.js`, then open checkout (`VIP_CHECKOUT_PROVIDERS=powerchat,credit`). |
| 428 | |
| 429 | ## Security notes |
| 430 | |
| 431 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). |
| 432 | |
| 433 | - Every API route checks one capability (services) or ownership (users); a presented token is judged |
| 434 | on that token alone. The API never reads cookies. |
| 435 | - Billing events: HMAC-SHA256 signature over the raw body (`VIP_EVENTS_SECRET`, rotation by list), |
| 436 | `source` must be `billing`, exactly-once inbox, ordering guard. |
| 437 | - Fail closed everywhere a membership is decided; "unknown" is never "yes". |
| 438 | - Outbound calls go only to configured service URLs (Network, Billing, Events); no user-supplied |
| 439 | URLs are fetched. Checkout redirects only to http(s) URLs Billing returned. |
| 440 | - Secrets appear as environment variable names only. |
| 441 | - The embeds (`/embed`) read no cookie or token and set none; the widget runs no script, and its CSP |
| 442 | allows only its own hashed style. `VIP_WIDGET_FRAME_ANCESTORS` is reduced to source expressions, so it |
| 443 | cannot add a CSP directive. The badge SVG is served sandboxed, with every name escaped. |
| 444 | `test/embeds.test.js` covers all of this. |
| 445 | - Per-actor limits on `/api/v1` (`server/api/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4), |
| 446 | counted once the credential is checked and before any work: a service as its principal (`svc:chat`), a |
| 447 | person as `user:usr_…`, anyone else by address. Past a limit: `429` problem+json `rate_limited` with |
| 448 | `Retry-After`, one log line and `vip_rate_limited_total{limit,window}`. Reads take `VIP_LIMITS_MINUTE` / |
| 449 | `VIP_LIMITS_HOUR` (120 a minute, 3000 an hour). `GET|POST /entitlements/check` and |
| 450 | `POST /policies/evaluate` give a service 1200 / 30000 (products ask per member and resource behind |
| 451 | their cache; a refusal would lock members out) and a person the defaults. `POST /checkout`: a person |
| 452 | 10 / 60, a service 60 / 1200; cancel 5 / 30. New plans and perks, publish and archive 10 / 100; plan, |
| 453 | perk and rule changes and preferences 30 / 300. Never limited: health, ready, `/release.json`, |
| 454 | `/metrics`, `/embed` and the signed Events deliveries at `/internal/events` (`test/actor-limits.test.js`). |
| 455 | |
| 456 | ## Acceptance (what the tests prove) |
| 457 | |
| 458 | | Criterion | Test | |
| 459 | |---|---| |
| 460 | | 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) | |
| 461 | | 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) | |
| 462 | | cancel / renew / refund converge (events, and with events lost) | `test/entitlements.test.js` (injected clock) | |
| 463 | | a renewal replayed or re-sent records one period; renew then refund reverses it; a reversal before the grant stays reversed; a failed renewal is inactive with a grace and never authorized; the audit doubts a paid period without a record | `test/renewal.test.js` | |
| 464 | | a stale projection cannot authorize past `valid_until` (+ grace), even with Billing down for 100 days | `test/entitlements.test.js` | |
| 465 | | authoritative fallback and sensitive checks | `test/entitlements.test.js`, `test/policies.test.js` | |
| 466 | | gated-resource evaluation fails closed (no rule, unknown, perk missing, guest, VIP down in the client) | `test/policies.test.js` | |
| 467 | | VIP works with Live unavailable | no code path calls Live; every test runs without a Live stub | |
| 468 | | a creator's membership card, badge and widget are public-data only, the same for everyone, embeddable and inert | `test/embeds.test.js` (published plans only, no subjects or binding configs, no cookie read or set, CORS, SVG escaping and sandbox, the widget's frame-ancestors and hashed style, the creator's member-count switch, Billing outage keeps the last count or shows none) | |
| 469 | | `/llms-full.txt` carries the full text of the home, creator and plan pages, and is cached for an hour | `test/llms-full.test.js`, `test/api.test.js` (discovery artifacts) | |
| 470 | | IndexNow is off without `INDEXNOW_KEY` (no key route, nothing sent); with one the key file is served at `/<key>.txt` as `text/plain` and publishing, editing and archiving a plan ping the creator page, the terms page and the sitemap; a draft and an unchanged edit never ping | `test/indexnow.test.js` | |
| 471 | | members-only posts: a member of the tier reads the text, a non-member and a signed-out visitor do not (page and API), the creator reads their own, staff hide any post, and the Markdown never carries markup | `test/member-posts.test.js` (the tier rules, the API, the moderation, the Media image, the renderer) | |
| 472 | |
| 473 | Not yet demonstrated (needs the other services): convergence **across consuming products** end to end |
| 474 | (Chat, Community, Blog and Wiki call VIP in code; Live does not), and a real Billing/Events round trip |
| 475 | on the host (Billing is in shadow and has sent VIP no events). The Live import ran on production on |
| 476 | 2026-09-23. |
| 477 | |
| 478 | ## Deploy |
| 479 | |
| 480 | Production deploys with `sudo ovhost deploy vip` on the host (strategy `git-checkout`: fetch, |
| 481 | fast-forward `/opt/openvibe.vip`, install on a lockfile change, restart, wait for `/api/ready`). |
| 482 | The unit is `openvibe-vip.service` on `127.0.0.1:4620`, the env file `/etc/openvibe/vip.env`. The database is |
| 483 | `ov_vip` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh vip` writes its settings); the |
| 484 | release migrates it at boot. The vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) serves |
| 485 | `openvibe.vip` since the launch (2026-10-17). |
| 486 | Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the |
| 487 | restart; afterwards `sudo ovhost rollback vip --to <sha>`. One blocker: `idx_vip_rules_resource` (one |
| 488 | active rule per resource) was replaced by a per-creator unique index, and a release from before it |
| 489 | recreates the narrower index, which fails at boot once two creators gate the same resource. |
| 490 | |
| 491 | ## Launch rule |
| 492 | |
| 493 | Held on 2026-10-17, when `openvibe.vip` launched (openvibe-contracts 0.200.0 made VIP `live`; Network stopped parking |
| 494 | the domain). The rule, as written before the launch (plan §12.12): |
| 495 | |
| 496 | 1. an owning runtime with health/readiness endpoints and observability — ✔ (`/api/health`, truthful `/api/ready`, `/metrics`); |
| 497 | 2. canonical identity/auth integration (Network subjects, scoped service principals) — ✔; the `vip` principal is provisioned on the host; |
| 498 | 3. server-rendered public routes useful without JavaScript — ✔; |
| 499 | 4. real persistence and end-to-end workflows — ✔, against the running Billing and Events since the cutover (2026-10-17); |
| 500 | 5. capability and event registration against `OpenVibe.Contracts` — ✔ v0.19.0; |
| 501 | 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); |
| 502 | 7. acceptance tests proving the advertised functionality — ✔ for VIP itself; cross-product convergence waits for the consumers. |
| 503 | |
| 504 | A placeholder is never counted as an implemented service. |
| 505 | |
| 506 | --- |
| 507 | |
| 508 | Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). |
| 509 | |
| 510 | <!-- versions:start --> |
| 511 | - openvibe-contracts: v0.419.0 |
| 512 | - openvibe-sdk: v0.161.0 |
| 513 | - openvibe-shared: v4.241.0 |
| 514 | <!-- versions:end --> |
| 515 |