openvibers/openvibe.tips Commits
Members-only posts on a creator's Tips page, gated by VIP membership (#194)
Changes
@@@ -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 onOpenVibe now" ([server/clients/live-now.js](server/clients/live-now.js): once a minute, 2 s timeout, never Live's AItext, 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 threecapabilities 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 cannnew file mode 100644@@@ -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"++ ]++}nnew file mode 100644@@@ -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"++ ]++}nnew file mode 100644@@@ -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"++ ]++}nnew file mode 100644@@@ -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);@@@ -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');@@@ -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 ───────────────────────────@@@ -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-varsapp.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;}@@@ -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),@@@ -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' },];@@@ -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, noctx.interactions = createInteractions(ctx);ctx.effects = createEffects(ctx);ctx.moderation = createModeration(ctx);++ ctx.posts = createPosts(ctx);return ctx;}nnew file mode 100644@@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };++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 };nnew file mode 100644@@@ -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 };@@@ -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 }) };nnew file mode 100644@@@ -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 };@@@ -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>@@@ -59,7 +59,17 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: aconst 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: aconfigs = 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: aconnected, 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: aif (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()));@@@ -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; }++}nnew file mode 100644@@@ -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();++})();
@@ -48,6 +48,10 @@ from *interaction delivered*.
@@ -48,6 +48,10 @@ from *interaction delivered*.
(openvibe-sdk transactional outbox)
(openvibe-sdk transactional outbox)
- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (a creator's Chat room)
- **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.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.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
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)
text, nothing when Live does not answer; `TIPS_LIVE_NOW=on|off`, off in tests by default)
@@ -62,7 +66,8 @@ from *interaction delivered*.
@@ -62,7 +66,8 @@ from *interaction delivered*.
Implemented here (the service manifest's `capabilities`, 19 `tips.*` ids, audience `openvibe.tips`):
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.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.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`:
Called elsewhere, as the service principal `tips`:
@@ -73,6 +78,7 @@ 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.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.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.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
## Run it
@@ -341,6 +347,34 @@ message. `test` records jobs in memory (development; simulations always use 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
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`).
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)
## 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
`/` (who is live on OpenVibe, the creators who switched their page on with their first active goal, what a supporter can
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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);
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
'use strict';
'use strict';
/**
/**
* Create Tips' OpenVibe.Events subscriptions for Billing's events: settlements (billing.transaction.*)
* 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.*']
* 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 ?
@@ -24,7 +26,7 @@ const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ?
(async () => {
(async () => {
const config = loadConfig();
const config = loadConfig();
const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);
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];
const secret = config.events.webhookSecrets[0];
if (!config.events.url) throw new Error('EVENTS_URL is not set');
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');
if (!secret || secret.length < 32) throw new Error('TIPS_EVENTS_SECRET must be set (32+ characters) before subscribing');
@@ -34,6 +34,11 @@
@@ -34,6 +34,11 @@
* | GET /moderation?creator=&state= | tips.interaction.moderate | the creator and their moderators |
* | 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 |
* | POST /interactions/:id/hide|restore | tips.interaction.moderate | the creator and their moderators |
* | GET /moderation/log?creator= | tips.interaction.moderate | owner |
* | 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 |
* | GET|POST /moderators, POST …/:s/remove | tips.profile.update | owner |
*
*
* Paid requests take `privacy: { anonymous, hide_amount, private_message }` (server/domain/privacy.js).
* Paid requests take `privacy: { anonymous, hide_amount, private_message }` (server/domain/privacy.js).
@@ -65,6 +70,9 @@ const CAP = {
@@ -65,6 +70,9 @@ const CAP = {
configUpdate: 'tips.overlay.config.update',
configUpdate: 'tips.overlay.config.update',
simulate: 'tips.simulation.run',
simulate: 'tips.simulation.run',
moderate: 'tips.interaction.moderate', // in openvibe-contracts since v0.32.0 (was docs/capabilities-proposal/)
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 }) {
function v1Router({ domain, apiAuth, limits }) {
@@ -299,6 +307,80 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -299,6 +307,80 @@ function v1Router({ domain, apiAuth, limits }) {
return g;
return g;
};
};
r.patch('/goals/:id', edit, ...write(async (req, res) => res.json({ goal: await goals.update(await ownGoal(req, CAP.goalUpdate), req.body || {}) })));
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)) })));
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 ───────────────────────────
// ── Overlay tokens and configs ───────────────────────────
@@ -27,6 +27,7 @@ const { loadConfig } = require('./config');
@@ -27,6 +27,7 @@ const { loadConfig } = require('./config');
const { openDb } = require('./db');
const { openDb } = require('./db');
const { createKeyProvider, createUserAuth } = require('./network');
const { createKeyProvider, createUserAuth } = require('./network');
const { createBillingClient } = require('./billing-client');
const { createBillingClient } = require('./billing-client');
+const { createVip } = require('./vip/client');
const { createAdapters } = require('./delivery');
const { createAdapters } = require('./delivery');
const { createTipsOutbox } = require('./events/outbox');
const { createTipsOutbox } = require('./events/outbox');
const { consumerRouter } = require('./events/consumer');
const { consumerRouter } = require('./events/consumer');
@@ -57,12 +58,15 @@ function createApp(opts = {}) {
@@ -57,12 +58,15 @@ function createApp(opts = {}) {
const userAuth = createUserAuth(config, keys);
const userAuth = createUserAuth(config, keys);
const billing = opts.billing || createBillingClient(config, { fetchImpl });
const billing = opts.billing || createBillingClient(config, { fetchImpl });
const adapters = opts.adapters || createAdapters(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 });
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
// 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
// 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.
// 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 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 apiAuth = createApiAuth({ config, keys, userAuth });
const release = require('openvibe-shared/release').createRelease({ service: 'tips', root: path.join(__dirname, '..') });
const release = require('openvibe-shared/release').createRelease({ service: 'tips', root: path.join(__dirname, '..') });
require('./web/layout').setRelease(release.release);
require('./web/layout').setRelease(release.release);
@@ -177,7 +181,8 @@ function createApp(opts = {}) {
@@ -177,7 +181,8 @@ function createApp(opts = {}) {
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
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
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
app.use((err, req, res, next) => {
@@ -191,10 +196,11 @@ function createApp(opts = {}) {
@@ -191,10 +196,11 @@ function createApp(opts = {}) {
log.error('[Tips] unhandled error:', err);
log.error('[Tips] unhandled error:', err);
if (res.headersSent) return undefined;
if (res.headersSent) return undefined;
if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'tips.internal', { ctx: req.ov });
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;
return app;
}
}
@@ -81,6 +81,20 @@ function loadConfig(env = process.env) {
@@ -81,6 +81,20 @@ function loadConfig(env = process.env) {
powerchatLinkTemplate: env.TIPS_POWERCHAT_LINK_TEMPLATE || '',
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: {
events: {
url: trim(env.EVENTS_URL || ''),
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
@@ -34,6 +34,8 @@ const TABLES = [
@@ -34,6 +34,8 @@ const TABLES = [
{ table: 'paid_media_requests', subject: 'creator_subject', file: null, erase: { keep: money } },
{ 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.
// 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'] },
{ 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: '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' },
{ 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' },
];
];
@@ -34,11 +34,21 @@ const { createInteractions } = require('./interactions');
@@ -34,11 +34,21 @@ const { createInteractions } = require('./interactions');
const { createEffects } = require('./effects');
const { createEffects } = require('./effects');
const { createModeration } = require('./moderation');
const { createModeration } = require('./moderation');
const { createSounds } = require('./sounds');
const { createSounds } = require('./sounds');
+const { createPosts } = require('./posts');
const MONEY = { isolation: 'serializable' };
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)); };
const runHook = (h) => { Promise.resolve().then(h).catch((e) => log.warn('[Tips] after-commit:', e.message)); };
ctx.tx = async (fn, opts = {}) => {
ctx.tx = async (fn, opts = {}) => {
@@ -66,6 +76,7 @@ function createDomain({ db, config, outbox, billing, adapters, valkey = null, no
@@ -66,6 +76,7 @@ function createDomain({ db, config, outbox, billing, adapters, valkey = null, no
ctx.interactions = createInteractions(ctx);
ctx.interactions = createInteractions(ctx);
ctx.effects = createEffects(ctx);
ctx.effects = createEffects(ctx);
ctx.moderation = createModeration(ctx);
ctx.moderation = createModeration(ctx);
+ ctx.posts = createPosts(ctx);
return ctx;
return ctx;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -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 };
@@ -44,6 +44,13 @@ function consumerRouter({ domain, config, log = console, accountData = null, acc
@@ -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 }. */
/** Apply one envelope (also used by tests and the replay tool). Returns { duplicate, outcome }. */
async function apply(event) {
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 (ACCOUNT_TOPICS.includes(event.event_type)) {
if (!accountData || !accountSend) throw new Error('account export and deletion are not configured');
if (!accountData || !accountSend) throw new Error('account export and deletion are not configured');
return { duplicate: false, outcome: await accountData.apply(event, { send: accountSend }) };
return { duplicate: false, outcome: await accountData.apply(event, { send: accountSend }) };
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -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 };
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
* the creator's own headline is the only pitch on their page.
* the creator's own headline is the only pitch on their page.
*/
*/
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const glyphs = require('openvibe-shared/glyphs');
const { esc } = require('./layout');
const { esc } = require('./layout');
const { VOICES } = require('../domain/profiles');
const { VOICES } = require('../domain/profiles');
const supportLinks = require('../domain/support-links');
const supportLinks = require('../domain/support-links');
@@ -13,6 +14,17 @@ const { pickBlock, foldBlock } = require('../render/short');
@@ -13,6 +14,17 @@ const { pickBlock, foldBlock } = require('../render/short');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const n = (v) => Number(v || 0).toLocaleString('en-US');
const when = (s) => (s ? esc(String(s).replace('T', ' ').slice(0, 16)) + ' UTC' : '');
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
// 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
// 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.
// sized — is used unchanged.
@@ -220,7 +232,72 @@ function supportLinksSection(profile) {
@@ -220,7 +232,72 @@ function supportLinksSection(profile) {
</section>`;
</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');
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>' : ''}
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">
<section class="creator-head">
@@ -228,8 +305,9 @@ function creatorPage({ profile, goals, viewer, csrf, idem, values, providers, er
@@ -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>` : ''}
<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>
<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>
</section>
-${active.length ? `<section><h2>Goals</h2>${active.map(goalCard).join('')}</section>` : ''}
${tipForm({ profile, goals, viewer, csrf, idem, values, providers, error, sounds })}
${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)}`;
${supportLinksSection(profile)}`;
}
}
@@ -342,7 +420,7 @@ function supportLinksCard({ profile: p, csrf, idem }) {
@@ -342,7 +420,7 @@ function supportLinksCard({ profile: p, csrf, idem }) {
</section>`;
</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 p = profile;
const f = (k) => (p[k] ? ' checked' : '');
const f = (k) => (p[k] ? ' checked' : '');
const alertCfg = configs.find((c) => c.kind === 'alerts');
const alertCfg = configs.find((c) => c.kind === 'alerts');
@@ -385,6 +463,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
@@ -385,6 +463,7 @@ ${flash ? `<p class="notice">${esc(flash)}</p>` : ''}${errorBox(error)}
</form>
</form>
</div>
</div>
</section>
</section>
+${postsAdmin({ posts, tiers: postTiers, csrf, idem })}
${soundsCard({ profile: p, sounds, csrf, idem })}
${soundsCard({ profile: p, sounds, csrf, idem })}
${supportLinksCard({ profile: p, csrf, idem })}
${supportLinksCard({ profile: p, csrf, idem })}
<section class="card"><h2>Goals</h2>
<section class="card"><h2>Goals</h2>
@@ -59,7 +59,17 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
@@ -59,7 +59,17 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
const secret = config.formSecret || crypto.randomBytes(32).toString('hex');
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) => {
const pageFor = (req, o) => {
// A public page names its Markdown twin in the Link header, exactly as the agent layer's own
// 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.
// 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
@@ -236,12 +246,14 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
configs = await overlays.listConfigs(db, p.creator_subject);
configs = await overlays.listConfigs(db, p.creator_subject);
}
}
const s = 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' })),
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)),
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),
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),
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),
sounds.list(db, s),
+ domain.posts.rows({ creatorSubject: s, includeHidden: true }),
+ domain.vip.tiers(s),
]);
]);
html(res, pageFor(req, {
html(res, pageFor(req, {
title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard',
title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard',
@@ -252,6 +264,8 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
@@ -252,6 +264,8 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
connected, chatAdapter: config.chat.adapter,
connected, chatAdapter: config.chat.adapter,
mod: { held, moderators: moderators.map(moderation.presentModerator), invites, log, moderating },
mod: { held, moderators: moderators.map(moderation.presentModerator), invites, log, moderating },
sounds: soundList,
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);
}), status);
}
}
@@ -318,6 +332,21 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow = { list: a
@@ -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', 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' }; });
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) => {
action('/dashboard/overlay-tokens', async (req, p) => {
const scopes = [bool(req.body.scope_alerts) && 'alerts', bool(req.body.scope_goals) && 'goals'].filter(Boolean);
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');
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
@@ -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
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.
// 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);
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 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 } }] : [];
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, {
return html(res, pageFor(req, {
title: `Support ${p.display_name}`, active: 'creator', canonicalPath: `/${p.handle}`,
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' : ''}.`,
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,
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' });
}), 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()));
r.get('/:handle', withViewer, async (req, res, next) => (profiles.normalizeHandle(req.params.handle) ? await creatorView(req, res) : next()));
@@ -259,4 +259,53 @@ async function startChat(network) {
@@ -259,4 +259,53 @@ async function startChat(network) {
return { url, messages, events, calls, state, close: () => new Promise((r) => server.close(r)) };
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; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,188 @@
@@ -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();
+})();