openvibers/openvibe.vip Commits

Membership card, badge and widget endpoints (roadmap §11.3): public data only, no cookies, safe to embed

OP OpenVibers committed 23 Sept 2026 · fe7eaad183 · 15 files, +560 −14

- GET /embed/:username/card.json (and GET /api/v1/creators/:ref/card for products): the creator's published plans at the terms new members buy (name, description, benefits, version), 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. CORS-open without credentials, cached a minute. Never drafts, archived plans, binding configs or who the members are.
- GET /embed/:username/badge.svg: "★ <badge perk>" plus the creator's name, every name escaped, served with CSP default-src 'none'; sandbox and Cross-Origin-Resource-Policy: cross-origin.
- GET /embed/:username/widget[?theme=light|dark]: name, member count, up to three plans and a join link opening openvibe.vip in a new tab (noopener noreferrer). No script; CSP default-src 'none' with its one inline style allowed by hash, form-action/base-uri 'none', frame-ancestors from VIP_WIDGET_FRAME_ANCESTORS (any site by default, reduced to source expressions); every other VIP page keeps frame-ancestors 'self', and the nginx reference serves /embed/ without the server-wide X-Frame-Options.
- The embeds are mounted before the cookie parser and are the same for everyone: no cookie or token read, none set.
- The member count is Billing's active subscriptions, cached VIP_MEMBER_COUNT_TTL_MS (5 min) with one call at a time; when Billing is down the last count is served as stale, or none. Creators switch it off in the dashboard (vip_creators.show_member_count, added on boot), whose new "Share your memberships" card gives the badge and widget HTML.
- test/embeds.test.js.

Changes

