# OpenVibe.VIP > Memberships: plans, perks and benefits recognised across every OpenVibe site. **Live site:** https://openvibe.vip · **API docs:** https://openvibe.services · **For AI:** https://openvibe.vip/llms.txt **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` **Status:** alpha — **launched 2026-10-17** at [openvibe.vip](https://openvibe.vip) (openvibe-contracts 0.200.0), right after the Billing cutover made OpenVibe.Billing the money ledger. Checkout is open (PowerChat and Vibes credit); the 43 plans imported from Live are on sale, with no memberships sold yet. It runs on the host since 2026-09-23. **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. 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 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`; the old `/:username` 301s there) 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 (database 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. An eleventh, `vip_membership_periods` (migration 0002), is the per-period charge ledger described under "Renewals, grace and refunds" below; it is a record of Billing's transactions, not an authority for anything. ## Does not own - 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). ## Depends on - **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. - **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox. - **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. - **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). - **openvibe-contracts** v0.76.0, **openvibe-sdk** v0.28.0 (outbox, inbox, delivery signatures, per-actor limits), **openvibe-shared** v2.2.0 (Frame, legal pages, release manifest and update metrics, metrics, readiness). - Consumers, through the client below: Chat (the member badge), Community (members-only spaces and threads), Blog (members-only posts) and Wiki (VIP spaces and pages); Live is not wired yet. - **Not** OpenVibe.Live: VIP works with Live unavailable (nothing here calls Live). ## Capabilities Implemented here (the service manifest's `capabilities`, 14 `vip.*` ids, audience `openvibe.vip`): `vip.plan.create|update|archive|list`, `vip.perk.create|update|list`, `vip.membership.checkout|status`, `vip.entitlement.check`, `vip.resource.policy.get|set|evaluate` and `vip.creator.members.list`. Called elsewhere, as the service principal `vip`: | Service | Grant | Why | |---|---|---| | OpenVibe.Billing | `billing.intent.create`, `billing.subscription.manage`, `billing.entitlement.check` | checkout, credit-paid periods and cancel, the authoritative check | | 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) | | OpenVibe.Network | `network.account.export.contribute`, `network.account.deletion.confirm` (granted last, once this release is live) | account export and deletion (ADR-033) | ## Run it ```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 # every test/*.test.js: stub Network + Billing, temp DBs, random ports, injected clock node scripts/subscribe.js # create the three Events subscriptions for Billing's events ``` Production: `/opt/openvibe.vip`, env `/etc/openvibe/vip.env`, unit [deploy/systemd/openvibe-vip.service](deploy/systemd/openvibe-vip.service), vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) (installed 2026-10-17: `openvibe.vip` serves VIP). The three Events subscriptions exist. ## Design ### Versioned plans - 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). - **Tiers** (since 2026-10-17, openvibe-contracts 0.201.0): a plan sold through Billing is one tier of the creator's membership (`billing_tier` 1–10, chosen at creation, else the next free one). A creator publishes at most **one plan per tier** (a unique index; a second is refused with `409 vip.plan.billing_product_taken`) and sets each tier's monthly price on Billing (`server/domain/prices.js`, `billing.tier.manage`: the dashboard's "Set price", or `price_cents` on `POST /plans` / `PATCH /plans/:id`). VIP never keeps a price. Tier 1 without a price sells at Billing's default; a higher tier without one cannot be joined. A member holds one tier of a creator at a time (Billing refuses a second while a period runs); a subscription made elsewhere (Live's) is tier 1, and Billing's `tier` on `billing.entitlement.changed` files a membership made without a checkout under that tier's plan. Network memberships still need a Billing product: 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 `createPgInbox`, 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. ### Renewals, grace and refunds (Billing does them; VIP records and converges) - A membership renews by **Billing's own sweep**: one charge per period from the subscriber's credit, idempotent on `renew::`, so a retried or replayed renewal never charges twice. VIP has no renewal call; it projects `billing.entitlement.changed` (`reason: renewed`), and a renewal keeps the plan version the membership already has. - **Charge ledger.** `vip_membership_periods` holds one row per paid period, unique on the Billing transaction (`billing_transaction_id`), written from a `granted`/`renewed` entitlement event that names its `transaction_id` and from the credit checkout answer. A replayed event, or the same transaction again, never records a second row. `billing.transaction.reversed` marks the period it reverses as `reversed` (`reversed_by` = the reversing transaction); if the reversal arrives **before** the grant event, a tombstone is written and the grant that follows leaves it reversed and grants nothing. Provider refunds and chargebacks (`entitlements_revoked`) and credit refunds of a subscription period (`metadata.original_type = subscription`) both count; a reversal of anything else is ignored. - **Renewal grace.** When a renewal charge fails Billing moves the subscription to `past_due` until `grace_until`, retrying on the same key, then ends it (`grace_ended`). VIP stores both (`vip_entitlement_projection.grace_until`), answers `status: inactive` with `grace: { until, reason: "renewal_failed" }` while the grace runs (`null` otherwise), and **never authorizes a `past_due` row**, whatever an event says. After the grace the membership row is kept and inactive; a later paid renewal restores it on the same plan version. `vip.membership.changed` carries `renewal_failed` / `grace_ended` as its `reason`; its payload is unchanged. - **Audit.** `periods.audit()` runs with the projection refresh job: an active, fresh row that claims a payment (last word `granted`/`renewed`, or the pair already has ledger rows) without a `paid` period reaching its `expires_at`, or whose period was reversed, is logged, counted (`vip_period_audit_offenders`) and put in doubt (`valid_until = 0`) so the next check asks Billing. Pairs VIP only knows from a Billing check (imports, shadow entitlements) are not audited until a payment event names them. ### 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:`). - PowerChat: Billing answers with a `checkout_ref` (`pcsub:`, routed through the site) and no URL, and VIP builds the link from `VIP_POWERCHAT_LINK_TEMPLATE` (`{ref}`, `{cents}`). Production sets it since 2026-10-17 to PowerChat's canonical tip link on the site's PowerChat account, as OpenVibe.Live builds it (`https:////tip?app_client_id=…&app_ref={ref}&app_purpose=subscription&app_amount_cents={cents}`); without it the page shows the reference to pay with. - 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). ### Members-only posts (the creator's own posts) A creator writes posts for the members of one or more of their tiers (Billing's tiers of that creator, `vip_plans.billing_tier`). A post carries a title, a body in Markdown (rendered to safe HTML: the source can never carry markup) and an optional image (a Media object — the bytes live in OpenVibe.Media under VIP's tenant, the post keeps the `med_` reference). Who may read the text is never stored: it is answered on every request by the entitlement check (Billing's truth, projected) and the tier the member holds. The list of posts never carries a body, and the body is served only after that check. So: * a signed-out visitor and a non-member see the title, the tier it needs and a way to join — never the text; * a member of a tier reads a post written for their tier or for a lower one (a higher tier includes the lower); * a member of another tier reads nothing; * a hidden post is not served at all — not its title, not its text. A locked post offers a way in only when a tier that reads it can be joined: the "Become a member" button points at the cheapest such plan. When none can be joined (the plan has no price yet, or is not sold through Billing), the post says so — "This post is for the members of Tier 2, which is not open to join yet." — and offers no button, because a primary button must never lead to a dead end. The line carries the shared lock glyph. The creator reads their own posts (on their page and on the dashboard). Staff hide any creator's post, the way they manage network plans; a creator may hide their own, but cannot bring back a post staff hid. A post's author reference is anonymized when the creator's account is deleted (ADR-033), like their plans and perks. ### 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 released in openvibe-contracts v0.19.0; the drafts stay in [docs/capabilities-proposal/](docs/capabilities-proposal/); 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 holding `staff.site.configure` (the contracts staff map) 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 /creators/:ref/card` | — (public) | — | the membership card (below), the same as `/embed/:username/card.json` | | `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) at `tier` (else the next free one), optionally `publish` and `price_cents` (the tier's monthly price, set on Billing) | | `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) | | `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, 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`) | | `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 | | `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}` | | `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** | | `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 | | `GET /creators/:ref/posts` | — (public) | owner sees the hidden posts too | the posts of a creator: title, tiers, image, date — never the text | | `GET /creators/:ref/posts/:id` | — (public) | owner / staff | one post; the text only for a member of its tier (or the creator, or staff) | | `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 | | `POST /creators/:ref/posts` | `vip.post.create` | the creator | write a post `{title, body, tiers}` for one or more of the creator's tiers | | `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 | The `vip.post.*` ids are not in openvibe-contracts yet, so no service can hold them: people (the creator, staff) write and moderate posts without a capability. A service that wants to needs a contracts entry first. Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`, `invalid_fallback`, `creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`, `plan_required`, `perk_missing`. Only `allow: true` allows. **The product's default gate (`fallback`).** A product that marks its own resource members-only (a Blog post, a Community space or thread) does not register a VIP rule per resource. It asks with `fallback: { requirement: 'member', binding: 'blog:gated_post' }`, which applies only when the owner has no active rule for that resource — a rule the creator sets in VIP (plan or perk requirement, sensitive) always wins. Under the fallback the viewer needs an active entitlement to the owner and, when the owner defines an active perk carrying that product binding, a plan version that includes one of those perks (`perk_missing` otherwise). A creator VIP has never seen is asked of Billing as is. The answer has `fallback: true` and `rule: null`. ## Consumer seam: `openvibe-vip/client` 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". ```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 }); 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), or vendor the file verbatim with the commit it was copied from (Chat, Community and Blog do, in `server/vip/vip-client.js`, until a VIP tag is published); moving it into openvibe-sdk is the intended home. ### The product cache and the convergence bound Every consumer puts `createVipCache` (same module) in front of the client, so the rule for how long a product may keep granting after a membership ends is written once: ```js const { createVipClient, createVipCache } = require('openvibe-vip/client'); const cache = createVipCache({ vip, ttlMs: 30_000, denyTtlMs: 10_000, unavailableTtlMs: 2_000 }); await cache.evaluate({ subject, resource, owner, fallback }); // Community, Blog await cache.entitlement({ subject, creator, product: 'chat' }); // Chat's badge cache.peekEntitlement({ subject, creator, product: 'chat' }); // never waits (a miss warms it) cache.handleEvent(envelope); // vip.membership.changed, billing.entitlement.changed, … ``` When a membership changes (Billing emits `billing.entitlement.changed`, VIP applies it and emits `vip.membership.changed`), a product stops granting within: | Situation | Bound | |---|---| | the product hands the event to `cache.handleEvent` | at once (the member–creator pair is dropped; an answer in flight is not stored) | | VIP has the event, the product does not | `ttlMs` (a cached "yes" lives at most that long) | | 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` | | 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` | | VIP unreachable | `ttlMs`, then every answer is a denial (a failure never extends a "yes") | A "no" is cached `denyTtlMs` (a new member waits at most that long); failures `unavailableTtlMs`. [test/convergence.test.js](test/convergence.test.js) proves every row against the real VIP with an injected clock. The products' TTLs: Chat 60 s (a badge), Community 30 s, Blog 30 s — each product's README repeats its bound and its own test drives the product through a change. 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`. ## Card, badge and widget (`/embed`) The reusable membership endpoints of roadmap §11.3, for a creator's stream page, their own site, or any other product. They cover creators with at least one published plan ([server/domain/cards.js](server/domain/cards.js), [server/web/embeds.js](server/web/embeds.js)): | Endpoint | What | Headers | |---|---|---| | `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` | | `GET /embed/:username/badge.svg` | a two-part badge, "★ " and the creator's name, for an `` | `image/svg+xml`, CSP `default-src 'none'; sandbox`, `Cross-Origin-Resource-Policy: cross-origin`, cached 5 min | | `GET /embed/:username/widget[?theme=light\|dark]` | an HTML widget for an `