openvibers/openvibe.tips Commits

Members-only posts on a creator's Tips page, gated by VIP membership (#194)

OP OpenVibers committed 7 days ago · 7b007b701a · 19 files, +972 −16

* Members-only posts on a creator's Tips page, gated by VIP membership

A creator writes posts for the members of their OpenVibe.VIP tiers. The title and the tiers are
public; the text is served only to a member of a tier that reads it.

- The gate is server-side: the body never leaves the server for anyone else — not in the page, not
  in JSON-LD, not in /llms-full.txt, not in the sitemap, not in a preview and not in the API list.
  The check is OpenVibe.VIP's entitlement answer for member → creator (Billing holds the truth),
  behind the shared product cache (openvibe-sdk/vip); a failure is a denial.
- Everyone else gets a card that says "Members only", the tiers it is written for and a "Become a
  member" button pointing at the creator's VIP plan. A tier nobody can join yet shows no button.
- The creator writes and hides their posts in the dashboard and always reads their own text. A
  hidden post is not served at all.
- API: GET /api/v1/profiles/:creator/posts, GET .../posts/:id, GET .../posts/:id/body (403
  tips.member_only for anyone else), POST .../posts, POST /api/v1/posts/:id/hide|unhide.

The Network needs the grant "tips vip.entitlement.check openvibe.vip"; without it every answer is
a denial (no post text, no tiers, no join link).

Tests: test/member-posts.test.js covers every read path (the creator page, the member's page, the
API list, the API body, llms-full.txt, the sitemap, a hidden post, VIP down and a tier nobody can
join).

* test: also cover the JSON-LD and the Markdown twin of the creator page

* Members-only posts: private pages, the house date, and a card that reads

Opus's review of #194.

Security
- Every HTML answer is now 'private, no-cache' with 'Vary: Cookie'. A shared cache (Cloudflare)
  stores by URL and ignores Vary, Cookie and Authorization (the 10-15 Media leak), and these pages
  carry the viewer's own state: the footer shows who is signed in, and a member's creator page
  carries the text of the posts they may read. The machine-readable pages and the agent twins carry
  no viewer's state and stay public; the API keeps its no-store.
- The cached entitlement answer now says its TTL in the README (30 s by default, never more than
  60 s), and a membership that ends is announced by OpenVibe.VIP (vip.membership.changed,
  subscribed in scripts/subscribe.js): the cached grant for that pair is dropped at once, so a
  cancelled member loses access without waiting for the TTL.

Design
- "Send <name> something" is the page's job and comes first; the members-only posts come after it.
- The intro line is gone. A post card reads: the title; one quiet line with the shared lock glyph
  (openvibe-shared/glyphs), "For Tier 1 members" and the date in the house format (a day, never a
  timestamp); then the "Become a member" button. No "Members only" heading, no repeated sentence.
- A member sees the same card with the tier in the quiet line, the text in place and no button.
- Many posts: the newest three, then a counted fold "More posts (N)" that opens in place (nothing
  is hidden with display:none, no tabs).

Changes