ddiff --git a/.env.example b/.env.example
iindex 9cb06be..4766ac3 100644
---- a/.env.example
++++ b/.env.example
@@@ -52,3 +52,10 @@ VIP_EVENTS_SECRET=
# ── Jobs ─────────────────────────────────────────────────
# VIP_JOBS=off
# VIP_REFRESH_INTERVAL_MS=60000
++
++# ── Public card, badge and widget (/embed) ───────────────
++# The widget's CSP frame-ancestors: any site by default (it is meant to be embedded); e.g.
++# "https://openvibe.live https://*.openvibe.network" to restrict it. The rest of VIP stays 'self'.
++# VIP_WIDGET_FRAME_ANCESTORS=*
++# How long a creator's member count (from Billing) is reused
++# VIP_MEMBER_COUNT_TTL_MS=300000
ddiff --git a/README.md b/README.md
iindex 7f5c9ae..58ff033 100644
---- a/README.md
++++ b/README.md
@@@ -72,7 +72,7 @@ home that is not a cache.
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 # 7 test files: stub Network + Billing, temp DBs, random ports, injected clock
++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
node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]
```
@@@ -157,6 +157,7 @@ handle or `network`.
|---|---|---|---|
| `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), optionally `publish` |
@@@ -244,13 +245,41 @@ 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, "★ <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 |
++| `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) |
++
++**Safe to embed:**
++- The endpoints are the same for everyone. They are mounted before the cookie parser and never read a
++ cookie, token or viewer, and they set no cookie.
++- They run no script.
++- They show nothing private: published plans only, never drafts or archived ones; perk names, never
++ product binding configs; a member count, never who. The creator can switch the count off in the
++ dashboard.
++- Every other VIP page keeps `frame-ancestors 'self'`. The nginx reference config serves `/embed/`
++ without the server-wide `X-Frame-Options`.
++
++**The member count** is Billing's: active subscriptions to the creator. It is reused for
++`VIP_MEMBER_COUNT_TTL_MS` (5 min), with one Billing call at a time per creator. When Billing does not
++answer, the last count is shown as `stale: true`, or no count at all. VIP never guesses from its
++projection, which only holds members someone asked about recently. The dashboard's *Share your
++memberships* card gives the creator the badge and widget HTML and the card URL.
++
## Pages (server-rendered, useful without JavaScript, shared chrome)
`/` directory of creators with published plans · `/:username` a creator's plans (terms, perks,
Billing's price and period from `GET /api/v1/rates`, join form) · `/:username/plans/:slug` public terms
history · `/me` your memberships, the version you joined under, cancel, badge preference ·
--`/dashboard` plans, versions, perks and bindings, members, members-only resources
--(`?as=network` for staff) · `/robots.txt`, `/sitemap.xml` · `/terms`, `/privacy`, `/dmca`
++`/dashboard` plans, versions, perks and bindings, members, members-only resources, the badge and
++widget to share and the member-count switch (`?as=network` for staff) · `/embed/:username/…` (above) · `/robots.txt`, `/sitemap.xml` · `/terms`, `/privacy`, `/dmca`
(openvibe-shared legal). Navbar from `https://openvibe.network/shared/navbar.js`, SSR footer and
`<noscript>` navigation from openvibe-shared. Forms carry HMAC anti-forgery tokens
(`VIP_FORM_SECRET`); cookies are SameSite=Lax. No feed: plans are not a publication stream.
@@@ -302,6 +331,10 @@ final import), `scripts/subscribe.js`, then open checkout (`VIP_CHECKOUT_PROVIDE
- Outbound calls go only to configured service URLs (Network, Billing, Events); no user-supplied
URLs are fetched. Checkout redirects only to http(s) URLs Billing returned.
- Secrets appear as environment variable names only.
++- The embeds (`/embed`) read no cookie or token and set none; the widget runs no script, and its CSP
++ allows only its own hashed style. `VIP_WIDGET_FRAME_ANCESTORS` is reduced to source expressions, so it
++ cannot add a CSP directive. The badge SVG is served sandboxed, with every name escaped.
++ `test/embeds.test.js` covers all of this.
## Acceptance (what the tests prove)
@@@ -315,6 +348,7 @@ final import), `scripts/subscribe.js`, then open checkout (`VIP_CHECKOUT_PROVIDE
| gated-resource evaluation fails closed (no rule, unknown, perk missing, guest, VIP down in the client) | `test/policies.test.js` |
| import idempotent, holds released, dry run keeps nothing, Billing outage reported | `test/import.test.js` |
| VIP works with Live unavailable | no code path calls Live; every test runs without a Live stub |
++| 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) |
Not yet demonstrated (needs the other services): convergence **across consuming products** (Live,
Chat, Community do not call VIP yet), and a real Billing/Events round trip on the host (Billing is in
ddiff --git a/STATUS.json b/STATUS.json
iindex ad02af0..3173336 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -16,14 +16,14 @@
"outbox events vip.plan.published and vip.membership.changed (openvibe-sdk)",
"import of Live's subscription offering, memberships from Billing entitlements",
"14 vip.* capabilities, the service manifest and its events registered in openvibe-contracts v0.19.0",
-- "deployed on the host (loopback only) with truthful /api/ready and /metrics; restore drill passed on the host 2026-09-23 05:23 UTC"
++ "deployed on the host (loopback only) with truthful /api/ready and /metrics; restore drill passed on the host 2026-09-23 05:23 UTC",
++ "reusable membership card (GET /embed/:username/card.json, GET /api/v1/creators/:ref/card), badge (/embed/:username/badge.svg) and embeddable widget (/embed/:username/widget): public data only, no cookies, the widget's CSP frame-ancestors configurable (VIP_WIDGET_FRAME_ANCESTORS), member count from Billing that the creator can hide (not deployed yet)"
],
"not_yet": [
"not launched: openvibe.vip is still an OpenVibe.Sites placeholder and the service answers on loopback only",
"checkout is closed until the Billing cutover (Billing runs in shadow; the owner must re-point the PowerChat webhook first); no billing.* event has reached VIP",
"the Live subscription import (scripts/import-live.js) has not been run (vip_migration_maps 0)",
"Chat, Community and Blog call VIP in code only: not deployed, and the Network has no vip.* grants for them yet; Live and Wiki do not call VIP",
-- "membership badge, card and widget endpoints",
"Billing sells one channel subscription per member and creator: one purchasable plan per creator, no tiers, no network membership product"
],
"updated": "2026-09-23"
ddiff --git a/deploy/nginx/openvibe.vip.conf b/deploy/nginx/openvibe.vip.conf
iindex 4217400..cab3c12 100644
---- a/deploy/nginx/openvibe.vip.conf
++++ b/deploy/nginx/openvibe.vip.conf
@@@ -91,6 +91,16 @@ server {
proxy_pass http://127.0.0.1:4620;
}
++ # ── Embeds: the public card, badge and widget (server/web/embeds.js) ──
++ # Meant to be used by other sites: the widget sets its own CSP frame-ancestors, so this block does
++ # not inherit the server's X-Frame-Options SAMEORIGIN (an add_header here replaces them all). Node
++ # sets nosniff, Referrer-Policy and the CSP on every embed response.
++ location ^~ /embed/ {
++ limit_req zone=ovvip_api burst=30 nodelay;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++ proxy_pass http://127.0.0.1:4620;
++ }
++
# ── Static assets: Node marks hashed ?v= URLs immutable ──
location ~ ^/(?:css|js)/[^/]+\.(?:css|js)$ {
proxy_pass http://127.0.0.1:4620;
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex cb9d0b8..0656a5b 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -107,6 +107,13 @@ function v1Router({ domain, apiAuth }) {
});
}));
++ /** The public membership card (server/domain/cards.js): what /embed/:username/card.json serves. */
++ r.get('/creators/:ref/card', wrap(async (req, res) => {
++ const card = await domain.cards.card(findCreator(req.params.ref));
++ if (!card) fail(404, 'vip.creator_not_found', 'no public membership card for this creator');
++ res.json({ card });
++ }));
++
// ── Plans ────────────────────────────────────────────────
r.get('/plans', wrap((req, res) => {
const c = findCreator(req.query.creator);
ddiff --git a/server/app.js b/server/app.js
iindex 463281b..8eb0db8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -10,6 +10,7 @@
* /api/v1/* API (service tokens + user tokens, see api/v1.js)
* POST /internal/events Billing events from OpenVibe.Events (signed webhook + inbox)
* /auth/* Network SSO session for the pages
++ * /embed/:username/card.json|badge.svg|widget public card, badge and widget (no cookies, see web/embeds.js)
* /, /me, /dashboard, /:username … server-rendered pages
*
* createApp({ config, db, keys, billing, outbox, now, fetchImpl, eventsFetch, log }) — all injectable.
@@@ -29,6 +30,7 @@ const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { createSessionRoutes } = require('./web/session');
const { createWebRoutes } = require('./web/routes');
++const { createEmbedRoutes } = require('./web/embeds');
const { createLayout, assetVersion } = require('./web/layout');
const { createVipReadiness } = require('./observability');
const pages = require('./web/pages');
@@@ -77,6 +79,8 @@ function createApp(opts = {}) {
].join('; '));
next();
});
++ // Embeds come before the cookie parser: they are the same for everyone and never read a cookie.
++ app.use('/embed', createEmbedRoutes({ domain, config }));
app.use(cookieParser());
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'vip', version: VERSION, release: release.release, events: outbox.status() }));
ddiff --git a/server/config.js b/server/config.js
iindex 47c37aa..b0e025b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -89,6 +89,15 @@ function loadConfig(env = process.env) {
refreshIntervalMs: int(env.VIP_REFRESH_INTERVAL_MS, 60 * 1000),
},
liveUrl: trim(env.LIVE_URL || 'https://openvibe.live'),
++
++ // The public card, badge and widget (/embed, server/web/embeds.js). frameAncestors is the
++ // widget's CSP frame-ancestors (any site by default: it is meant to be embedded; the rest of
++ // VIP stays 'self'). The member count comes from Billing, cached for memberCountTtlMs.
++ embeds: {
++ // Source expressions only: a ';' or a line break would start another CSP directive.
++ frameAncestors: String(env.VIP_WIDGET_FRAME_ANCESTORS || '*').replace(/[;,\r\n]+/g, ' ').replace(/\s+/g, ' ').trim() || '*',
++ memberCountTtlMs: int(env.VIP_MEMBER_COUNT_TTL_MS, 5 * 60 * 1000),
++ },
};
}
ddiff --git a/server/db.js b/server/db.js
iindex 006bd86..084ac24 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -240,8 +240,26 @@ function openDb(dbPath) {
return db;
}
++/**
++ * Columns added after the first schema, for new and existing databases alike (ALTER TABLE … ADD
++ * COLUMN when missing; idempotent, never rewrites a row).
++ */
++const COLUMNS = [
++ // The creator's choice: their member count on the public card and widget (server/domain/cards.js).
++ ['vip_creators', 'show_member_count', 'INTEGER NOT NULL DEFAULT 1'],
++];
++
++function addColumns(db) {
++ const have = new Map();
++ for (const [table, name, def] of COLUMNS) {
++ if (!have.has(table)) have.set(table, new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));
++ if (!have.get(table).has(name)) { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${def}`); have.get(table).add(name); }
++ }
++}
++
function migrate(db) {
db.exec(SCHEMA);
++ db.transaction(() => addColumns(db))();
const now = new Date().toISOString();
db.prepare('INSERT OR IGNORE INTO settings (id, created_at) VALUES (1, ?)').run(now);
// The network itself offers network-wide plans and owns network perks.
@@@ -249,4 +267,4 @@ function migrate(db) {
VALUES ('network', 'network', NULL, NULL, NULL, 'OpenVibe', 'system', ?, ?)`).run(now, now);
}
--module.exports = { openDb, migrate };
++module.exports = { openDb, migrate, COLUMNS };
ddiff --git a/server/domain/cards.js b/server/domain/cards.js
nnew file mode 100644
iindex 0000000..e0bdc15
---- /dev/null
++++ b/server/domain/cards.js
@@@ -0,0 +1,118 @@
++'use strict';
++
++/**
++ * The public face of a creator's memberships, reused by other products and other sites (roadmap
++ * §11.3 "reusable membership badge/card/widget endpoints"; rendered by server/web/embeds.js):
++ *
++ * card(creator) plan names and terms (the current published version), perks, the join and
++ * terms links, Billing's price, the member count, and the badge and widget URLs
++ * badge(creator) what a member's badge reads: the name of the badge perk in the plan new members
++ * buy ("Subscriber badge" → "Subscriber"), else "Member"
++ *
++ * Only public things: published plans (never drafts or archived ones), public terms, perk names and
++ * kinds (never product binding configs), and a member COUNT (never who), which the creator can switch
++ * off (vip_creators.show_member_count). A creator with no published plan has no card.
++ *
++ * The member count is Billing's (active subscriptions to the creator), reused for
++ * config.embeds.memberCountTtlMs; when Billing does not answer the last count is kept (flagged
++ * stale), or none is shown. VIP's projection is not used for it: it only holds members someone asked
++ * about recently.
++ */
++const BADGE_SUFFIX = /\s+badge$/i;
++
++function createCards({ db, config, creators, plans, perks, billing, now, log = console }) {
++ const counts = new Map(); // creator id → { at, count, stale, pending }
++ let rates = { at: -Infinity, value: null };
++
++ /** Billing's rates (the price new members pay), cached like the plan pages do. */
++ async function price() {
++ if (now() - rates.at >= config.billing.ratesTtlMs) {
++ try { rates = { at: now(), value: await billing.rates() }; } catch { rates = { at: now() - config.billing.ratesTtlMs + 30_000, value: rates.value }; }
++ }
++ const sub = rates.value && rates.value.subscription;
++ return sub && sub.price_cents ? sub : null;
++ }
++
++ /**
++ * { count, shown, stale }: Billing's count of active members, or count null when the creator hides
++ * it or nothing is known yet. One Billing call at a time per creator; after a failure the last
++ * count is served (stale) and Billing is asked again 30 s later.
++ */
++ async function memberCount(c) {
++ if (!c || c.kind !== 'creator' || !c.show_member_count) return { count: null, shown: false, stale: false };
++ const hit = counts.get(c.id);
++ if (hit && hit.pending) return hit.pending;
++ if (hit && now() - hit.at < config.embeds.memberCountTtlMs) return { count: hit.count, shown: true, stale: hit.stale };
++ const pending = billing.listSubscriptions({ streamer: c.subject, status: 'active' })
++ .then((out) => {
++ const count = new Set((out.subscriptions || []).map((x) => x.subscriber && x.subscriber.id).filter(Boolean)).size;
++ counts.set(c.id, { at: now(), count, stale: false });
++ return { count, shown: true, stale: false };
++ })
++ .catch((e) => {
++ log.warn(`[VIP] member count for ${c.id}: ${e.message}`);
++ const count = hit ? hit.count : null;
++ counts.set(c.id, { at: now() - config.embeds.memberCountTtlMs + 30_000, count, stale: count != null });
++ return { count, shown: true, stale: count != null };
++ });
++ counts.set(c.id, { ...(hit || { at: -Infinity, count: null, stale: false }), pending });
++ return pending;
++ }
++
++ const kindOf = (perkId) => { const p = perks.byId(perkId); return p ? p.kind : 'other'; };
++
++ /** The published plans of a creator, each at the version new members buy. */
++ function publicPlans(c) {
++ return plans.list({ creatorId: c.id }).map((p) => plans.present(p)).filter((p) => p.current_version);
++ }
++
++ function badge(c, list = publicPlans(c)) {
++ const plan = list.find((p) => p.purchasable) || list[0];
++ const v = plan && plan.current_version;
++ const perk = v && v.perks.find((x) => kindOf(x.id) === 'badge');
++ const label = perk ? perk.name.replace(BADGE_SUFFIX, '').trim() || perk.name : 'Member';
++ return { label: label.slice(0, 24) };
++ }
++
++ const urls = (c) => {
++ const u = encodeURIComponent(c.username);
++ return {
++ page: `${config.baseUrl}/${u}`, badge: `${config.baseUrl}/embed/${u}/badge.svg`,
++ widget: `${config.baseUrl}/embed/${u}/widget`, card: `${config.baseUrl}/embed/${u}/card.json`,
++ };
++ };
++
++ /** The public card of a creator row, or null when it has none (no handle, suspended, no published plan). */
++ async function card(c) {
++ if (!c || c.kind !== 'creator' || c.status !== 'active' || !c.username) return null;
++ const list = publicPlans(c);
++ if (!list.length) return null;
++ const u = urls(c);
++ const joiningOpen = config.billing.providers.length > 0 && list.some((p) => p.purchasable);
++ const [members, sub] = await Promise.all([memberCount(c), list.some((p) => p.purchasable) ? price() : null]);
++ return {
++ creator: { username: c.username, display_name: c.display_name || c.username, url: u.page },
++ plans: list.map((p) => {
++ const v = p.current_version;
++ return {
++ id: p.id, slug: p.slug, name: v.name, description: v.description, benefits: v.benefits, version: v.version,
++ perks: v.perks.map((x) => ({ key: x.key, name: x.name, kind: kindOf(x.id) })),
++ purchasable: p.purchasable,
++ join_url: `${u.page}#plan-${encodeURIComponent(p.slug)}`,
++ terms_url: `${u.page}/plans/${encodeURIComponent(p.slug)}`,
++ };
++ }),
++ join_url: u.page,
++ joining_open: joiningOpen,
++ price: sub ? { price_cents: sub.price_cents, period_days: sub.period_days, charged_by: 'OpenVibe.Billing' } : null,
++ members: { count: members.count, shown: members.shown, stale: members.stale },
++ badge: { ...badge(c, list), svg_url: u.badge },
++ widget_url: u.widget,
++ card_url: u.card,
++ };
++ }
++
++ return { card, badge, memberCount, publicPlans, urls };
++}
++
++module.exports = { createCards };
ddiff --git a/server/domain/creators.js b/server/domain/creators.js
iindex b3f648f..4e7e385 100644
---- a/server/domain/creators.js
++++ b/server/domain/creators.js
@@@ -47,12 +47,13 @@ function createCreators({ db, now }) {
return row;
}
-- function update(id, { displayName, bio }) {
++ function update(id, { displayName, bio, showMemberCount }) {
const row = byId(id);
if (!row) fail(404, 'vip.creator_not_found', `no creator ${id}`);
const name = displayName !== undefined ? text(displayName, 'display_name', 80) : row.display_name;
const about = bio !== undefined ? text(bio, 'bio', 2000) : row.bio;
-- db.prepare('UPDATE vip_creators SET display_name = ?, bio = ?, updated_at = ? WHERE id = ?').run(name, about, iso(now()), id);
++ const count = showMemberCount !== undefined ? (showMemberCount ? 1 : 0) : row.show_member_count;
++ db.prepare('UPDATE vip_creators SET display_name = ?, bio = ?, show_member_count = ?, updated_at = ? WHERE id = ?').run(name, about, count, iso(now()), id);
return byId(id);
}
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex b49c1f4..7d4dc89 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -8,6 +8,7 @@ const { createMemberships } = require('./memberships');
const { createEntitlements } = require('./entitlements');
const { createPolicies } = require('./policies');
const { createCheckout } = require('./checkout');
++const { createCards } = require('./cards');
function createDomain({ db, config, outbox, billing, now = () => Date.now(), log = console }) {
const creators = createCreators({ db, now });
@@@ -17,8 +18,9 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), log
const entitlements = createEntitlements({ db, now, config, billing, outbox, memberships, plans, log });
const policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });
const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements });
++ const cards = createCards({ db, config, creators, plans, perks, billing, now, log });
const tx = (fn) => db.transaction(fn)();
-- return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, entitlements, policies, checkout };
++ return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, entitlements, policies, checkout, cards };
}
module.exports = { createDomain };
ddiff --git a/server/web/embeds.js b/server/web/embeds.js
nnew file mode 100644
iindex 0000000..f31d08d
---- /dev/null
++++ b/server/web/embeds.js
@@@ -0,0 +1,133 @@
++'use strict';
++
++/**
++ * Embeds for other products and other sites: a creator's membership card, badge and widget.
++ *
++ * GET /embed/:username/card.json the public card (server/domain/cards.js), CORS-open
++ * GET /embed/:username/badge.svg the creator's member badge as an SVG image
++ * GET /embed/:username/widget an HTML widget for an <iframe>: member count and a join link
++ *
++ * They are the same for everyone. They are mounted before the cookie parser and never read a cookie,
++ * a token or the viewer. They set no cookie, run no script and carry nothing private: published plans,
++ * perk names, a member count the creator may switch off, never who the members are.
++ *
++ * Headers:
++ * card.json Access-Control-Allow-Origin: * (no credentials), Cache-Control public
++ * badge.svg image/svg+xml, CSP default-src 'none'; sandbox (inert if opened directly),
++ * Cross-Origin-Resource-Policy: cross-origin (usable as an <img> anywhere)
++ * widget CSP default-src 'none', the style allowed by its hash, form-action/base-uri 'none',
++ * frame-ancestors = VIP_WIDGET_FRAME_ANCESTORS (any site by default; every other VIP
++ * page stays frame-ancestors 'self'); links open in a new tab with no opener
++ */
++const crypto = require('crypto');
++const express = require('express');
++const { esc } = require('../util');
++
++const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
++// XML 1.0 forbids most control characters; a name carrying one would break the SVG.
++const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
++const xml = (s) => esc(String(s == null ? '' : s).replace(CONTROL, ''));
++const clip = (s, n) => { const a = [...String(s || '')]; return a.length > n ? `${a.slice(0, n - 1).join('')}…` : a.join(''); };
++const fmt = (n) => Number(n).toLocaleString('en-US');
++
++// The widget's styles, one per theme, allowed by hash (no 'unsafe-inline').
++const BASE_CSS = `*{box-sizing:border-box}html,body{margin:0;height:100%}body{font:15px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);color:var(--fg)}
++.w{height:100%;display:flex;flex-direction:column;justify-content:center;gap:6px;padding:14px 16px;border:1px solid var(--line);border-radius:12px}
++.k{margin:0;font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}h1{margin:0;font-size:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.n{margin:0;color:var(--muted)}.n b{color:var(--fg)}ul{margin:0;padding:0;list-style:none;font-size:13px;color:var(--muted)}li{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.b{align-self:flex-start;margin-top:4px;padding:7px 14px;border-radius:8px;background:var(--accent);color:#fff;font-weight:600;text-decoration:none}.b:focus-visible{outline:2px solid var(--fg);outline-offset:2px}`;
++const LIGHT = '--bg:#ffffff;--fg:#0f172a;--muted:#475569;--line:#e2e8f0;--accent:#2563eb';
++const DARK = '--bg:#0b1220;--fg:#e6edf7;--muted:#9aa7bd;--line:rgba(255,255,255,.12);--accent:#3b82f6';
++const STYLES = {
++ auto: `:root{${LIGHT}}@media (prefers-color-scheme:dark){:root{${DARK}}}${BASE_CSS}`,
++ light: `:root{${LIGHT}}${BASE_CSS}`,
++ dark: `:root{${DARK}}${BASE_CSS}`,
++};
++const HASH = Object.fromEntries(Object.entries(STYLES).map(([k, css]) => [k, `'sha256-${crypto.createHash('sha256').update(css).digest('base64')}'`]));
++
++function badgeSvg({ label, name }) {
++ const left = `★ ${clip(label, 24)}`;
++ const right = clip(name, 28);
++ const width = (s) => Math.round([...s].length * 6.8 + 14);
++ const lw = width(left);
++ const rw = width(right);
++ const w = lw + rw;
++ const title = `${label} of ${name} on OpenVibe.VIP`;
++ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20" viewBox="0 0 ${w} 20" role="img" aria-label="${xml(title)}">
++<title>${xml(title)}</title>
++<linearGradient id="g" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".12"/><stop offset="1" stop-opacity=".12"/></linearGradient>
++<clipPath id="r"><rect width="${w}" height="20" rx="4" fill="#fff"/></clipPath>
++<g clip-path="url(#r)"><rect width="${lw}" height="20" fill="#1f2937"/><rect x="${lw}" width="${rw}" height="20" fill="#2563eb"/><rect width="${w}" height="20" fill="url(#g)"/></g>
++<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,Geneva,sans-serif" font-size="11"><text x="${lw / 2}" y="14">${xml(left)}</text><text x="${lw + rw / 2}" y="14">${xml(right)}</text></g>
++</svg>
++`;
++}
++
++function widgetHtml({ card, theme }) {
++ const name = card.creator.display_name;
++ const m = card.members;
++ const count = m.shown && m.count != null ? `<p class="n"><b>${fmt(m.count)}</b> member${m.count === 1 ? '' : 's'}</p>` : '';
++ const plans = card.plans.slice(0, 3).map((p) => `<li>${xml(p.name)}${p.perks.length ? ` · ${xml(p.perks.map((x) => x.name).join(', '))}` : ''}</li>`).join('');
++ const cta = card.joining_open ? `Join ${xml(clip(name, 24))}` : 'See the plans';
++ return `<!DOCTYPE html>
++<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex">
++<title>${xml(name)} memberships · OpenVibe.VIP</title><style>${STYLES[theme]}</style></head>
++<body><main class="w"><p class="k">Memberships on OpenVibe.VIP</p><h1>${xml(name)}</h1>${count}<ul>${plans}</ul>
++<a class="b" href="${xml(card.join_url)}" target="_blank" rel="noopener noreferrer">${cta}</a></main></body></html>
++`;
++}
++
++function createEmbedRoutes({ domain, config }) {
++ const r = express.Router();
++ const { creators, cards } = domain;
++ const frameAncestors = config.embeds.frameAncestors;
++
++ /** The creator and its card, or null (unknown handle, suspended, no published plan). */
++ async function lookup(username) {
++ if (!USERNAME_RE.test(String(username || ''))) return null;
++ const c = creators.byUsername(username);
++ const card = c ? await cards.card(c) : null;
++ return card ? { c, card } : null;
++ }
++ const common = (res) => res.set({ 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex' });
++
++ r.get('/:username/card.json', async (req, res, next) => {
++ try {
++ const found = await lookup(req.params.username);
++ common(res).set({ 'Access-Control-Allow-Origin': '*', 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'" });
++ if (!found) return res.status(404).set('Cache-Control', 'public, max-age=60').json({ code: 'vip.creator_not_found', detail: 'no public membership card for this name' });
++ return res.set('Cache-Control', 'public, max-age=60').json(found.card);
++ } catch (e) { return next(e); }
++ });
++
++ r.get('/:username/badge.svg', async (req, res, next) => {
++ try {
++ const found = await lookup(req.params.username);
++ common(res).set({ 'Content-Security-Policy': "default-src 'none'; sandbox", 'Cross-Origin-Resource-Policy': 'cross-origin', 'Access-Control-Allow-Origin': '*' });
++ if (!found) return res.status(404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('No member badge for this name.\n');
++ return res.status(200).set('Cache-Control', 'public, max-age=300').type('image/svg+xml; charset=utf-8')
++ .send(badgeSvg({ label: found.card.badge.label, name: found.card.creator.display_name }));
++ } catch (e) { return next(e); }
++ });
++
++ r.get('/:username/widget', async (req, res, next) => {
++ try {
++ const theme = ['light', 'dark'].includes(req.query.theme) ? req.query.theme : 'auto';
++ const found = await lookup(req.params.username);
++ common(res).set({
++ 'Content-Security-Policy': [`default-src 'none'`, `style-src ${HASH[theme]}`, "img-src 'none'", "base-uri 'none'", "form-action 'none'", `frame-ancestors ${frameAncestors}`].join('; '),
++ 'Referrer-Policy': 'strict-origin-when-cross-origin',
++ });
++ res.removeHeader('X-Frame-Options');
++ if (!found) {
++ return res.status(404).set('Cache-Control', 'public, max-age=60').type('html')
++ .send(`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>OpenVibe.VIP</title><style>${STYLES[theme]}</style></head><body><main class="w"><p class="k">OpenVibe.VIP</p><p class="n">No memberships to show.</p></main></body></html>\n`);
++ }
++ return res.status(200).set('Cache-Control', 'public, max-age=60').type('html').send(widgetHtml({ card: found.card, theme }));
++ } catch (e) { return next(e); }
++ });
++
++ return r;
++}
++
++module.exports = { createEmbedRoutes, badgeSvg, widgetHtml, STYLES, HASH };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 266d693..3a77312 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -169,7 +169,23 @@ ${actions}
<details><summary>Versions (${esc((plan.versions || []).length)})</summary><ul class="versions">${versions}</ul></details></article>`;
}
--function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base }) {
++/** The dashboard's "Share" card: the public card, badge and widget, and the member count switch. */
++function shareCard({ creator, embeds, hasPlan }) {
++ if (!embeds) return '';
++ if (!hasPlan) return '<section class="card"><h2>Share your memberships</h2><p class="muted">Publish a plan to get a member badge, a card and a widget for your other sites.</p></section>';
++ // The snippets are HTML the creator pastes elsewhere: escaped as HTML first, then again for the input.
++ const name = esc(creator.display_name || creator.username);
++ const img = `<a href="${esc(embeds.page)}"><img src="${esc(embeds.badge)}" alt="Member of ${name} on OpenVibe.VIP" height="20"></a>`;
++ const frame = `<iframe src="${esc(embeds.widget)}" title="${name} memberships" width="320" height="180" style="border:0" loading="lazy"></iframe>`;
++ return `<section class="card"><h2>Share your memberships</h2>
++<p class="muted small">For your stream page, your site or anywhere else. They show your published plans${creator.show_member_count ? ' and how many members you have' : ''}, never who your members are, and they set no cookies.</p>
++<p><img src="${esc(embeds.badge)}" alt="" height="20"></p>
++<label>Badge (HTML) <input readonly value="${esc(img)}"></label>
++<label>Widget (HTML; add <code>?theme=light</code> or <code>?theme=dark</code> to fix its colours) <input readonly value="${esc(frame)}"></label>
++<label>Card for developers (JSON, open to any site) <input readonly value="${esc(embeds.card)}"></label></section>`;
++}
++
++function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base, embeds }) {
if (!viewer) {
return `<section class="card narrow"><h1>Creator dashboard</h1><p>Sign in with your OpenVibe account to offer a membership plan.</p>
<p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;
@@@ -181,7 +197,9 @@ function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPe
${isNetwork ? '' : `<section class="card"><h2>Profile</h2><form method="post" action="${base}/profile">${csrf(token)}
<label>Display name <input name="display_name" maxlength="80" value="${esc(creator.display_name || '')}"></label>
<label>About your memberships <textarea name="bio" maxlength="2000" rows="3">${esc(creator.bio || '')}</textarea></label>
--<button type="submit">Save</button></form></section>`}
++<label class="check"><input type="checkbox" name="show_member_count" value="1"${creator.show_member_count ? ' checked' : ''}> Show how many members you have on your card and widget</label>
++<button type="submit">Save</button></form></section>
++${shareCard({ creator, embeds, hasPlan: plans.some((p) => p.status === 'published') })}`}
<section><h2>Plans</h2>${plans.length ? plans.map((p) => planEditor({ plan: p, allPerks, token, base })).join('') : '<p class="muted">No plans yet.</p>'}
<details class="card"><summary>New plan</summary><form method="post" action="${base}/plans">${csrf(token)}
<label>Name <input name="name" maxlength="80" required></label>
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex db8a12e..105c104 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -20,7 +20,7 @@ const { viewerMiddleware } = require('./session');
const { createForms } = require('./forms');
const pages = require('./pages');
--const RESERVED = new Set(['me', 'dashboard', 'auth', 'api', 'internal', 'css', 'js', 'terms', 'privacy', 'dmca', 'robots.txt', 'sitemap.xml', 'release.json', 'metrics', 'favicon.ico']);
++const RESERVED = new Set(['me', 'dashboard', 'auth', 'api', 'internal', 'css', 'js', 'terms', 'privacy', 'dmca', 'robots.txt', 'sitemap.xml', 'release.json', 'metrics', 'favicon.ico', 'embed']);
function createWebRoutes({ domain, config, layout, userAuth }) {
const router = express.Router();
@@@ -71,7 +71,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
});
}
-- router.get('/robots.txt', (req, res) => res.type('text/plain').send(`User-agent: *\nDisallow: /me\nDisallow: /dashboard\nDisallow: /auth/\nDisallow: /api/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));
++ router.get('/robots.txt', (req, res) => res.type('text/plain').send(`User-agent: *\nDisallow: /me\nDisallow: /dashboard\nDisallow: /auth/\nDisallow: /api/\nDisallow: /embed/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));
router.get('/sitemap.xml', (req, res) => {
const urls = [`${config.baseUrl}/`, ...creators.listPublic({ limit: 5000 }).map((c) => `${config.baseUrl}/${encodeURIComponent(c.username)}`)];
res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${u.replace(/&/g, '&amp;')}</loc></url>`).join('\n')}\n</urlset>\n`);
@@@ -144,6 +144,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
body: pages.dashboard({
viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource,
rules: isNetwork ? [] : policies.list(creator.id).map(policies.present), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',
++ embeds: !isNetwork && creator.username ? domain.cards.urls(creator) : null,
}),
});
} catch (e) { next(e); }
@@@ -157,7 +158,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
post(`${base}/profile`, async (req, res, to) => {
withAs(req);
const c = dashCreator(req);
-- creators.update(c.id, { displayName: req.body.display_name, bio: req.body.bio });
++ creators.update(c.id, { displayName: req.body.display_name, bio: req.body.bio, showMemberCount: c.kind === 'creator' ? bool(req.body.show_member_count) : undefined });
back(res, to, 'ok', 'Profile saved.');
}, opts);
post(`${base}/plans`, async (req, res, to) => {
ddiff --git a/test/embeds.test.js b/test/embeds.test.js
nnew file mode 100644
iindex 0000000..773bd6f
---- /dev/null
++++ b/test/embeds.test.js
@@@ -0,0 +1,184 @@
++'use strict';
++/**
++ * The reusable membership card, badge and widget (roadmap §11.3): public data only (published plans,
++ * perk names, a member count the creator may hide, never who the members are), the same for everyone
++ * (no cookie read or set), safe to embed (the widget's CSP frame-ancestors, no script, the badge inert
++ * when opened), and a member count from Billing that survives a Billing outage without inventing one.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, harness } = require('./helpers/app');
++
++const { test, run } = harness('embeds');
++
++(async () => {
++ const t = await boot();
++ const lena = t.network.newUser('lena', { role: 'streamer' });
++ const quiet = t.network.newUser('quiet', { role: 'streamer' });
++ const members = [t.network.newUser('milo'), t.network.newUser('nora')];
++ const cookie = `ov_token=${t.network.signUser(members[0])}`;
++ const get = async (p, headers = {}) => {
++ const r = await fetch(t.base + p, { headers, redirect: 'manual' });
++ return { status: r.status, headers: r.headers, text: await r.text() };
++ };
++ let plan;
++
++ test('setup: a published plan with a badge perk, a draft and an archived plan, two members in Billing', async () => {
++ await t.call('POST', '/api/v1/perks', { user: lena, body: { name: 'Supporter badge', key: 'supporter', kind: 'badge', bindings: [{ product: 'chat', binding: 'badge', config: { badge: 'secret-config' } }] } });
++ await t.call('POST', '/api/v1/perks', { user: lena, body: { name: 'Members-only posts', key: 'posts', kind: 'gated_content' } });
++ plan = (await t.call('POST', '/api/v1/plans', { user: lena, body: { name: 'Club', description: 'The club', benefits: ['A badge in chat'], perks: ['supporter', 'posts'], publish: true } })).json.plan;
++ assert.ok(plan && plan.purchasable);
++ await t.call('POST', '/api/v1/plans', { user: lena, body: { name: 'Secret draft plan', billing_kind: null } });
++ const old = (await t.call('POST', '/api/v1/plans', { user: lena, body: { name: 'Old terms plan', billing_kind: null, publish: true } })).json.plan;
++ await t.call('POST', `/api/v1/plans/${old.id}/archive`, { user: lena });
++ for (const m of members) t.billing.pay(m.subject, lena.subject);
++ await t.call('POST', '/api/v1/plans', { user: quiet, body: { name: 'Draft only' } });
++ });
++
++ test('card.json: published plans, perks, join and terms links, Billing\'s price and member count; CORS-open', async () => {
++ const r = await get('/embed/lena/card.json');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null);
++ assert.match(r.headers.get('cache-control'), /^public/);
++ assert.strictEqual(r.headers.get('set-cookie'), null);
++ const card = JSON.parse(r.text);
++ assert.deepStrictEqual(card.creator, { username: 'lena', display_name: 'Lena', url: 'http://vip.test/lena' });
++ assert.deepStrictEqual(card.plans.map((p) => p.name), ['Club'], 'no drafts, no archived plans');
++ const club = card.plans[0];
++ assert.deepStrictEqual(club.perks.map((p) => [p.key, p.kind]), [['supporter', 'badge'], ['posts', 'gated_content']]);
++ assert.strictEqual(club.join_url, 'http://vip.test/lena#plan-club');
++ assert.strictEqual(club.terms_url, 'http://vip.test/lena/plans/club');
++ assert.strictEqual(card.joining_open, true);
++ assert.deepStrictEqual(card.price, { price_cents: 499, period_days: 30, charged_by: 'OpenVibe.Billing' });
++ assert.deepStrictEqual(card.members, { count: 2, shown: true, stale: false });
++ assert.strictEqual(card.badge.label, 'Supporter');
++ assert.strictEqual(card.badge.svg_url, 'http://vip.test/embed/lena/badge.svg');
++ assert.strictEqual(card.widget_url, 'http://vip.test/embed/lena/widget');
++ for (const secret of [lena.subject, ...members.map((m) => m.subject), 'secret-config', 'Secret draft', 'Old terms']) {
++ assert.ok(!r.text.includes(secret), `the card leaks ${secret}`);
++ }
++ const signedIn = await get('/embed/lena/card.json', { Cookie: cookie, Authorization: 'Bearer not-even-checked' });
++ assert.strictEqual(signedIn.text, r.text, 'the same for everyone: no cookie or token is read');
++ assert.strictEqual(signedIn.headers.get('set-cookie'), null);
++ });
++
++ test('the API serves the same card to products by subject', async () => {
++ const r = await t.call('GET', `/api/v1/creators/${lena.subject}/card`, { token: null });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.card.creator.username, 'lena');
++ assert.strictEqual((await t.call('GET', `/api/v1/creators/${quiet.subject}/card`, { token: null })).status, 404, 'no published plan, no card');
++ assert.strictEqual((await t.call('GET', '/api/v1/creators/network/card', { token: null })).status, 404);
++ });
++
++ test('badge.svg: an inert SVG image anyone can embed, names escaped', async () => {
++ const r = await get('/embed/lena/badge.svg', { Cookie: cookie });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^image\/svg\+xml/);
++ assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);
++ assert.match(r.headers.get('content-security-policy'), /sandbox/);
++ assert.strictEqual(r.headers.get('cross-origin-resource-policy'), 'cross-origin');
++ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');
++ assert.strictEqual(r.headers.get('set-cookie'), null);
++ assert.match(r.text, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
++ assert.match(r.text, /★ Supporter/);
++ assert.match(r.text, />Lena</);
++ assert.ok(!/<script|\son\w+=|href=/i.test(r.text), 'no script, handler or link');
++ // A display name with markup is text in the SVG.
++ const evil = (await t.page('/dashboard', { user: lena })).text;
++ const csrf = t.csrf(evil);
++ await t.page('/dashboard/profile', { user: lena, method: 'POST', form: { _csrf: csrf, display_name: '<script>alert(1)</script> & "x"', bio: '', show_member_count: '1' } });
++ const e = await get('/embed/lena/badge.svg');
++ assert.ok(!e.text.includes('<script'), e.text);
++ assert.match(e.text, /&lt;script&gt;alert\(1\)&lt;\/script&gt; &amp; &quot;x&quot;|&lt;script&gt;alert\(1\)&lt;\/script&gt; &amp; &quot;…/);
++ await t.page('/dashboard/profile', { user: lena, method: 'POST', form: { _csrf: csrf, display_name: 'Lena', bio: '', show_member_count: '1' } });
++ });
++
++ test('widget: embeddable anywhere, no script, its style allowed by hash, the rest of VIP still not framable', async () => {
++ const r = await get('/embed/lena/widget');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/html/);
++ const csp = r.headers.get('content-security-policy');
++ assert.match(csp, /default-src 'none'/);
++ assert.match(csp, /frame-ancestors \*/);
++ assert.match(csp, /form-action 'none'/);
++ assert.match(csp, /base-uri 'none'/);
++ assert.ok(!/unsafe-inline/.test(csp));
++ const style = r.text.match(/<style>([\s\S]*?)<\/style>/)[1];
++ assert.ok(csp.includes(`'sha256-${crypto.createHash('sha256').update(style).digest('base64')}'`), 'the inline style is allowed by its hash');
++ assert.strictEqual(r.headers.get('x-frame-options'), null);
++ assert.strictEqual(r.headers.get('set-cookie'), null);
++ assert.ok(!/<script|\son\w+=/i.test(r.text), 'no script or handler');
++ assert.match(r.text, /<b>2<\/b> members/);
++ assert.match(r.text, /<a class="b" href="http:\/\/vip\.test\/lena" target="_blank" rel="noopener noreferrer">Join Lena<\/a>/);
++ assert.match(r.text, /Club · Supporter badge, Members-only posts/);
++ for (const m of members) assert.ok(!r.text.includes(m.subject) && !r.text.includes(m.username));
++ const signedIn = await get('/embed/lena/widget', { Cookie: cookie });
++ assert.strictEqual(signedIn.text, r.text, 'no personalisation');
++ const dark = await get('/embed/lena/widget?theme=dark');
++ const darkStyle = dark.text.match(/<style>([\s\S]*?)<\/style>/)[1];
++ assert.notStrictEqual(darkStyle, style);
++ assert.ok(dark.headers.get('content-security-policy').includes(crypto.createHash('sha256').update(darkStyle).digest('base64')));
++ const page = await get('/lena');
++ assert.match(page.headers.get('content-security-policy'), /frame-ancestors 'self'/, 'the plan page itself cannot be framed');
++ });
++
++ test('the creator can hide their member count', async () => {
++ const dash = await t.page('/dashboard', { user: lena });
++ assert.match(dash.text, /Share your memberships/);
++ assert.match(dash.text, /\/embed\/lena\/widget/);
++ await t.page('/dashboard/profile', { user: lena, method: 'POST', form: { _csrf: t.csrf(dash.text), display_name: 'Lena', bio: '' } });
++ const card = JSON.parse((await get('/embed/lena/card.json')).text);
++ assert.deepStrictEqual(card.members, { count: null, shown: false, stale: false });
++ assert.ok(!/members<\/p>/.test((await get('/embed/lena/widget')).text));
++ await t.page('/dashboard/profile', { user: lena, method: 'POST', form: { _csrf: t.csrf(dash.text), display_name: 'Lena', bio: '', show_member_count: '1' } });
++ assert.strictEqual(JSON.parse((await get('/embed/lena/card.json')).text).members.count, 2);
++ });
++
++ test('member count: cached, then Billing\'s new answer; when Billing is down the last count is kept (stale), never invented', async () => {
++ t.billing.pay(t.network.newUser('olga').subject, lena.subject);
++ assert.strictEqual(JSON.parse((await get('/embed/lena/card.json')).text).members.count, 2, 'cached');
++ t.clock.advance(t.config.embeds.memberCountTtlMs + 1);
++ assert.strictEqual(JSON.parse((await get('/embed/lena/card.json')).text).members.count, 3);
++ t.billing.state.down = true;
++ t.clock.advance(t.config.embeds.memberCountTtlMs + 1);
++ assert.deepStrictEqual(JSON.parse((await get('/embed/lena/card.json')).text).members, { count: 3, shown: true, stale: true });
++ // A creator VIP never counted: no number rather than a guess.
++ const fresh = t.network.newUser('fresh', { role: 'streamer' });
++ t.billing.state.down = false;
++ await t.call('POST', '/api/v1/plans', { user: fresh, body: { name: 'Fresh club', publish: true } });
++ t.billing.state.down = true;
++ const r = await get('/embed/fresh/card.json');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(JSON.parse(r.text).members, { count: null, shown: true, stale: false });
++ assert.match((await get('/embed/fresh/widget')).text, /Fresh/);
++ t.billing.state.down = false;
++ });
++
++ test('no card for an unknown name, a creator without a published plan, or a malformed name', async () => {
++ for (const name of ['nobody', 'quiet', '..', '%3Cscript%3E']) {
++ for (const kind of ['card.json', 'badge.svg', 'widget']) {
++ const r = await get(`/embed/${name}/${kind}`);
++ assert.strictEqual(r.status, 404, `${name}/${kind} → ${r.status}`);
++ assert.strictEqual(r.headers.get('set-cookie'), null);
++ }
++ }
++ const w = await get('/embed/nobody/widget');
++ assert.match(w.headers.get('content-security-policy'), /default-src 'none'/);
++ assert.match((await get('/robots.txt')).text, /Disallow: \/embed\//);
++ });
++
++ test('VIP_WIDGET_FRAME_ANCESTORS restricts who may frame the widget (and cannot add a directive)', async () => {
++ const t2 = await boot({ env: { VIP_WIDGET_FRAME_ANCESTORS: "https://openvibe.live https://example.com; script-src 'unsafe-inline'" } });
++ const zed = t2.network.newUser('zed', { role: 'streamer' });
++ await t2.call('POST', '/api/v1/plans', { user: zed, body: { name: 'Zed club', publish: true } });
++ const r = await fetch(`${t2.base}/embed/zed/widget`);
++ assert.strictEqual(r.status, 200);
++ const csp = r.headers.get('content-security-policy');
++ assert.match(csp, /frame-ancestors https:\/\/openvibe\.live https:\/\/example\.com script-src 'unsafe-inline'$/);
++ assert.ok(!/; script-src/.test(csp), 'no directive smuggled in');
++ await t2.close();
++ });
++
++ await run();
++})().catch((e) => { console.error(e); process.exit(1); });