openvibers/openvibe.vip README.md

README.md

main 39.6 KB · 515 lines · markdown

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),
9right after the Billing cutover made OpenVibe.Billing the money ledger. Checkout is open (PowerChat and Vibes credit);
10the 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) —
13Billing 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
19Creator and network membership plans and perks with product bindings. A creator publishes a plan;
20a member joins it through OpenVibe.Billing; every OpenVibe product can ask VIP whether a person is a
21member and whether they may see a members-only resource. VIP owns plan versions, perk definitions,
22member and creator UX and gated-resource policy; Billing remains the subscription and entitlement
23truth.
24
25## Owns
26
27The 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
41Operational tables, authority for nothing outside VIP: `vip_memberships` (which plan **version** a
42membership was bought under — never whether it is active), `vip_entitlement_projection` (a cache of
43Billing 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
45list; it is needed so that "a membership always points at the version it was bought under" has a
46home that is not a cache. An eleventh, `vip_membership_periods` (migration 0002), is the per-period
47charge ledger described under "Renewals, grace and refunds" below; it is a record of Billing's
48transactions, 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
76Implemented 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
80Called 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
91fnm exec --using=22.22.1 npm install
92cp .env.example .env # OV_OAUTH_CLIENT_SECRET, VIP_FORM_SECRET, VIP_EVENTS_SECRET, BILLING_URL …
93fnm exec --using=22.22.1 npm run dev # http://localhost:4620
94fnm exec --using=22.22.1 npm test # every test/*.test.js: stub Network + Billing, temp DBs, random ports, injected clock
95node scripts/subscribe.js # create the three Events subscriptions for Billing's events
96```
97
98Production: `/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).
101The 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
197A 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
199source can never carry markup) and an optional image (a Media object — the bytes live in
200OpenVibe.Media under VIP's tenant, the post keeps the `med_` reference).
201
202Who 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
204the 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
211A locked post offers a way in only when a tier that reads it can be joined: the "Become a member" button
212points at the cheapest such plan. When none can be joined (the plan has no price yet, or is not sold
213through Billing), the post says so — "This post is for the members of Tier 2, which is not open to join
214yet." — and offers no button, because a primary button must never lead to a dead end. The line carries
215the shared lock glyph.
216
217The creator reads their own posts (on their page and on the dashboard). Staff hide any creator's post,
218the way they manage network plans; a creator may hide their own, but cannot bring back a post staff
219hid. A post's author reference is anonymized when the creator's account is deleted (ADR-033), like
220their 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
231Service 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,
235perks, rules, members; their own memberships and checks); staff holding `staff.site.configure` (the contracts staff map) manage
236network plans. Errors are RFC 9457 problem+json. Creators are named by SubjectRef, `usr_` id, VIP
237handle 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
267The `vip.post.*` ids are not in openvibe-contracts yet, so no service can hold them: people (the
268creator, staff) write and moderate posts without a capability. A service that wants to needs a
269contracts entry first.
270
271Evaluate 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
276post, 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
278has no active rule for that resource — a rule the creator sets in VIP (plan or perk requirement,
279sensitive) always wins. Under the fallback the viewer needs an active entitlement to the owner and,
280when the owner defines an active perk carrying that product binding, a plan version that includes one
281of those perks (`perk_missing` otherwise). A creator VIP has never seen is asked of Billing as is.
282The answer has `fallback: true` and `rule: null`.
283
284## Consumer seam: `openvibe-vip/client`
285
286A 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
288token, a malformed answer — is a denial, never an exception and never a "yes".
289
290```js
291const { createVipClient } = require('openvibe-vip/client');
292const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl: `${NETWORK}/oauth/token`, clientId, clientSecret,
293 audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });
294const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens, timeoutMs: 2000 });
295
296const d = await vip.evaluate({ subject: viewer ? viewer.subject : null, resource: { service: 'blog', type: 'post', id: '42' } });
297if (!d.allow) return renderMembersOnlyTeaser(d.reason);
298
299await vip.isMember(viewerSubject, creatorSubject); // boolean; false on any doubt
300await vip.checkEntitlement({ subject, creator, mode: 'authoritative' }); // sensitive content
301```
302
303Until 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
305copied from (Chat, Community and Blog do, in `server/vip/vip-client.js`, until a VIP tag is published);
306moving it into openvibe-sdk is the intended home.
307
308### The product cache and the convergence bound
309
310Every consumer puts `createVipCache` (same module) in front of the client, so the rule for how long
311a product may keep granting after a membership ends is written once:
312
313```js
314const { createVipClient, createVipCache } = require('openvibe-vip/client');
315const cache = createVipCache({ vip, ttlMs: 30_000, denyTtlMs: 10_000, unavailableTtlMs: 2_000 });
316await cache.evaluate({ subject, resource, owner, fallback }); // Community, Blog
317await cache.entitlement({ subject, creator, product: 'chat' }); // Chat's badge
318cache.peekEntitlement({ subject, creator, product: 'chat' }); // never waits (a miss warms it)
319cache.handleEvent(envelope); // vip.membership.changed, billing.entitlement.changed, …
320```
321
322When 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
333A "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
335injected clock. The products' TTLs: Chat 60 s (a badge), Community 30 s, Blog 30 s — each product's
336README repeats its bound and its own test drives the product through a change.
337Live'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
342The reusable membership endpoints of roadmap §11.3, for a creator's stream page, their own site, or any
343other 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
364answer, the last count is shown as `stale: true`, or no count at all. VIP never guesses from its
365projection, which only holds members someone asked about recently. The dashboard's *Share your
366memberships* 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,
371Billing's price and period from `GET /api/v1/rates`, join form) and their members-only posts (the
372title and the tier are public, the text only for a member of that tier) · `/@username/plans/:slug` public terms
373history · `/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
375posts (write, hide) and, for staff, the moderation list, the badge and
376widget 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
384creator with a published plan). `/llms.txt` is the site map for language models; `/llms-full.txt` is
385the same header followed by the full plain text of the home, every public creator page and every
386published 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
388JSON-LD through openvibe-shared/shell.
389
390### IndexNow (openvibe-shared/indexnow)
391
392With `INDEXNOW_KEY` set (8–128 hex or alphanumeric characters, what IndexNow's own tools generate),
393the key file is served at `/<key>.txt` as `text/plain` (before static, so a file cannot shadow it) and
394a 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
396batches and debounces (one POST per 30 s window). A draft, an unchanged edit, a replayed publish and
397network plans (no public creator page) never ping, and a failed ping never takes a publish down.
398Unset: 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
404and 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
417The 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
427After the cutover: run `scripts/subscribe.js`, then open checkout (`VIP_CHECKOUT_PROVIDERS=powerchat,credit`).
428
429## Security notes
430
431Reporting 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
473Not 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
475on the host (Billing is in shadow and has sent VIP no events). The Live import ran on production on
4762026-09-23.
477
478## Deploy
479
480Production deploys with `sudo ovhost deploy vip` on the host (strategy `git-checkout`: fetch,
481fast-forward `/opt/openvibe.vip`, install on a lockfile change, restart, wait for `/api/ready`).
482The 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
484release 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).
486Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
487restart; afterwards `sudo ovhost rollback vip --to <sha>`. One blocker: `idx_vip_rules_resource` (one
488active rule per resource) was replaced by a per-creator unique index, and a release from before it
489recreates the narrower index, which fails at boot once two creators gate the same resource.
490
491## Launch rule
492
493Held on 2026-10-17, when `openvibe.vip` launched (openvibe-contracts 0.200.0 made VIP `live`; Network stopped parking
494the domain). The rule, as written before the launch (plan §12.12):
495
4961. an owning runtime with health/readiness endpoints and observability — ✔ (`/api/health`, truthful `/api/ready`, `/metrics`);
4972. canonical identity/auth integration (Network subjects, scoped service principals) — ✔; the `vip` principal is provisioned on the host;
4983. server-rendered public routes useful without JavaScript — ✔;
4994. real persistence and end-to-end workflows — ✔, against the running Billing and Events since the cutover (2026-10-17);
5005. capability and event registration against `OpenVibe.Contracts` — ✔ v0.19.0;
5016. a migration/seed strategy ✔, a security/threat review (notes above; a review by someone else is still due), sitemap/robots ✔ (no feed by design);
5027. acceptance tests proving the advertised functionality — ✔ for VIP itself; cross-product convergence waits for the consumers.
503
504A placeholder is never counted as an implemented service.
505
506---
507
508Part 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