ddiff --git a/README.md b/README.md
iindex d554102..07618f4 100644
---- a/README.md
++++ b/README.md
@@@ -48,6 +48,10 @@ from *interaction delivered*.
(openvibe-sdk transactional outbox)
- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (a creator's Chat room)
- **OpenVibe.Chat** — chat delivery through its typed ingress (`/internal/chat/messages`, `/internal/chat/events`)
++- **OpenVibe.VIP** — the gate of the members-only posts: `POST /api/v1/entitlements/check`
++ (`vip.entitlement.check`, the entitlement answer for member → creator; Billing holds the truth) and the creator's
++ public VIP page (`GET /api/v1/creators/<subject>`: their tiers and the plan a join button points at). Without the
++ Network grant `tips vip.entitlement.check openvibe.vip` every answer is a denial: no post text, no tiers, no join link
- **OpenVibe.Live** — overlay consumer; its public stream list (`GET <LIVE_URL>/api/streams`) feeds the home's "Live on
OpenVibe now" ([server/clients/live-now.js](server/clients/live-now.js): once a minute, 2 s timeout, never Live's AI
text, nothing when Live does not answer; `TIPS_LIVE_NOW=on|off`, off in tests by default)
@@@ -62,7 +66,8 @@ from *interaction delivered*.
Implemented here (the service manifest's `capabilities`, 19 `tips.*` ids, audience `openvibe.tips`):
`tips.profile.get|update`, `tips.checkout.create`, `tips.superchat.create`, `tips.tts.request`,
`tips.media_request.create`, `tips.sound.request`, `tips.interaction.get|list|record|moderate`, `tips.goal.create|update|close`,
--`tips.overlay.token.create|revoke`, `tips.overlay.config.get|update` and `tips.simulation.run`.
++`tips.overlay.token.create|revoke`, `tips.overlay.config.get|update`, `tips.post.list|create|moderate` and
++`tips.simulation.run`.
Called elsewhere, as the service principal `tips`:
@@@ -73,6 +78,7 @@ Called elsewhere, as the service principal `tips`:
| OpenVibe.Network | `network.account.export.contribute`, `network.account.deletion.confirm` (granted last, once this release is live) | account export and deletion (ADR-033) |
| OpenVibe.Chat | `chat.message.send`, `chat.event.publish` (only with `TIPS_CHAT_ADAPTER=chat`) | donation lines and TTS through `/internal/chat/messages`, the alert through `/internal/chat/events` |
| OpenVibe.Network | `identity.subject.resolve` (only with `TIPS_CHAT_ADAPTER=chat`) | the creator's Live user id (Chat's room) from `/internal/identity/resolve` |
++| OpenVibe.VIP | `vip.entitlement.check` | the entitlement answer that opens a members-only post |
## Run it
@@@ -341,6 +347,34 @@ message. `test` records jobs in memory (development; simulations always use it).
no chat effects. Production runs `chat` since the launch (2026-10-17): the `tips` principal holds the three
capabilities above (`TIPS_CHAT_URL` only when Chat is not on `127.0.0.1:4400`).
++## Members-only posts
++
++A creator writes posts for the members of their OpenVibe.VIP tiers (the Billing tiers of their published VIP
++plans). The title and the tiers are public; the text is served only to a member of a tier that reads it.
++
++- **The gate is server-side.** The body never leaves the server for anyone else: not in the page, not in JSON-LD,
++ not in `/llms-full.txt`, not in the sitemap, not in a preview, and not in the API list
++ ([server/domain/posts.js](server/domain/posts.js), [server/vip/client.js](server/vip/client.js)). The check is
++ OpenVibe.VIP's entitlement answer for member → creator (Billing holds the truth, ADR-012), behind the shared
++ product cache (`openvibe-sdk/vip`): a "yes" lives at most `TIPS_VIP_CACHE_TTL_MS` — 30 s by default and never
++ more than 60 s — and a failure is a denial. A membership that ends is announced by OpenVibe.VIP
++ (`vip.membership.changed`, subscribed in `scripts/subscribe.js`); the cached grant for that pair is dropped
++ at once, so a cancelled member loses access without waiting for the TTL.
++- **Everyone else gets a card that says so**: "Members only", the tiers it is written for and a "Become a member"
++ button pointing at the creator's VIP plan. A tier nobody can join yet shows no button — a button that leads
++ nowhere is worse than none.
++- **Every HTML answer is private.** A shared cache (Cloudflare) stores by URL and ignores `Vary`, `Cookie`
++ and `Authorization` (the 10-15 Media leak), and these pages carry the viewer's own state: the footer
++ shows who is signed in, and a member's creator page carries the text of the posts they may read. So
++ every page answers `Cache-Control: private, no-cache` with `Vary: Cookie`. The machine-readable pages
++ (`robots.txt`, `sitemap.xml`, `llms.txt`, `llms-full.txt`) and the agent twins carry no viewer's state
++ and stay public; the API answers `no-store`.
++- **The creator** writes and hides their posts in the dashboard (`/dashboard`, `POST /dashboard/posts`) and always
++ reads their own text. A hidden post is not served at all (neither its title nor its text).
++- **The API**: `GET /api/v1/profiles/:creator/posts`, `GET /api/v1/profiles/:creator/posts/:id`,
++ `GET /api/v1/profiles/:creator/posts/:id/body` (403 `tips.member_only` for anyone else),
++ `POST /api/v1/profiles/:creator/posts`, `POST /api/v1/posts/:id/hide|unhide`.
++
## Pages (server-rendered, useful without JavaScript)
`/` (who is live on OpenVibe, the creators who switched their page on with their first active goal, what a supporter can
ddiff --git a/docs/capabilities-proposal/tips.post.create.json b/docs/capabilities-proposal/tips.post.create.json
nnew file mode 100644
iindex 0000000..1e01448
---- /dev/null
++++ b/docs/capabilities-proposal/tips.post.create.json
@@@ -0,0 +1,19 @@
++{
++ "id": "tips.post.create",
++ "version": "1.0",
++ "owner": "tips",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Write a members-only post for a creator: the title, the Markdown text and the tiers that read it.",
++ "permissions": [
++ "tips:posts"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "standard",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/profiles/:creator/posts"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.post.list.json b/docs/capabilities-proposal/tips.post.list.json
nnew file mode 100644
iindex 0000000..2f669dc
---- /dev/null
++++ b/docs/capabilities-proposal/tips.post.list.json
@@@ -0,0 +1,20 @@
++{
++ "id": "tips.post.list",
++ "version": "1.0",
++ "owner": "tips",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Read the members-only posts of a creator: the title and the tiers. The text is served only to a member of a tier that reads it.",
++ "permissions": [
++ "tips:posts"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "standard",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/profiles/:creator/posts",
++ "GET /api/v1/profiles/:creator/posts/:id"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/tips.post.moderate.json b/docs/capabilities-proposal/tips.post.moderate.json
nnew file mode 100644
iindex 0000000..7dd1848
---- /dev/null
++++ b/docs/capabilities-proposal/tips.post.moderate.json
@@@ -0,0 +1,20 @@
++{
++ "id": "tips.post.moderate",
++ "version": "1.0",
++ "owner": "tips",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Hide a members-only post and show it again. A hidden post is not served at all.",
++ "permissions": [
++ "tips:posts"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "standard",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/posts/:id/hide",
++ "POST /api/v1/posts/:id/unhide"
++ ]
++}
ddiff --git a/migrations/0007_creator_posts.sql b/migrations/0007_creator_posts.sql
nnew file mode 100644
iindex 0000000..afdb648
---- /dev/null
++++ b/migrations/0007_creator_posts.sql
@@@ -0,0 +1,21 @@
++-- phase: expand
++-- Members-only posts (server/domain/posts.js): the posts a creator writes for the members of their
++-- OpenVibe.VIP tiers. The title and the tiers are public; the text is served only to a member of a
++-- tier that reads it (the entitlement check, server/vip/client.js) and never leaves the server for
++-- anyone else — not in the page, not in JSON-LD, not in the machine-readable pages.
++CREATE TABLE IF NOT EXISTS creator_posts (
++ id text PRIMARY KEY, -- cpost_<ULID>
++ creator_subject text NOT NULL,
++ title text NOT NULL,
++ body text NOT NULL, -- the creator's Markdown
++ body_html text NOT NULL, -- its safe HTML (server/domain/markdown.js)
++ tiers jsonb NOT NULL DEFAULT '[]'::jsonb, -- the Billing tiers it is written for
++ status text NOT NULL DEFAULT 'published',
++ created_at timestamptz NOT NULL,
++ updated_at timestamptz NOT NULL,
++ published_at timestamptz,
++ hidden_at timestamptz,
++ hidden_by text,
++ hide_reason text
++);
++CREATE INDEX IF NOT EXISTS creator_posts_creator ON creator_posts (creator_subject, published_at DESC, id);
ddiff --git a/scripts/subscribe.js b/scripts/subscribe.js
iindex bb341d4..a4cb46b 100644
---- a/scripts/subscribe.js
++++ b/scripts/subscribe.js
@@@ -2,7 +2,9 @@
'use strict';
/**
* Create Tips' OpenVibe.Events subscriptions for Billing's events: settlements (billing.transaction.*)
-- * and EXTERNAL receipts (billing.receipt.*, tips on a creator's own PowerChat).
++ * and EXTERNAL receipts (billing.receipt.*, tips on a creator's own PowerChat), and for OpenVibe.VIP's
++ * membership changes (vip.membership.changed: a membership that ended drops the cached entitlement at
++ * once, so a cancelled member loses access without waiting for the cache).
*
* node scripts/subscribe.js [--endpoint http://127.0.0.1:4610/internal/events] [--pattern 'billing.transaction.*']
*
@@@ -24,7 +26,7 @@ const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ?
(async () => {
const config = loadConfig();
const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);
-- const patterns = opt('pattern') ? [opt('pattern')] : ['billing.transaction.*', 'billing.receipt.*'];
++ const patterns = opt('pattern') ? [opt('pattern')] : ['billing.transaction.*', 'billing.receipt.*', 'vip.membership.changed'];
const secret = config.events.webhookSecrets[0];
if (!config.events.url) throw new Error('EVENTS_URL is not set');
if (!secret || secret.length < 32) throw new Error('TIPS_EVENTS_SECRET must be set (32+ characters) before subscribing');
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 3858b08..90413de 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -34,6 +34,11 @@
* | GET /moderation?creator=&state= | tips.interaction.moderate | the creator and their moderators |
* | POST /interactions/:id/hide|restore | tips.interaction.moderate | the creator and their moderators |
* | GET /moderation/log?creator= | tips.interaction.moderate | owner |
++ * | GET /profiles/:creator/posts | tips.post.list | anyone when the page is on; owner |
++ * | GET /profiles/:creator/posts/:id | tips.post.list | anyone when the page is on; owner |
++ * | GET /profiles/:creator/posts/:id/body | — | a member of a tier that reads it |
++ * | POST /profiles/:creator/posts | tips.post.create | owner |
++ * | POST /posts/:id/hide, POST …/:id/unhide | tips.post.moderate | owner |
* | GET|POST /moderators, POST …/:s/remove | tips.profile.update | owner |
*
* Paid requests take `privacy: { anonymous, hide_amount, private_message }` (server/domain/privacy.js).
@@@ -65,6 +70,9 @@ const CAP = {
configUpdate: 'tips.overlay.config.update',
simulate: 'tips.simulation.run',
moderate: 'tips.interaction.moderate', // in openvibe-contracts since v0.32.0 (was docs/capabilities-proposal/)
++ postList: 'tips.post.list', // members-only posts (docs/capabilities-proposal/)
++ postCreate: 'tips.post.create',
++ postModerate: 'tips.post.moderate',
};
function v1Router({ domain, apiAuth, limits }) {
@@@ -299,6 +307,80 @@ function v1Router({ domain, apiAuth, limits }) {
return g;
};
r.patch('/goals/:id', edit, ...write(async (req, res) => res.json({ goal: await goals.update(await ownGoal(req, CAP.goalUpdate), req.body || {}) })));
++
++ // ── Members-only posts ───────────────────────────────────────────────
++ // The title and the tiers are public; the text is served only to a member of a tier that reads it
++ // (the entitlement check, server/domain/posts.js). A hidden post is not served at all.
++ const postsView = async (req, creator, { owner = false } = {}) => {
++ const info = await domain.vip.creator(creator.creator_subject);
++ const posts = await domain.posts.view({
++ creatorSubject: creator.creator_subject,
++ memberSubject: req.principal.kind === 'user' ? req.principal.subject : null,
++ plans: info ? info.plans : [],
++ owner,
++ });
++ for (const post of posts) post.join_url = post.unlocked ? null : await domain.vip.joinUrl(creator.creator_subject, post.tiers);
++ return posts;
++ };
++ /** One post and whether this reader may read it (a hidden one is not served at all). */
++ const onePost = async (req, creator, id) => {
++ const post = await domain.posts.byId(id);
++ if (!post || post.creator_subject !== creator.creator_subject || post.status !== 'published') fail(404, 'tips.post_not_found', 'no post ' + id);
++ const info = await domain.vip.creator(creator.creator_subject);
++ const owner = req.principal.kind === 'user' && req.principal.subject === creator.creator_subject;
++ const access = owner ? { unlocked: true, tier: null } : await domain.posts.access({
++ post, memberSubject: req.principal.kind === 'user' ? req.principal.subject : null,
++ creatorSubject: creator.creator_subject, plans: info ? info.plans : [],
++ });
++ return { post, access };
++ };
++ /** The creator's posts: the title and the tiers for everyone, the text only for a member. */
++ r.get('/profiles/:creator/posts', wrap(async (req, res) => {
++ const creator = await creatorOf(req, req.params.creator);
++ const who = req.principal;
++ const owner = (who.kind === 'user' && who.subject === creator.creator_subject) || (who.kind === 'service' && apiAuth.granted(who, CAP.postList));
++ if (!creator.page_enabled && !owner) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');
++ res.json({ creator: { type: 'user', id: creator.creator_subject }, posts: await postsView(req, creator, { owner }) });
++ }));
++ r.get('/profiles/:creator/posts/:id', wrap(async (req, res) => {
++ const creator = await creatorOf(req, req.params.creator);
++ const who = req.principal;
++ const owner = (who.kind === 'user' && who.subject === creator.creator_subject) || (who.kind === 'service' && apiAuth.granted(who, CAP.postList));
++ if (!creator.page_enabled && !owner) fail(404, 'tips.creator_not_found', 'no tip profile for this creator');
++ const { post, access } = await onePost(req, creator, req.params.id);
++ res.json({ post: domain.posts.present(post, { unlocked: !!access.unlocked, tier: access.tier || null }) });
++ }));
++ /** The text itself: only for a member of a tier that reads it. */
++ r.get('/profiles/:creator/posts/:id/body', wrap(async (req, res) => {
++ const creator = await creatorOf(req, req.params.creator);
++ const { post, access } = await onePost(req, creator, req.params.id);
++ if (!access.unlocked) fail(403, 'tips.member_only', 'this post is for the members of a tier');
++ res.json({ post: { id: post.id, title: post.title, body: post.body, body_html: post.body_html, tiers: domain.posts.tiersOf(post) } });
++ }));
++ r.post('/profiles/:creator/posts', edit, ...write(async (req, res) => {
++ const b = req.body || {};
++ const creator = await creatorOf(req, b.creator || req.params.creator);
++ allow(req, CAP.postCreate, creator.creator_subject);
++ const actor = req.principal.kind === 'user' ? req.principal.subject : req.principal.sub;
++ const post = await domain.posts.create({ creatorSubject: creator.creator_subject, title: b.title, body: b.body, tiers: b.tiers, actor });
++ res.status(201).json({ post: domain.posts.present(post, { unlocked: true }) });
++ }));
++ const ownPost = async (req, cap) => {
++ const post = await domain.posts.byId(req.params.id);
++ if (!post) fail(404, 'tips.post_not_found', 'no such post');
++ allow(req, cap, post.creator_subject);
++ return post;
++ };
++ r.post('/posts/:id/hide', edit, ...write(async (req, res) => {
++ const post = await ownPost(req, CAP.postModerate);
++ const actor = req.principal.kind === 'user' ? req.principal.subject : req.principal.sub;
++ res.json({ post: domain.posts.present(await domain.posts.hide(post.id, { actor })) });
++ }));
++ r.post('/posts/:id/unhide', edit, ...write(async (req, res) => {
++ const post = await ownPost(req, CAP.postModerate);
++ const actor = req.principal.kind === 'user' ? req.principal.subject : req.principal.sub;
++ res.json({ post: domain.posts.present(await domain.posts.unhide(post.id, { actor })) });
++ }));
r.post('/goals/:id/close', edit, ...write(async (req, res) => res.json({ goal: await goals.close(await ownGoal(req, CAP.goalClose)) })));
// ── Overlay tokens and configs ───────────────────────────
ddiff --git a/server/app.js b/server/app.js
iindex e2e9a77..8e05b49 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -27,6 +27,7 @@ const { loadConfig } = require('./config');
const { openDb } = require('./db');
const { createKeyProvider, createUserAuth } = require('./network');
const { createBillingClient } = require('./billing-client');
++const { createVip } = require('./vip/client');
const { createAdapters } = require('./delivery');
const { createTipsOutbox } = require('./events/outbox');
const { consumerRouter } = require('./events/consumer');
@@@ -57,12 +58,15 @@ function createApp(opts = {}) {
const userAuth = createUserAuth(config, keys);
const billing = opts.billing || createBillingClient(config, { fetchImpl });
const adapters = opts.adapters || createAdapters(config, { fetchImpl });
++ // OpenVibe.VIP: the gate of the members-only posts (server/vip/client.js). Fails closed without a
++ // client secret or without the Network grant "tips vip.entitlement.check openvibe.vip".
++ const vip = opts.vip || createVip(config, { fetchImpl, now, log });
const outbox = opts.outbox || createTipsOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
// IndexNow (openvibe-shared/indexnow): created once at boot from INDEXNOW_KEY; unset → off (nothing
// mounted, nothing sent). The key file is served at /<key>.txt and the profile change pings the
// engines when a creator's public page appears, changes or goes away.
const indexnow = opts.indexnow || createIndexNow({ host: config.baseUrl, key: config.indexnow.key, ...(fetchImpl ? { fetch: fetchImpl } : {}), log });
-- const domain = createDomain({ db, config, outbox, billing, adapters, valkey, now, log, indexnow });
++ const domain = createDomain({ db, config, outbox, billing, adapters, valkey, now, log, indexnow, vip });
const apiAuth = createApiAuth({ config, keys, userAuth });
const release = require('openvibe-shared/release').createRelease({ service: 'tips', root: path.join(__dirname, '..') });
require('./web/layout').setRelease(release.release);
@@@ -177,7 +181,8 @@ function createApp(opts = {}) {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
-- return res.status(404).type('html').send(layout.page({ title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) }));
++ return res.status(404).type('html').set({ 'Cache-Control': 'private, no-cache', Vary: 'Cookie' })
++ .send(layout.page({ title: 'Not found', robots: 'noindex', body: pages.errorPage({ status: 404, title: 'Nothing here', message: 'That page does not exist.' }) }));
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
@@@ -191,10 +196,11 @@ function createApp(opts = {}) {
log.error('[Tips] unhandled error:', err);
if (res.headersSent) return undefined;
if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'tips.internal', { ctx: req.ov });
-- return res.status(500).type('html').send(layout.page({ title: 'Error', robots: 'noindex', body: pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }) }));
++ return res.status(500).type('html').set({ 'Cache-Control': 'private, no-cache', Vary: 'Cookie' })
++ .send(layout.page({ title: 'Error', robots: 'noindex', body: pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }) }));
});
-- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, adapters, billing, consumer, metrics, indexnow, accountData });
++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, adapters, billing, vip, consumer, metrics, indexnow, accountData });
return app;
}
ddiff --git a/server/config.js b/server/config.js
iindex 83ef655..4b06f8a 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -81,6 +81,20 @@ function loadConfig(env = process.env) {
powerchatLinkTemplate: env.TIPS_POWERCHAT_LINK_TEMPLATE || '',
},
++ // OpenVibe.VIP: the gate of the members-only posts (server/vip/client.js). A service token for
++ // audience openvibe.vip with the capability vip.entitlement.check — the Network needs the grant
++ // "tips vip.entitlement.check openvibe.vip"; without it every answer is a denial (fail-closed).
++ vip: {
++ url: trim(env.TIPS_VIP_URL || 'http://127.0.0.1:4620'),
++ publicUrl: trim(env.TIPS_VIP_PUBLIC_URL || 'https://openvibe.vip'),
++ audience: env.TIPS_VIP_AUDIENCE || 'openvibe.vip',
++ timeoutMs: int(env.TIPS_VIP_TIMEOUT_MS, 2000),
++ ttlMs: int(env.TIPS_VIP_CACHE_TTL_MS, 30_000),
++ denyTtlMs: int(env.TIPS_VIP_DENY_TTL_MS, 10_000),
++ unavailableTtlMs: int(env.TIPS_VIP_UNAVAILABLE_TTL_MS, 2_000),
++ plansTtlMs: int(env.TIPS_VIP_PLANS_TTL_MS, 60_000),
++ },
++
events: {
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
iindex 987dba5..e59cf90 100644
---- a/server/domain/account-data.js
++++ b/server/domain/account-data.js
@@@ -34,6 +34,8 @@ const TABLES = [
{ table: 'paid_media_requests', subject: 'creator_subject', file: null, erase: { keep: money } },
// The creator's paid sounds: listed in the export (each file is at its /sounds/<id> link), deleted on erasure.
{ table: 'creator_sounds', subject: 'creator_subject', file: 'sounds.json', columns: ['id', 'name', 'mime', 'bytes', 'created_at'] },
++ // The creator's members-only posts: their own writing, exported and erased with the rest of their page.
++ { table: 'creator_posts', subject: 'creator_subject', file: 'posts.json', columns: ['id', 'title', 'body', 'tiers', 'status', 'created_at', 'published_at'] },
{ table: 'tip_moderation_log', subject: 'creator_subject', file: null, erase: { keep: 'the creator\'s moderation log, kept for accountability' } },
{ table: 'tip_moderation_log', subject: 'actor', file: null, erase: { keep: 'the moderation log names the moderator, for the creator\'s accountability' }, kind: 'tip_moderation_log' },
];
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 75f97dd..ec634ab 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -34,11 +34,21 @@ const { createInteractions } = require('./interactions');
const { createEffects } = require('./effects');
const { createModeration } = require('./moderation');
const { createSounds } = require('./sounds');
++const { createPosts } = require('./posts');
const MONEY = { isolation: 'serializable' };
--function createDomain({ db, config, outbox, billing, adapters, valkey = null, now = () => Date.now(), log = console, indexnow = null }) {
-- const ctx = { db, config, now, log, outbox, billing, adapters, valkey, indexnow, MONEY };
++function createDomain({ db, config, outbox, billing, adapters, valkey = null, now = () => Date.now(), log = console, indexnow = null, vip = null }) {
++ // OpenVibe.VIP without a client (no secret, no grant): every membership is a denial, every tier list
++ // is empty and no join link is offered — the members-only posts stay shut.
++ const noVip = {
++ enabled: false,
++ entitlement: async () => ({ active: false, status: 'inactive', reason: 'vip_off' }),
++ creator: async () => null,
++ tiers: async () => [],
++ joinUrl: async () => null,
++ };
++ const ctx = { db, config, now, log, outbox, billing, adapters, valkey, indexnow, vip: vip || noVip, MONEY };
const runHook = (h) => { Promise.resolve().then(h).catch((e) => log.warn('[Tips] after-commit:', e.message)); };
ctx.tx = async (fn, opts = {}) => {
@@@ -66,6 +76,7 @@ function createDomain({ db, config, outbox, billing, adapters, valkey = null, no
ctx.interactions = createInteractions(ctx);
ctx.effects = createEffects(ctx);
ctx.moderation = createModeration(ctx);
++ ctx.posts = createPosts(ctx);
return ctx;
}
ddiff --git a/server/domain/markdown.js b/server/domain/markdown.js
nnew file mode 100644
iindex 0000000..88b3a51
---- /dev/null
++++ b/server/domain/markdown.js
@@@ -0,0 +1,101 @@
++'use strict';
++
++/**
++ * A small, safe Markdown renderer for the members-only posts a creator writes.
++ *
++ * Everything is HTML-escaped first, so the source can never carry markup: no raw HTML, no scripts, no
++ * styles, no images. What survives is the small set of marks below — headings, bold, italic, inline
++ * code, fenced code, links (http/https only), bullet and numbered lists, block quotes, rules — which is
++ * what a post needs and nothing more. Links get rel="noopener noreferrer nofollow".
++ *
++ * Headings are shifted (a post's title is the page's h3, so a Markdown "##" becomes an h4) and the
++ * result is stored with the post: the HTML a member reads is the HTML the creator's Markdown rendered
++ * to when they wrote it.
++ */
++const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
++const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESCAPES[c]);
++
++const PLACEHOLDER = '\u0000';
++
++/** The inline marks of one line of text (already escaped). */
++function inline(src) {
++ const held = [];
++ const keep = (html) => { held.push(html); return PLACEHOLDER + (held.length - 1) + PLACEHOLDER; };
++ let s = escapeHtml(src);
++ // Inline code first: its content is shown as it is, never as markup.
++ s = s.replace(/`([^`]+)`/g, (_m, code) => keep('<code>' + code + '</code>'));
++ // Links: only http and https survive; anything else stays as plain text.
++ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => (/^https?:\/\//i.test(url)
++ ? keep('<a href="' + url + '" rel="noopener noreferrer nofollow">' + label + '</a>')
++ : m));
++ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
++ s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>');
++ s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
++ s = s.replace(/(^|[^\w])_([^_\n]+)_/g, '$1<em>$2</em>');
++ return s.replace(new RegExp(PLACEHOLDER + '(\\d+)' + PLACEHOLDER, 'g'), (_m, i) => held[Number(i)]);
++}
++
++/**
++ * The Markdown of a post → safe HTML.
++ * @param {string} src the creator's Markdown
++ * @param {object} [opts] { headingShift } how far headings move down (a post's title is an h3 on the page)
++ */
++function renderMarkdown(src, { headingShift = 2 } = {}) {
++ const lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
++ const out = [];
++ let i = 0;
++ const flushParagraph = (buf) => { if (buf.length) { out.push('<p>' + inline(buf.join(' ')) + '</p>'); buf.length = 0; } };
++ const para = [];
++ while (i < lines.length) {
++ const line = lines[i];
++ if (/^\s*```\s*$/.test(line)) { // a fenced code block: shown as it is, never as markup
++ flushParagraph(para);
++ const code = [];
++ i += 1;
++ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1; }
++ i += 1;
++ out.push('<pre><code>' + escapeHtml(code.join('\n')) + '</code></pre>');
++ continue;
++ }
++ if (!line.trim()) { flushParagraph(para); i += 1; continue; }
++ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
++ if (heading) {
++ flushParagraph(para);
++ const level = Math.min(6, heading[1].length + headingShift);
++ out.push('<h' + level + '>' + inline(heading[2].trim()) + '</h' + level + '>');
++ i += 1;
++ continue;
++ }
++ if (/^\s*(?:\*{3,}|-{3,}|_{3,})\s*$/.test(line)) { flushParagraph(para); out.push('<hr>'); i += 1; continue; }
++ if (/^\s*>\s?/.test(line)) {
++ flushParagraph(para);
++ const quote = [];
++ while (i < lines.length && /^\s*>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^\s*>\s?/, '')); i += 1; }
++ out.push('<blockquote><p>' + inline(quote.join(' ')) + '</p></blockquote>');
++ continue;
++ }
++ const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
++ const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
++ if (bullet || numbered) {
++ flushParagraph(para);
++ const tag = bullet ? 'ul' : 'ol';
++ const items = [];
++ while (i < lines.length) {
++ const b = /^\s*[-*+]\s+(.*)$/.exec(lines[i]);
++ const n = /^\s*\d+[.)]\s+(.*)$/.test(lines[i]);
++ if (bullet && !b) break;
++ if (numbered && !n) break;
++ items.push((bullet ? b : n)[1]);
++ i += 1;
++ }
++ out.push('<' + tag + '>' + items.map((it) => '<li>' + inline(it) + '</li>').join('') + '</' + tag + '>');
++ continue;
++ }
++ para.push(line.trim());
++ i += 1;
++ }
++ flushParagraph(para);
++ return out.join('\n');
++}
++
++module.exports = { renderMarkdown, escapeHtml };
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
nnew file mode 100644
iindex 0000000..c683fdf
---- /dev/null
++++ b/server/domain/posts.js
@@@ -0,0 +1,147 @@
++'use strict';
++
++/**
++ * Members-only posts: the posts a creator writes for the members of their OpenVibe.VIP tiers.
++ *
++ * A post names the tiers it is for (the Billing tiers of that creator's VIP plans) and carries the
++ * title, the creator's Markdown and its safe HTML. Who may read it is never stored: it is answered by
++ * the entitlement check (OpenVibe.VIP's answer for member → creator, which is Billing's truth) together
++ * with the tier the member holds.
++ *
++ * Reading rules:
++ * - the list of posts never carries a body; the body is served only after the entitlement check;
++ * - a member reads a post written for their tier or for a lower one;
++ * - a signed-out visitor, a non-member and a member of another tier get the title, the tiers it needs
++ * and a way to join — never the body;
++ * - a hidden post is not served at all (not its body, not its title).
++ */
++const { sql } = require('openvibe-sdk/db');
++const { fail, prefixedId, iso, json, text } = require('../util');
++const { renderMarkdown } = require('./markdown');
++
++const MAX_TIERS = 10;
++
++function createPosts(ctx) {
++ const { db } = ctx;
++
++ const byId = async (id) => (id ? await db.maybe(sql`SELECT * FROM creator_posts WHERE id = ${String(id)}`) : null);
++ /** The creator's posts (published only, unless the caller may moderate). */
++ const rows = async ({ creatorSubject, includeHidden = false }) => {
++ const st = includeHidden ? ['published', 'hidden'] : ['published'];
++ return await db.many(sql`SELECT * FROM creator_posts WHERE creator_subject = ${String(creatorSubject || '')} AND status = ANY(${st}) ORDER BY published_at DESC, created_at DESC, id DESC`);
++ };
++ /** Every creator's posts, newest first (the moderation list). */
++ const recent = async ({ limit = 50 } = {}) => await db.many(sql`SELECT * FROM creator_posts ORDER BY created_at DESC LIMIT ${Number(limit)}`);
++
++ /** The tiers of one post, as numbers. */
++ const tiersOf = (post) => {
++ // A jsonb column comes back parsed; an older row may still carry the text.
++ const raw = post && post.tiers;
++ const list = Array.isArray(raw) ? raw : json(raw, []);
++ return Array.isArray(list) ? list.map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= MAX_TIERS).sort((a, b) => a - b) : [];
++ };
++
++ /**
++ * The tier a member holds: the plan their membership was bought under (VIP's plan of that
++ * creator); a membership made elsewhere (Billing sells those at tier 1) is tier 1.
++ */
++ function memberTier(entitlement, plans) {
++ const planId = entitlement && entitlement.membership ? entitlement.membership.plan_id : null;
++ if (!planId) return 1;
++ const plan = (plans || []).find((p) => p && p.id === planId);
++ const tier = plan ? Number(plan.tier) : 0;
++ return Number.isInteger(tier) && tier >= 1 ? tier : 1;
++ }
++
++ /**
++ * May this reader read this post? → { unlocked, reason, tier }.
++ * The entitlement is VIP's answer (Billing's truth, projected); anything VIP cannot confirm is
++ * refused, so a membership that ended never opens a post.
++ */
++ async function access({ post, memberSubject, creatorSubject, plans = null, entitlement = null }) {
++ const tiers = tiersOf(post);
++ if (!memberSubject) return { unlocked: false, reason: 'signed_out', tier: null };
++ const ent = entitlement || await ctx.vip.entitlement({ subject: memberSubject, creator: creatorSubject });
++ if (!ent || ent.active !== true || ent.status !== 'active') return { unlocked: false, reason: 'not_a_member', tier: null, entitlement: ent };
++ const tier = memberTier(ent, plans);
++ if (!tiers.length || !tiers.some((t) => tier >= t)) return { unlocked: false, reason: 'other_tier', tier, entitlement: ent };
++ return { unlocked: true, reason: 'member', tier, entitlement: ent };
++ }
++
++ /** The post as it is shown, with the body only when the reader may read it. */
++ function present(post, { unlocked = false, tier = null } = {}) {
++ const shown = {
++ id: post.id, creator: { type: 'user', id: post.creator_subject }, title: post.title, tiers: tiersOf(post),
++ status: post.status, created_at: post.created_at, updated_at: post.updated_at, published_at: post.published_at || null,
++ hidden_at: post.hidden_at || null, hidden_by: post.hidden_by || null, hide_reason: post.hide_reason || null,
++ locked: !unlocked, reader_tier: tier,
++ };
++ if (unlocked) { shown.body = post.body; shown.body_html = post.body_html; }
++ return shown;
++ }
++
++ /**
++ * The posts as a page or an API shows them: the title and the tiers for everyone, the body only
++ * for a member of a tier that reads it (the creator reads their own).
++ */
++ async function view({ creatorSubject, memberSubject = null, plans = null, owner = false }) {
++ const list = await rows({ creatorSubject });
++ const out = [];
++ for (const row of list) {
++ const a = owner ? { unlocked: true, tier: null } : await access({ post: row, memberSubject, creatorSubject, plans });
++ out.push({
++ id: row.id, title: row.title, tiers: tiersOf(row), published_at: row.published_at,
++ unlocked: !!a.unlocked, locked: !a.unlocked, body_html: a.unlocked ? row.body_html : null, reader_tier: a.tier || null,
++ });
++ }
++ return out;
++ }
++
++ /**
++ * Write a post for the members of one or more of the creator's tiers.
++ * The tiers must be tiers the creator sells on OpenVibe.VIP, so every member of a tier can join
++ * it and a "Become a member" link always leads somewhere.
++ */
++ async function create({ creatorSubject, title, body, tiers, actor = null }) {
++ const cleanTitle = text(title, 'title', 140);
++ const cleanBody = text(body, 'body', 20000);
++ if (!cleanTitle) fail(422, 'tips.invalid_input', 'title is required');
++ if (!cleanBody) fail(422, 'tips.invalid_input', 'body is required');
++ const wanted = Array.isArray(tiers) ? tiers : (tiers == null || tiers === '' ? [] : [tiers]);
++ const numbers = [...new Set(wanted.map((t) => Number(t)).filter((t) => Number.isInteger(t) && t >= 1 && t <= MAX_TIERS))].sort((a, b) => a - b);
++ if (!numbers.length) fail(422, 'tips.invalid_input', 'choose at least one tier for the post');
++ const offered = await ctx.vip.tiers(creatorSubject);
++ if (!offered.length) fail(422, 'tips.post_no_tiers', 'publish a plan on OpenVibe.VIP first: a post is written for the members of a tier');
++ const unknown = numbers.filter((t) => !offered.includes(t));
++ if (unknown.length) fail(422, 'tips.post_unknown_tier', `you do not sell tier ${unknown.join(', ')}`);
++ const at = iso(ctx.now());
++ const id = prefixedId('cpost', ctx.now());
++ await db.one(sql`INSERT INTO creator_posts (id, creator_subject, title, body, body_html, tiers, status, created_at, updated_at, published_at)
++ VALUES (${id}, ${String(creatorSubject)}, ${cleanTitle}, ${cleanBody}, ${renderMarkdown(cleanBody)}, ${sql.json(numbers)}, 'published', ${at}, ${at}, ${at}) RETURNING *`);
++ return await byId(id);
++ }
++
++ /** Hide a post (the creator's own call, or a moderator): it is not served at all any more. */
++ async function hide(id, { actor = null, reason = null } = {}) {
++ const post = await byId(id);
++ if (!post) fail(404, 'tips.post_not_found', 'no post ' + id);
++ if (post.status === 'hidden') return post;
++ const at = iso(ctx.now());
++ await db.one(sql`UPDATE creator_posts SET status = 'hidden', hidden_at = ${at}, hidden_by = ${actor}, hide_reason = ${reason ? String(reason).slice(0, 300) : null}, updated_at = ${at} WHERE id = ${String(id)} RETURNING *`);
++ return await byId(id);
++ }
++
++ /** Show a hidden post again (the creator, or a moderator). */
++ async function unhide(id, { actor = null } = {}) {
++ const post = await byId(id);
++ if (!post) fail(404, 'tips.post_not_found', 'no post ' + id);
++ if (post.status !== 'hidden') return post;
++ const at = iso(ctx.now());
++ await db.one(sql`UPDATE creator_posts SET status = 'published', hidden_at = NULL, hidden_by = NULL, hide_reason = NULL, updated_at = ${at} WHERE id = ${String(id)} RETURNING *`);
++ return await byId(id);
++ }
++
++ return { byId, rows, recent, create, hide, unhide, present, access, view, tiersOf, memberTier, MAX_TIERS };
++}
++
++module.exports = { createPosts, MAX_TIERS };
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex 521e3d3..4f932be 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -44,6 +44,13 @@ function consumerRouter({ domain, config, log = console, accountData = null, acc
/** Apply one envelope (also used by tests and the replay tool). Returns { duplicate, outcome }. */
async function apply(event) {
++ // A membership changed at OpenVibe.VIP: the cached entitlement for that pair goes at once, so a
++ // cancellation opens no post before the TTL is up. It changes no money state, so it needs no
++ // inbox row of its own (applying it twice changes nothing).
++ if (event.event_type === 'vip.membership.changed') {
++ const dropped = domain.vip && typeof domain.vip.invalidate === 'function' ? domain.vip.invalidate(event) : false;
++ return { duplicate: false, outcome: dropped ? 'vip entitlement dropped' : 'ignored:type' };
++ }
if (ACCOUNT_TOPICS.includes(event.event_type)) {
if (!accountData || !accountSend) throw new Error('account export and deletion are not configured');
return { duplicate: false, outcome: await accountData.apply(event, { send: accountSend }) };
ddiff --git a/server/vip/client.js b/server/vip/client.js
nnew file mode 100644
iindex 0000000..2087b61
---- /dev/null
++++ b/server/vip/client.js
@@@ -0,0 +1,120 @@
++'use strict';
++
++/**
++ * OpenVibe.VIP: the gate of the members-only posts (server/domain/posts.js).
++ *
++ * Two things are asked of VIP:
++ *
++ * entitlement(member, creator) the entitlement answer for that pair (Billing holds the truth,
++ * ADR-012; VIP projects it): active only for an active membership.
++ * Asked through openvibe-sdk/vip behind the shared product cache, so a
++ * "yes" lives at most config.vip.ttlMs and a failure never becomes one.
++ * creator(subject) the creator's VIP page and their plans: the tiers a post can name and
++ * the plan a "Become a member" button points at. A public read.
++ *
++ * Everything fails closed: no client secret, no VIP, no answer → no membership, no tiers, no join link
++ * and no post text.
++ */
++const { createVipClient, createVipCache } = require('openvibe-sdk/vip');
++const { serviceAuth } = require('openvibe-contracts');
++
++const AUDIENCE = 'openvibe.vip';
++const SCOPE = 'vip.entitlement.check';
++
++function createVip(config, { fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
++ const enabled = !!(config.vip && config.vip.url && config.oauth && config.oauth.clientSecret);
++ const tokenClient = enabled ? serviceAuth.createTokenClient({
++ tokenUrl: config.network.internalUrl + '/oauth/token',
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: AUDIENCE,
++ scope: SCOPE,
++ }) : null;
++
++ const vip = createVipClient({
++ baseUrl: config.vip.url,
++ tokenClient,
++ timeoutMs: config.vip.timeoutMs,
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ log,
++ });
++ const cache = createVipCache({
++ vip, ttlMs: config.vip.ttlMs, denyTtlMs: config.vip.denyTtlMs, unavailableTtlMs: config.vip.unavailableTtlMs, now,
++ });
++
++ // The creator's VIP page and their plans: cached for a minute (a plan rarely changes).
++ const pages = new Map();
++
++ async function creator(subject) {
++ const key = String(subject || '');
++ if (!key) return null;
++ const hit = pages.get(key);
++ const t = now();
++ if (hit && hit.until > t) return hit.value;
++ if (!enabled) { pages.set(key, { until: t + config.vip.plansTtlMs, value: null }); return null; }
++ let value = null;
++ try {
++ const res = await fetchImpl(config.vip.url + '/api/v1/creators/' + encodeURIComponent(key), {
++ headers: { Accept: 'application/json', ...(await tokenClient.authHeaders()) },
++ signal: AbortSignal.timeout(config.vip.timeoutMs),
++ });
++ const data = res.ok ? await res.json() : null;
++ if (data) {
++ const list = Array.isArray(data.plans) ? data.plans : [];
++ value = {
++ username: data.creator && data.creator.username ? String(data.creator.username) : null,
++ plans: list.map((p) => ({
++ id: String(p.id),
++ slug: p.slug ? String(p.slug) : null,
++ name: (p.current_version && p.current_version.name) || null,
++ tier: Number(p.tier) || null,
++ purchasable: p.purchasable === true,
++ price_cents: p.price_cents == null ? null : Number(p.price_cents),
++ })),
++ };
++ }
++ } catch (err) {
++ log.warn('[Tips] the VIP creator read failed:', err.message);
++ }
++ pages.set(key, { until: t + config.vip.plansTtlMs, value });
++ return value;
++ }
++
++ /** The tiers the creator sells (the Billing tiers of their published VIP plans). */
++ async function tiers(subject) {
++ const c = await creator(subject);
++ if (!c) return [];
++ return [...new Set(c.plans.map((p) => p.tier).filter((n) => Number.isInteger(n) && n >= 1))].sort((a, b) => a - b);
++ }
++
++ /**
++ * The way in: the cheapest plan that reads the post and can be joined. When no tier that reads it
++ * can be joined, there is no button — a button that leads nowhere is worse than none.
++ */
++ async function joinUrl(subject, needed) {
++ const c = await creator(subject);
++ if (!c || !c.username) return null;
++ const min = Array.isArray(needed) && needed.length ? Math.min(...needed) : 1;
++ const plan = c.plans.filter((p) => p.purchasable && p.tier && p.tier >= min).sort((a, b) => a.tier - b.tier)[0];
++ if (!plan || !plan.slug) return null;
++ return config.vip.publicUrl + '/' + encodeURIComponent(c.username) + '#plan-' + encodeURIComponent(plan.slug);
++ }
++
++ /**
++ * A membership changed (OpenVibe.VIP's vip.membership.changed): the cached answer for that pair
++ * is dropped at once, so a cancellation takes effect without waiting for the TTL.
++ */
++ function invalidate(envelope) {
++ return cache.handleEvent(envelope);
++ }
++
++ /** The entitlement answer for member → creator (fail-closed; never throws). */
++ async function entitlement({ subject, creator: creatorSubject } = {}) {
++ if (!subject || !creatorSubject || subject === creatorSubject) return { active: false, status: 'inactive', reason: 'signed_out' };
++ return await cache.entitlement({ subject, creator: creatorSubject, product: 'tips' });
++ }
++
++ return { entitlement, creator, tiers, joinUrl, invalidate, enabled };
++}
++
++module.exports = { createVip, AUDIENCE, SCOPE };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 9b3d8be..695377f 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -6,6 +6,7 @@
* the creator's own headline is the only pitch on their page.
*/
const showcase = require('openvibe-shared/showcase');
++const glyphs = require('openvibe-shared/glyphs');
const { esc } = require('./layout');
const { VOICES } = require('../domain/profiles');
const supportLinks = require('../domain/support-links');
@@@ -13,6 +14,17 @@ const { pickBlock, foldBlock } = require('../render/short');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
++// The house date (the format every OpenVibe page shows a date in): a day, never a timestamp.
++const fmtDate = (s) => {
++ if (!s) return '';
++ const d = new Date(s);
++ return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10);
++};
++// The shared lock glyph (openvibe-shared/glyphs), drawn on the server: the same drawing the network's
++// icons use, without a script or a font. It carries its own aria-hidden, so a screen reader reads the
++// line, not the lock.
++const lockGlyph = '<span style="display:inline-block;width:1.05em;height:1.05em;line-height:1;vertical-align:-.18em;margin-right:.3em">'
++ + glyphs.svg('lock', { size: 16 }) + '</span>';
// A Media object is stored at full size: ask its resize ladder for the width drawn on this page (96 for the 40 px
// creator card, 320 for the 96 px creator page head). Any other URL — an avatar from another host, or one already
// sized — is used unchanged.
@@@ -220,7 +232,72 @@ function supportLinksSection(profile) {
</section>`;
}
--function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview, sounds = [] }) {
++/**
++ * One post card: the title, one quiet line (the tier it is written for and the date) and, for
++ * someone who is not a member of that tier, the way in. The text is only ever rendered for a member
++ * of a tier that reads it (the route decides that, after the entitlement check).
++ */
++function postCard(p) {
++ const tier = p.tiers.length ? 'Tier ' + p.tiers.join(', ') : 'a tier';
++ const date = p.published_at ? fmtDate(p.published_at) : '';
++ // A member reads the text: the line names the tier, there is nothing left to unlock.
++ const line = p.unlocked
++ ? esc(tier) + (date ? ' · ' + esc(date) : '')
++ : lockGlyph + 'For ' + esc(tier) + ' members' + (date ? ' · ' + esc(date) : '');
++ const body = p.unlocked
++ ? '<div class="post-body">' + p.body_html + '</div>'
++ // A button that leads nowhere is worse than no button: no way in, no button.
++ : (p.join_url ? '<p><a class="btn" href="' + esc(p.join_url) + '">Become a member</a></p>' : '');
++ return '<article class="card post"><h3>' + esc(p.title) + '</h3><p class="muted small">' + line + '</p>' + body + '</article>';
++}
++
++/**
++ * The creator page's members-only posts: the newest three, and the rest in a counted fold that opens
++ * in place (the content stays in the page; nothing is hidden with display:none and there are no
++ * tabs). The title and the tier are public; the text is not.
++ */
++function postsSection(posts) {
++ if (!posts || !posts.length) return '';
++ const shown = posts.slice(0, 3);
++ const rest = posts.slice(3);
++ const more = rest.length
++ ? '<details class="tp-fold tp-fold-block" id="posts-more" open><summary>More posts (' + rest.length + ')</summary>'
++ + rest.map(postCard).join('') + '</details>'
++ : '';
++ return '<section class="posts" aria-labelledby="posts-h"><h2 id="posts-h">Members-only posts</h2>'
++ + '<div class="post-list">' + shown.map(postCard).join('') + more + '</div></section>';
++}
++
++/**
++ * The dashboard's members-only posts: the composer (the tiers the creator sells on OpenVibe.VIP) and
++ * the creator's own posts with the text they wrote.
++ */
++function postsAdmin({ posts = [], tiers = [], csrf, idem }) {
++ const composer = tiers.length
++ ? '<details class="card"><summary>New members-only post</summary>'
++ + '<form method="post" action="/dashboard/posts">' + hidden({ csrf, idem: idem + '-post' })
++ + '<label>Title <input name="title" maxlength="140" required></label>'
++ + '<label>Text (Markdown) <textarea name="body" rows="6" required></textarea></label>'
++ + '<fieldset><legend>Who reads it</legend>'
++ + tiers.map((t) => '<label><input type="checkbox" name="tiers" value="' + esc(t) + '"> Tier ' + esc(t) + '</label>').join('')
++ + '</fieldset>'
++ + '<p class="muted small">A member of a higher tier reads posts written for a lower tier too. The text is only ever sent to members.</p>'
++ + '<button class="btn" type="submit">Publish post</button></form></details>'
++ : '<p class="muted small">Publish a plan on <a href="https://openvibe.vip">OpenVibe.VIP</a> first: a post is written for the members of one of your tiers.</p>';
++ const mine = posts.length ? posts.map((p) => '<article class="card post"><h3>' + esc(p.title) + '</h3>'
++ + '<p class="muted small">' + esc(p.tiers.map((t) => 'Tier ' + t).join(', ')) + ' · ' + when(p.published_at)
++ + ' · <span class="pill">' + esc(p.status) + '</span></p>'
++ + '<div class="post-body">' + p.body_html + '</div>'
++ + '<form class="inline" method="post" action="/dashboard/posts/' + esc(p.id) + '/' + (p.status === 'hidden' ? 'unhide' : 'hide') + '">'
++ + hidden({ csrf, idem: idem + '-post-' + p.id })
++ + '<button class="btn ghost" type="submit">' + (p.status === 'hidden' ? 'Show again' : 'Hide') + '</button></form>'
++ + '</article>').join('') : '<p class="muted small">No posts yet.</p>';
++ return '<section class="card"><h2>Members-only posts</h2>'
++ + '<p class="muted small">A post is written for the members of one or more of your tiers. Only a member of that tier reads the text; everyone else sees the title and a way to join.</p>'
++ + composer + mine + '</section>';
++}
++
++function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, error, ownerPreview, sounds = [], posts = [] }) {
const active = goals.filter((g) => g.status === 'active');
return `${ownerPreview ? '<p class="notice">Your page is switched off: only you can see it, and search engines are told not to index it. Switch it on in the <a href="/dashboard">dashboard</a>.</p>' : ''}
<section class="creator-head">
@@@ -228,8 +305,9 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er
<div><h1>${esc(profile.display_name)}</h1>${profile.headline ? `<p class="headline">${esc(profile.headline)}</p>` : ''}
<p class="links"><a href="https://openvibe.live/@${esc(profile.handle)}">Watch on OpenVibe.Live</a> · <a href="/${esc(profile.handle)}/goals">Goals</a>${profile.page.supporters_page ? ` · <a href="/${esc(profile.handle)}/supporters">Supporters</a>` : ''}</p></div>
</section>
--${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}
++${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
++${postsSection(posts)}
${supportLinksSection(profile)}`;
}
@@@ -342,7 +420,7 @@ function supportLinksCard({ profile: p, csrf, idem }) {
</section>`;
}
--function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {}, sounds = [] }) {
++function dashboard({ profile, goals, tokens, configs, totals, recent, deliveries, csrf, idem, flash, error, connected, chatAdapter, mod = {}, sounds = [], posts = [], postTiers = [] }) {
const p = profile;
const f = (k) => (p[k] ? ' checked' : '');
const alertCfg = configs.find((c) => c.kind === 'alerts');
@@@ -385,6 +463,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
</form>
</div>
</section>
++${postsAdmin({ posts, tiers: postTiers, csrf, idem })}
${soundsCard({ profile: p, sounds, csrf, idem })}
${supportLinksCard({ profile: p, csrf, idem })}
<section class="card"><h2>Goals</h2>
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 991e05e..9422d80 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -59,7 +59,17 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
const form = express.urlencoded({ extended: false, limit: '32kb' });
const secret = config.formSecret || crypto.randomBytes(32).toString('hex');
-- const html = (res, body, status = 200, extraHeaders = {}) => res.status(status).set({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', ...extraHeaders }).send(body);
++ // Every page here is rendered for one viewer: the footer shows who is signed in, and a member's
++ // creator page carries the text of the posts they may read. A shared cache stores by URL and
++ // ignores Vary, Cookie and Authorization (the 10-15 Media leak), so every HTML answer is
++ // private and says it varies by cookie. The machine-readable pages (robots.txt, sitemap.xml,
++ // llms.txt, llms-full.txt) and the agent twins carry no viewer's state and stay public.
++ const html = (res, body, status = 200, extraHeaders = {}) => res.status(status).set({
++ 'Content-Type': 'text/html; charset=utf-8',
++ 'Cache-Control': 'private, no-cache',
++ Vary: 'Cookie',
++ ...extraHeaders,
++ }).send(body);
const pageFor = (req, o) => {
// A public page names its Markdown twin in the Link header, exactly as the agent layer's own
// responses do; a page rendered for a signed-in person (noindex) has no twin and no header.
@@@ -236,12 +246,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
configs = await overlays.listConfigs(db, p.creator_subject);
}
const s = p.creator_subject;
-- const [recentRows, goalRows, tokens, totals, deliveries, connected, held, moderators, invites, log, moderating, soundList] = await Promise.all([
++ const [recentRows, goalRows, tokens, totals, deliveries, connected, held, moderators, invites, log, moderating, soundList, postRows, postTiers] = await Promise.all([
interactions.list(db, { creator: s, limit: 20 }).then((out) => interactions.presentMany(db, out.rows, { viewer: 'owner' })),
goals.list(db, s).then((list) => goals.presentMany(db, list)),
overlays.listTokens(db, s), interactions.totals(db, s), overlays.recentDeliveries(db, s, 20), overlays.connected(s),
moderation.heldCount(db, s), moderation.listModerators(db, s), moderation.openInvites(db, s), moderation.log(db, s, 20), moderation.moderatedBy(db, req.viewer.subject),
sounds.list(db, s),
++ domain.posts.rows({ creatorSubject: s, includeHidden: true }),
++ domain.vip.tiers(s),
]);
html(res, pageFor(req, {
title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard',
@@@ -252,6 +264,8 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
connected, chatAdapter: config.chat.adapter,
mod: { held, moderators: moderators.map(moderation.presentModerator), invites, log, moderating },
sounds: soundList,
++ posts: postRows.map((r) => ({ id: r.id, title: r.title, tiers: domain.posts.tiersOf(r), status: r.status, published_at: r.published_at, body_html: r.body_html })),
++ postTiers,
}),
}), status);
}
@@@ -318,6 +332,21 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
};
action('/dashboard/goals/:id', async (req, p) => { await goals.update(await ownGoal(req, p), { title: req.body.title, target_amount: req.body.target_amount, revision: req.body.revision }); return { done: 'Goal updated' }; });
action('/dashboard/goals/:id/close', async (req, p) => { await goals.close(await ownGoal(req, p)); return { done: 'Goal closed' }; });
++ // The members-only posts: the creator writes them, hides them and shows them again.
++ action('/dashboard/posts', async (req, p) => {
++ const b = req.body || {};
++ const tiers = [].concat(b.tiers || []).map((t) => Number(t)).filter((n) => Number.isInteger(n) && n >= 1 && n <= 10);
++ await domain.posts.create({ creatorSubject: p.creator_subject, title: b.title, body: b.body, tiers, actor: req.viewer.subject });
++ return { done: 'Post published' };
++ });
++ /** A post of this creator's own (the dashboard is the creator's own posts). */
++ const ownPost = async (req, p) => {
++ const post = await domain.posts.byId(req.params.id);
++ if (!post || post.creator_subject !== p.creator_subject) throw new TipsError(404, 'tips.post_not_found', 'no post ' + req.params.id);
++ return post;
++ };
++ action('/dashboard/posts/:id/hide', async (req, p) => { await domain.posts.hide((await ownPost(req, p)).id, { actor: req.viewer.subject }); return { done: 'Post hidden' }; });
++ action('/dashboard/posts/:id/unhide', async (req, p) => { await domain.posts.unhide((await ownPost(req, p)).id, { actor: req.viewer.subject }); return { done: 'Post shown again' }; });
action('/dashboard/overlay-tokens', async (req, p) => {
const scopes = [bool(req.body.scope_alerts) && 'alerts', bool(req.body.scope_goals) && 'goals'].filter(Boolean);
const alertCfg = (await overlays.listConfigs(db, p.creator_subject)).find((c) => c.kind === 'alerts');
@@@ -512,13 +541,18 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
if (p.handle !== String(req.params.handle)) return res.redirect(301, `/${p.handle}`); // @name, Name → the canonical path
// The page shows what the public sees, for the creator too (a preview); the dashboard has the rest.
const list = await goals.publicGoals(db, await goals.list(db, p.creator_subject), p);
++ // The members-only posts: the title and the tier are public, the text only for a member of a
++ // tier that reads it (server/domain/posts.js, after the entitlement check).
++ const vipCreator = await domain.vip.creator(p.creator_subject);
++ const posts = await domain.posts.view({ creatorSubject: p.creator_subject, memberSubject: req.viewer ? req.viewer.subject : null, plans: vipCreator ? vipCreator.plans : [], owner });
++ for (const post of posts) post.join_url = post.unlocked ? null : await domain.vip.joinUrl(p.creator_subject, post.tiers);
const csrf = req.viewer ? csrfFor(req.viewer.subject) : '';
const jsonLd = p.page_enabled ? [{ '@context': 'https://schema.org', '@type': 'ProfilePage', name: p.display_name, url: `${config.baseUrl}/${p.handle}`, mainEntity: { '@type': 'Person', name: p.display_name, alternateName: p.handle, image: p.avatar_url || undefined } }] : [];
return html(res, pageFor(req, {
title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,
description: p.headline || `Tip ${p.display_name} on OpenVibe.Tips: tips, goals, paid messages${p.tts_enabled ? ', text-to-speech' : ''}${p.media_requests_enabled ? ', media requests' : ''}.`,
robots: p.page_enabled ? 'index,follow' : 'noindex,nofollow', ogImage: p.avatar_url || undefined, jsonLd,
-- body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled, sounds: p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [] }),
++ body: pages.creatorPage({ profile: p, goals: list, viewer: req.viewer, csrf, idem: idem(), values, providers, error, ownerPreview: !p.page_enabled, sounds: p.sounds_enabled ? await sounds.list(db, p.creator_subject) : [], posts }),
}), status, p.page_enabled ? {} : { 'X-Robots-Tag': 'noindex, nofollow' });
}
r.get('/:handle', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex 431b475..0f234eb 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -259,4 +259,53 @@ async function startChat(network) {
return { url, messages, events, calls, state, close: () => new Promise((r) => server.close(r)) };
}
--module.exports = { startNetwork, startBilling, startEvents, startChat };
++module.exports = { startNetwork, startBilling, startEvents, startChat, startVip };
++
++/**
++ * OpenVibe.VIP as Tips sees it: POST /api/v1/entitlements/check (the membership answer for a pair)
++ * and GET /api/v1/creators/:ref (the creator's page and their plans). The caller presents the service
++ * token the Network issues for the client `tips` (audience openvibe.vip, capability
++ * vip.entitlement.check); without that capability the answers are refused, as VIP refuses them.
++ */
++async function startVip() {
++ const state = { answers: new Map(), creators: new Map(), calls: [], creatorCalls: [], down: false };
++ const server = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ if (state.down) { req.socket.destroy(); return; }
++ const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
++ const claims = claimsOf(req.headers.authorization);
++ if (!claims) return send(401, { code: 'token.missing' });
++ if (!(claims.aud || []).includes('openvibe.vip')) return send(401, { code: 'token.invalid' });
++ if (!(claims.cap || []).includes('vip.entitlement.check')) return send(403, { code: 'capability.denied' });
++ if (req.url.startsWith('/api/v1/entitlements/check') && req.method === 'POST') {
++ const b = JSON.parse(raw || '{}');
++ state.calls.push(b);
++ const answer = state.answers.get(b.subject + '|' + b.creator);
++ return send(200, {
++ member: { type: 'user', id: b.subject }, creator: { type: 'user', id: b.creator },
++ ...(answer || { status: 'inactive', active: false, expires_at: null, membership: null }),
++ });
++ }
++ if (req.url.startsWith('/api/v1/creators/') && req.method === 'GET') {
++ const ref = decodeURIComponent(req.url.slice('/api/v1/creators/'.length).split('?')[0]);
++ state.creatorCalls.push(ref);
++ const c = state.creators.get(ref);
++ if (!c) return send(404, { code: 'vip.creator_not_found' });
++ return send(200, { creator: { username: c.username }, plans: c.plans || [] });
++ }
++ return send(404, { code: 'not_found' });
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { state, server, url: 'http://127.0.0.1:' + server.address().port, close: () => new Promise((r) => server.close(r)) };
++}
++
++/** The claims of a service token (its signature is the Network's business; the capability is ours). */
++function claimsOf(header) {
++ const raw = String(header || '').replace(/^Bearer /i, '');
++ const part = raw.split('.')[1];
++ if (!part) return null;
++ try { return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); } catch { return null; }
++}
ddiff --git a/test/member-posts.test.js b/test/member-posts.test.js
nnew file mode 100644
iindex 0000000..a897fb2
---- /dev/null
++++ b/test/member-posts.test.js
@@@ -0,0 +1,188 @@
++'use strict';
++/**
++ * The members-only posts of a creator's page: the text is served to a member of a tier that reads it
++ * and to nobody else — not in the page, not in the API, not in the machine-readable pages, and not
++ * when OpenVibe.VIP cannot answer. A member's page is never left for a shared cache.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { startVip } = require('./helpers/stubs');
++
++const SECRET = 'the secret text of a members-only post';
++const JOIN = 'https://openvibe.vip/alice#plan-gold';
++const asPost = (title) => ({ title, body: SECRET, tiers: [1] });
++/** The posts section of a creator page. */
++const section = (html) => (html.split('<section class="posts"')[1] || '').split('</section>')[0];
++
++(async () => {
++ await check('the members-only posts', async () => {
++ const vip = await startVip();
++ // The plans are read on every request in this test, so a plan that stops being joinable is seen.
++ const t = await boot({ env: { TIPS_VIP_URL: vip.url, TIPS_VIP_PUBLIC_URL: 'https://openvibe.vip', TIPS_VIP_PLANS_TTL_MS: '0' } });
++ try {
++ const creator = await t.creator('alice');
++ const member = t.network.newUser('bob');
++ const stranger = t.network.newUser('carol');
++ // The creator sells one tier on VIP; Bob is a member of it, Carol is not.
++ vip.state.creators.set(creator.subject, {
++ username: 'alice',
++ plans: [{ id: 'plan_gold', slug: 'gold', tier: 1, purchasable: true, current_version: { name: 'Gold' } }],
++ });
++ const grant = () => vip.state.answers.set(member.subject + '|' + creator.subject, {
++ status: 'active', active: true, expires_at: new Date(Date.now() + 86400e3).toISOString(),
++ membership: { id: 'mem_1', plan_id: 'plan_gold' },
++ });
++ const revoke = () => vip.state.answers.set(member.subject + '|' + creator.subject, {
++ status: 'inactive', active: false, expires_at: null, membership: null,
++ });
++ grant();
++
++ // The creator publishes a post for tier 1.
++ const created = await t.call('POST', '/api/v1/profiles/me/posts', { user: creator, body: asPost('A note for members') });
++ assert.equal(created.status, 201, created.text);
++ const post = created.json.post;
++
++ // 1. The creator page: the title and the way in, never the text.
++ const page = await t.call('GET', '/alice');
++ assert.equal(page.status, 200, page.text);
++ assert.ok(page.text.includes('A note for members'), 'the title is public');
++ assert.ok(!page.text.includes(SECRET), 'the text never reaches a stranger');
++ assert.ok(page.text.includes(JOIN), 'the join link is on the card');
++ assert.ok(page.text.includes('Become a member'), 'the card offers the way in');
++
++ // 2. The card reads: the title, one quiet line (lock glyph, tier, date), then the button.
++ const cards = section(page.text);
++ assert.ok(cards.includes('For Tier 1 members'), 'the quiet line names the tier it is written for');
++ assert.ok(cards.includes('<svg'), 'the quiet line carries the shared lock glyph');
++ assert.ok(/\d{4}-\d{2}-\d{2}/.test(cards), 'the quiet line carries the date');
++ assert.ok(!cards.includes('UTC'), 'the date is a day, not a timestamp');
++ assert.ok(!cards.includes('Members only'), 'no "Members only" heading');
++ assert.ok(!cards.includes('This post is for the members of'), 'no repeated sentence');
++ assert.ok(!cards.includes('Posts for the members of a tier'), 'no intro line');
++
++ // 3. The order: the tip form is the page's job and comes first.
++ assert.ok(page.text.indexOf('Send Alice something') < page.text.indexOf('<section class="posts"'), 'the tip form comes before the posts');
++
++ // 4. The cache: a shared cache stores by URL and ignores Vary, so a page that depends on
++ // the viewer must be private.
++ assert.equal(page.headers.get('cache-control'), 'private, no-cache', 'a stranger\'s page is private');
++ assert.ok(String(page.headers.get('vary') || '').includes('Cookie'), 'the page varies by cookie');
++ const memberRes = await fetch(t.base + '/alice', { headers: { Cookie: 'ov_token=' + t.network.signUser(member) } });
++ const memberHtml = await memberRes.text();
++ assert.equal(memberRes.headers.get('cache-control'), 'private, no-cache', 'a member\'s page is private');
++ assert.ok(String(memberRes.headers.get('vary') || '').includes('Cookie'));
++
++ // 5. A member reads the text: the same card, the tier in the quiet line, no button.
++ assert.ok(memberHtml.includes(SECRET), 'the member reads the text on the page');
++ assert.ok(memberHtml.includes('Tier 1 \u00b7'), 'the member sees the tier in the quiet line');
++ assert.ok(!memberHtml.includes('Become a member'), 'the member has no button');
++ assert.ok(!memberHtml.includes('For Tier 1 members'), 'the member has no locked line');
++ // The text never travels in an answer a shared cache may keep.
++ assert.equal(memberRes.headers.get('cache-control'), 'private, no-cache');
++
++ // 6. The JSON-LD of the page carries no text.
++ const ld = (page.text.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/) || [])[1] || '';
++ assert.ok(ld.length > 0, 'the page has its JSON-LD');
++ assert.ok(!ld.includes(SECRET), 'the JSON-LD carries no text');
++
++ // 7. The machine-readable pages carry no text either.
++ const llms = await t.call('GET', '/llms-full.txt');
++ assert.equal(llms.status, 200);
++ assert.ok(!llms.text.includes(SECRET), 'llms-full.txt carries no text');
++ const sitemap = await t.call('GET', '/sitemap.xml');
++ assert.equal(sitemap.status, 200);
++ assert.ok(!sitemap.text.includes(SECRET), 'the sitemap carries no text');
++ // The Markdown twin of the creator page (the agent layer): the title, never the text.
++ const twin = await t.call('GET', '/alice.md');
++ assert.ok(!twin.text.includes(SECRET), 'the Markdown twin carries no text');
++
++ // 8. The API list never carries a body; the body route refuses a stranger.
++ const list = await t.call('GET', '/api/v1/profiles/alice/posts', { user: stranger });
++ assert.equal(list.status, 200, list.text);
++ assert.ok(!JSON.stringify(list.json).includes(SECRET), 'the list carries no text');
++ assert.equal(list.json.posts.length, 1);
++ assert.equal(list.json.posts[0].locked, true);
++ assert.equal(list.json.posts[0].join_url, JOIN);
++ assert.equal(list.headers.get('cache-control'), 'no-store', 'the API is never cached');
++ const refused = await t.call('GET', '/api/v1/profiles/alice/posts/' + post.id + '/body', { user: stranger });
++ assert.equal(refused.status, 403, refused.text);
++ assert.ok(!refused.text.includes(SECRET));
++
++ // 9. A member reads the text (the API).
++ const memberBody = await t.call('GET', '/api/v1/profiles/alice/posts/' + post.id + '/body', { user: member });
++ assert.equal(memberBody.status, 200, memberBody.text);
++ assert.ok(memberBody.json.post.body.includes(SECRET));
++
++ // 10. A hidden post is not served at all (not its title, not its text).
++ const hidden = await t.call('POST', '/api/v1/posts/' + post.id + '/hide', { user: creator });
++ assert.equal(hidden.status, 200, hidden.text);
++ const gone = await t.call('GET', '/api/v1/profiles/alice/posts/' + post.id);
++ assert.equal(gone.status, 404);
++ const afterHide = await t.call('GET', '/alice');
++ assert.ok(!afterHide.text.includes('A note for members'), 'a hidden post is gone from the page');
++ assert.ok(!afterHide.text.includes(SECRET));
++ const shown = await t.call('POST', '/api/v1/posts/' + post.id + '/unhide', { user: creator });
++ assert.equal(shown.status, 200, shown.text);
++
++ // 11. OpenVibe.VIP down: nobody reads the text (it fails closed).
++ vip.state.down = true;
++ const newcomer = t.network.newUser('dave');
++ const downBody = await t.call('GET', '/api/v1/profiles/alice/posts/' + post.id + '/body', { user: newcomer });
++ assert.equal(downBody.status, 403, 'without VIP nobody is a member');
++ assert.ok(!downBody.text.includes(SECRET));
++ vip.state.down = false;
++
++ // 12. A membership that ends: OpenVibe.VIP announces it and the cached grant goes at once.
++ const before = await t.call('GET', '/api/v1/profiles/alice/posts/' + post.id + '/body', { user: member });
++ assert.equal(before.status, 200, before.text);
++ revoke();
++ const delivered = await t.deliver({
++ event_id: 'evt_01M6R3NC10YJVGSPB8NKZQMCD6',
++ event_type: 'vip.membership.changed',
++ source: 'vip',
++ version: 1,
++ timestamp: new Date().toISOString(),
++ actor: { type: 'service', id: 'openvibe.vip' },
++ subject: { type: 'account', id: member.subject },
++ visibility: 'internal',
++ payload: { member: { type: 'user', id: member.subject }, creator: { type: 'user', id: creator.subject }, active: false },
++ });
++ assert.equal(delivered.status, 200, JSON.stringify(delivered.json));
++ assert.equal(delivered.json.outcome, 'vip entitlement dropped');
++ const after = await t.call('GET', '/api/v1/profiles/alice/posts/' + post.id + '/body', { user: member });
++ assert.equal(after.status, 403, 'the cancelled member reads nothing, without waiting for the TTL');
++ assert.ok(!after.text.includes(SECRET));
++ grant();
++
++ // 13. Many posts: the newest three, then a counted fold that opens in place.
++ for (const title of ['Second note', 'Third note', 'Fourth note']) {
++ const made = await t.call('POST', '/api/v1/profiles/me/posts', { user: creator, body: asPost(title) });
++ assert.equal(made.status, 201, made.text);
++ }
++ const many = await t.call('GET', '/alice');
++ assert.ok(many.text.includes('More posts (1)'), 'the fold counts the rest');
++ assert.equal((many.text.match(/<article class="card post">/g) || []).length, 4, 'every card stays in the page');
++ const fold = (many.text.split('<summary>More posts (1)</summary>')[1] || '');
++ assert.ok(fold.includes('A note for members'), 'the fold carries the older post');
++ assert.ok(!many.text.includes('style="display:none'), 'nothing is hidden with display:none');
++
++ // 14. The creator reads their own post in the dashboard.
++ const dashRes = await fetch(t.base + '/dashboard', { headers: { Cookie: 'ov_token=' + t.network.signUser(creator) } });
++ const dashHtml = await dashRes.text();
++ assert.equal(dashRes.status, 200);
++ assert.ok(dashHtml.includes('A note for members'), 'the creator sees their post in the dashboard');
++ assert.ok(dashHtml.includes(SECRET), 'the creator reads their own text');
++
++ // 15. A tier nobody can join shows no button.
++ vip.state.creators.set(creator.subject, { username: 'alice', plans: [{ id: 'plan_gold', slug: 'gold', tier: 1, purchasable: false, current_version: { name: 'Gold' } }] });
++ const noPlan = await t.call('GET', '/alice');
++ assert.ok(!noPlan.text.includes(JOIN), 'a tier nobody can join shows no button');
++ assert.ok(!noPlan.text.includes('Become a member'), 'no button at all when there is no way in');
++ assert.ok(section(noPlan.text).includes('For Tier 1 members'), 'the card still says who it is for');
++ } finally {
++ await t.close();
++ await vip.close();
++ }
++ });
++ done();
++})();