openvibers/openvibe.vip Commits
Members-only posts on creator pages (#188)
Changes
@@@ -32,6 +32,11 @@ VIP_FORM_SECRET=# ── OpenVibe.Billing (entitlement truth, checkout, cancel) ──BILLING_URL=http://127.0.0.1:4600++++# OpenVibe.Media: the image on a members-only post (the bytes live in Media, under the VIP_MEDIA_APP tenant).++OV_MEDIA_URL=https://openvibe.media++OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100++VIP_MEDIA_APP=vip# BILLING_AUDIENCE=openvibe.billing# VIP_BILLING_TIMEOUT_MS=5000# Providers offered on plan pages (must be enabled on Billing); `credit` pays from Vibes credit.@@@ -59,6 +59,7 @@ transactions, not an authority for anything.and its events `billing.entitlement.changed`, `billing.subscription.canceled`, `billing.transaction.reversed`.- **OpenVibe.Network** — RS256 service and user tokens (JWKS), OAuth sign-in for the pages.- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.++- **OpenVibe.Media** — the image on a members-only post (`media.object.upload`, the `vip` tenant): the bytes live in Media, VIP keeps the `med_` reference. Without a service principal the post is written without an image.- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).- **openvibe-contracts** v0.76.0, **openvibe-sdk** v0.28.0 (outbox, inbox, delivery signatures, per-actorlimits), **openvibe-shared** v2.2.0 (Frame, legal pages, release manifest and update metrics, metrics,@@@ -188,6 +189,33 @@ The three Events subscriptions exist.- Cancel is the member's own action: VIP confirms with Billing that the subscription is theirs, thenBilling `POST /subscriptions/:id/cancel` (at period end).++### Members-only posts (the creator's own posts)++++A creator writes posts for the members of one or more of their tiers (Billing's tiers of that creator,++`vip_plans.billing_tier`). A post carries a title, a body in Markdown (rendered to safe HTML: the++source can never carry markup) and an optional image (a Media object — the bytes live in++OpenVibe.Media under VIP's tenant, the post keeps the `med_` reference).++++Who may read the text is never stored: it is answered on every request by the entitlement check++(Billing's truth, projected) and the tier the member holds. The list of posts never carries a body, and++the body is served only after that check. So:++++* a signed-out visitor and a non-member see the title, the tier it needs and a way to join — never the text;++* a member of a tier reads a post written for their tier or for a lower one (a higher tier includes the lower);++* a member of another tier reads nothing;++* a hidden post is not served at all — not its title, not its text.++++A locked post offers a way in only when a tier that reads it can be joined: the "Become a member" button++points at the cheapest such plan. When none can be joined (the plan has no price yet, or is not sold++through Billing), the post says so — "This post is for the members of Tier 2, which is not open to join++yet." — and offers no button, because a primary button must never lead to a dead end. The line carries++the shared lock glyph.++++The creator reads their own posts (on their page and on the dashboard). Staff hide any creator's post,++the way they manage network plans; a creator may hide their own, but cannot bring back a post staff++hid. A post's author reference is anonymized when the creator's account is deleted (ADR-033), like++their plans and perks.++### Events (openvibe-sdk outbox, same transaction as the change)| Event | When | Payload |@@@ -227,6 +255,15 @@ handle or `network`.| `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` || `POST /policies/evaluate` `{subject, resource, owner, rule_id?, mode?, fallback?}` — `owner` = the creator the PRODUCT says owns the resource (usr_ id or `network`); only that creator's rule applies, none → `owner_required`. `fallback` `{ requirement: 'member', binding?: 'product:binding' }` = the product's default gate, used only when the owner has no rule (below) | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement, fallback? }` — **fails closed** || `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |++| `GET /creators/:ref/posts` | — (public) | owner sees the hidden posts too | the posts of a creator: title, tiers, image, date — never the text |++| `GET /creators/:ref/posts/:id` | — (public) | owner / staff | one post; the text only for a member of its tier (or the creator, or staff) |++| `GET /creators/:ref/posts/:id/body` | — | the member of the tier, the creator, staff | the text: 401 signed out, 403 not a member, 404 hidden |++| `POST /creators/:ref/posts` | `vip.post.create` | the creator | write a post `{title, body, tiers}` for one or more of the creator's tiers |++| `POST /posts/:id/hide`, `POST /posts/:id/unhide` | `vip.post.moderate` | the creator, staff | hide a post (nobody reads it any more), show it again |++++The `vip.post.*` ids are not in openvibe-contracts yet, so no service can hold them: people (the++creator, staff) write and moderate posts without a capability. A service that wants to needs a++contracts entry first.Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,`invalid_fallback`, `creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`,@@@ -328,9 +365,11 @@ memberships* card gives the creator the badge and widget HTML and the card URL.## Pages (server-rendered, useful without JavaScript, OpenVibe Frame)`/` directory of creators with published plans · `/:username` a creator's plans (terms, perks,--Billing's price and period from `GET /api/v1/rates`, join form) · `/:username/plans/:slug` public terms++Billing's price and period from `GET /api/v1/rates`, join form) and their members-only posts (the++title and the tier are public, the text only for a member of that tier) · `/:username/plans/:slug` public termshistory · `/me` your memberships, the version you joined under, cancel, badge preference ·--`/dashboard` plans, versions, perks and bindings, members, members-only resources, the badge and++`/dashboard` plans, versions, perks and bindings, members, members-only resources, the members-only++posts (write, hide) and, for staff, the moderation list, the badge andwidget to share and the member-count switch (`?as=network` for staff) · `/embed/:username/…` (above) · `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` · `/terms`, `/privacy`, `/dmca`(openvibe-shared legal). Navbar from `https://openvibe.network/shared/navbar.js`, SSR footer and`<noscript>` navigation from openvibe-shared. Forms carry HMAC anti-forgery tokens@@@ -426,6 +465,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).| a creator's membership card, badge and widget are public-data only, the same for everyone, embeddable and inert | `test/embeds.test.js` (published plans only, no subjects or binding configs, no cookie read or set, CORS, SVG escaping and sandbox, the widget's frame-ancestors and hashed style, the creator's member-count switch, Billing outage keeps the last count or shows none) || `/llms-full.txt` carries the full text of the home, creator and plan pages, and is cached for an hour | `test/llms-full.test.js`, `test/api.test.js` (discovery artifacts) || IndexNow is off without `INDEXNOW_KEY` (no key route, nothing sent); with one the key file is served at `/<key>.txt` as `text/plain` and publishing, editing and archiving a plan ping the creator page, the terms page and the sitemap; a draft and an unchanged edit never ping | `test/indexnow.test.js` |++| members-only posts: a member of the tier reads the text, a non-member and a signed-out visitor do not (page and API), the creator reads their own, staff hide any post, and the Markdown never carries markup | `test/member-posts.test.js` (the tier rules, the API, the moderation, the Media image, the renderer) |Not yet demonstrated (needs the other services): convergence **across consuming products** end to end(Chat, Community, Blog and Wiki call VIP in code; Live does not), and a real Billing/Events round tripnnew file mode 100644@@@ -0,0 +1,27 @@++-- phase: expand++-- Members-only posts (owner's ask, plan 2026-10-22 §4 "Creator money: Tips, VIP": members-only posts on++-- creator pages). A creator writes a post for the members of one or more of their tiers: the tiers are++-- Billing's tiers of that creator (vip_plans.billing_tier), the title and body are the creator's, and an++-- optional image is a Media object (med_…, the bytes live in OpenVibe.Media).++--++-- The body is never read with the list of posts: it is served only after the entitlement check++-- (server/domain/posts.js), so a signed-out visitor and a non-member never receive it.++CREATE TABLE IF NOT EXISTS vip_member_posts (++ id text COLLATE "C" PRIMARY KEY,++ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),++ title text COLLATE "C" NOT NULL,++ body text COLLATE "C" NOT NULL,++ body_html text COLLATE "C" NOT NULL,++ tiers text COLLATE "C" NOT NULL DEFAULT '[]',++ image_id text COLLATE "C",++ image_url text COLLATE "C",++ status text COLLATE "C" NOT NULL DEFAULT 'published' CHECK (status IN ('published', 'hidden')),++ created_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ published_at text COLLATE "C",++ hidden_at text COLLATE "C",++ hidden_by text COLLATE "C",++ hide_reason text COLLATE "C"++);++CREATE INDEX IF NOT EXISTS idx_vip_member_posts_creator ON vip_member_posts (creator_id, published_at DESC);@@@ -13,6 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",++ "multer": "^2.5.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",@@@ -91,6 +92,12 @@}}},++ "node_modules/append-field": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",++ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",++ "license": "MIT"++ },"node_modules/array-flatten": {"version": "1.1.1","resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",@@@ -140,6 +147,17 @@"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==","license": "BSD-3-Clause"},++ "node_modules/busboy": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",++ "dependencies": {++ "streamsearch": "^1.1.0"++ },++ "engines": {++ "node": ">=10.16.0"++ }++ },"node_modules/bytes": {"version": "3.1.2","resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",@@@ -1068,6 +1086,24 @@"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==","license": "MIT"},++ "node_modules/multer": {++ "version": "2.5.0",++ "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",++ "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",++ "license": "MIT",++ "dependencies": {++ "append-field": "^1.0.0",++ "busboy": "^1.6.0",++ "type-is": "^1.6.18"++ },++ "engines": {++ "node": ">= 10.16.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },"node_modules/negotiator": {"version": "0.6.3","resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",@@@ -1589,6 +1625,14 @@"node": ">= 0.8"}},++ "node_modules/streamsearch": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",++ "engines": {++ "node": ">=10.0.0"++ }++ },"node_modules/toidentifier": {"version": "1.0.1","resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",@@@ -30,6 +30,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",++ "multer": "^2.5.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",@@@ -27,6 +27,9 @@ const CAP = {policySet: 'vip.resource.policy.set',policyEvaluate: 'vip.resource.policy.evaluate',members: 'vip.creator.members.list',++ postCreate: 'vip.post.create',++ postList: 'vip.post.list',++ postModerate: 'vip.post.moderate',};function v1Router({ domain, apiAuth, limits }) {@@@ -381,6 +384,85 @@ function v1Router({ domain, apiAuth, limits }) {}}));++ // ── Members-only posts ───────────────────────────────────++ // The text of a post is served only after the entitlement check (Billing's truth): the list never++ // carries it, and /posts/:id/body answers the member of the tier the post is written for.++ const postCreator = async (req) => {++ const c = await findCreator(req.params.ref);++ if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');++ return c;++ };++ const postRow = async (req, c) => {++ const row = await domain.posts.byId(req.params.id);++ if (!row || row.creator_id !== c.id) fail(404, 'vip.post_not_found', 'no such post');++ return row;++ };++ /** May this caller read the text of this post? The creator and staff may; a member of the tier may. */++ const postAccess = async (req, c, row) => {++ const p = req.principal;++ if (p.kind === 'user' && (p.subject === c.subject || isStaff(p))) return { unlocked: true, tier: null };++ if (p.kind !== 'user') return { unlocked: false, reason: 'not_a_member', tier: null };++ return await domain.posts.access({ post: row, memberSubject: p.subject, creatorSubject: c.subject, creatorId: c.id, traceparent: trace(req) });++ };++ r.get('/creators/:ref/posts', wrap(async (req, res) => {++ const c = await postCreator(req);++ const own = mayManage(req.principal, c, CAP.postList);++ const posts = [];++ for (const row of await domain.posts.rows({ creatorId: c.id, includeHidden: own })) {++ const access = await postAccess(req, c, row);++ posts.push(domain.posts.present(row, { unlocked: !!access.unlocked, tier: access.tier || null }));++ }++ res.json({ creator: creators.present(c), posts });++ }));++ r.get('/creators/:ref/posts/:id', wrap(async (req, res) => {++ const c = await postCreator(req);++ const row = await postRow(req, c);++ const own = mayManage(req.principal, c, CAP.postList);++ if (row.status === 'hidden' && !own) fail(404, 'vip.post_not_found', 'no such post');++ const access = await postAccess(req, c, row);++ res.json({ post: domain.posts.present(row, { unlocked: !!access.unlocked, tier: access.tier || null }) });++ }));++ // The text itself: only a member of the tier the post is written for (or the creator, or staff).++ r.get('/creators/:ref/posts/:id/body', wrap(async (req, res) => {++ const c = await postCreator(req);++ const row = await postRow(req, c);++ needAuth(req.principal);++ const staff = req.principal.kind === 'user' && isStaff(req.principal);++ if (row.status === 'hidden' && !(staff || req.principal.subject === c.subject)) fail(404, 'vip.post_not_found', 'no such post');++ const access = await postAccess(req, c, row);++ if (!access.unlocked) fail(403, 'vip.member_only', 'this post is for the members of another tier');++ res.json({ post_id: row.id, tier: access.tier || null, body: row.body, body_html: row.body_html });++ }));++ r.post('/creators/:ref/posts', create, wrap(async (req, res) => {++ const b = req.body || {};++ const c = await postCreator(req);++ requireManage(req, c, CAP.postCreate);++ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ let image = null;++ if (b.image && b.image.id) {++ const url = b.image.url ? String(b.image.url) : null;++ if (url && !url.startsWith(domain.config.media.url)) fail(422, 'vip.invalid_input', 'the image must be an object stored in OpenVibe.Media');++ image = { id: String(b.image.id), url };++ }++ const row = await domain.posts.create({ creatorId: c.id, title: b.title, body: b.body, tiers: b.tiers, image, actor, traceparent: trace(req) });++ res.status(201).json({ post: domain.posts.present(row, { unlocked: true }) });++ }));++ // Moderation (the way staff hide a post): the creator hides their own, staff hides anyone's.++ r.post('/posts/:id/hide', create, wrap(async (req, res) => {++ const row = await domain.posts.byId(req.params.id);++ if (!row) fail(404, 'vip.post_not_found', 'no such post');++ requireManage(req, await creators.byId(row.creator_id), CAP.postModerate);++ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ res.json({ post: domain.posts.present(await domain.posts.hide(row.id, { actor, reason: req.body && req.body.reason })) });++ }));++ r.post('/posts/:id/unhide', create, wrap(async (req, res) => {++ const row = await domain.posts.byId(req.params.id);++ if (!row) fail(404, 'vip.post_not_found', 'no such post');++ requireManage(req, await creators.byId(row.creator_id), CAP.postModerate);++ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;++ res.json({ post: domain.posts.present(await domain.posts.unhide(row.id, { actor, staff: isStaff(req.principal) })) });++ }));++return r;}@@@ -25,6 +25,7 @@ const { loadConfig } = require('./config');const { openDb } = require('./db');const { createKeyProvider, createUserAuth } = require('./network');const { createBillingClient } = require('./billing-client');++const { createMediaClient } = require('./media-client');const { createVipOutbox } = require('./events/outbox');const { consumerRouter } = require('./events/consumer');const accountDataLib = require('./domain/account-data');@@@ -60,7 +61,9 @@ async function createApp(opts = {}) {const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({host: config.baseUrl, key: config.indexnow.key, fetch: fetchImpl, log,});-- const domain = createDomain({ db, config, outbox, billing, now, log, indexnow });++ // OpenVibe.Media (the image on a members-only post). Tests inject a stand-in through opts.media.++ const media = opts.media || createMediaClient({ config, fetchImpl });++ const domain = createDomain({ db, config, outbox, billing, now, log, indexnow, media });const apiAuth = createApiAuth({ config, keys, userAuth });const release = require('openvibe-shared/release').createRelease({ service: 'vip', root: path.join(__dirname, '..') });setRelease(release.release);@@@ -75,6 +75,14 @@ function loadConfig(env = process.env) {graceMs: int(env.VIP_PROJECTION_GRACE_MS, 60 * 1000),},++ // OpenVibe.Media: the image on a members-only post. The bytes live in Media under VIP's tenant++ // (the namespace VIP's service token holds media.object.upload for); VIP keeps the med_ reference.++ media: {++ url: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),++ internalUrl: trim(env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),++ app: String(env.VIP_MEDIA_APP || 'vip').trim() || 'vip',++ },++events: {url: trim(env.EVENTS_URL || ''),intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),@@@ -29,6 +29,9 @@ const TABLES = [{ table: 'vip_plan_versions', subject: 'created_by', file: 'plan-versions.json', columns: ['id', 'plan_id', 'version', 'name', 'description', 'benefits', 'terms', 'change_note', 'created_at', 'published_at'], erase: { keep: 'plan versions are the immutable terms members joined under' } },{ table: 'vip_perks', subject: 'created_by', file: 'perks.json', columns: ['id', 'creator_id', 'key', 'name', 'description', 'kind', 'status', 'created_at'], erase: anonymize },{ table: 'vip_gated_resource_rules', subject: 'created_by', file: null, erase: anonymize },++ // The posts a creator wrote: theirs to take away with them (the author reference goes), but the posts++ // themselves stay with the creator row, which is suspended and no longer served.++ { table: 'vip_member_posts', subject: 'created_by', file: 'posts.json', columns: ['id', 'creator_id', 'title', 'body', 'tiers', 'status', 'image_id', 'published_at', 'created_at'], order: 'created_at', erase: anonymize },];/** The creator row stays (it is referenced; counted as retained above) but is suspended and loses what names the person. */@@@ -11,8 +11,9 @@ const { createPolicies } = require('./policies');const { createCheckout } = require('./checkout');const { createCards } = require('./cards');const { createPrices } = require('./prices');++const { createPosts } = require('./posts');--function createDomain({ db, config, outbox, billing, now = () => Date.now(), log = console, indexnow = null }) {++function createDomain({ db, config, outbox, billing, now = () => Date.now(), log = console, indexnow = null, media = null }) {const creators = createCreators({ db, now });const perks = createPerks({ db, now });const plans = createPlans({ db, now, outbox, creators, perks, baseUrl: config.baseUrl, indexnow });@@@ -23,8 +24,10 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), logconst checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements, periods });const prices = createPrices({ config, billing, now });const cards = createCards({ db, config, creators, plans, perks, billing, prices, now, log });++ // The posts a creator writes for the members of their tiers (readers are decided by the entitlement check).++ const posts = createPosts({ db, now, creators, plans, memberships, entitlements });const tx = async (fn) => await db.tx(fn);-- return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, periods, entitlements, policies, checkout, cards, prices };++ return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, periods, entitlements, policies, checkout, cards, prices, posts, media };}module.exports = { createDomain };nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++++/**++ * A small, safe Markdown renderer for the 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+(.*)$/.exec(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,143 @@++'use strict';++++/**++ * vip_member_posts: the posts a creator writes for the members of their tiers.++ *++ * A post names the tiers it is for (Billing's tiers of that creator, vip_plans.billing_tier) and carries++ * the title, the Markdown body with its safe HTML, and an optional image (a Media object). Who may read++ * it is never stored: it is answered by the entitlement check (Billing's truth, projected by++ * server/domain/entitlements.js) 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 higher tier includes the lower);++ * - 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 { fail, prefixedId, iso, json, text } = require('../util');++const { renderMarkdown } = require('./markdown');++++const MAX_TIERS = 10;++++function createPosts({ db, now, creators, plans, memberships, entitlements }) {++ const byId = async (id) => (id ? await db.prepare('SELECT * FROM vip_member_posts WHERE id = ?').get(id) || null : null);++ const rows = async ({ creatorId, includeHidden = false }) => {++ const st = includeHidden ? ['published', 'hidden'] : ['published'];++ const marks = st.map(() => '?').join(',');++ return await db.prepare('SELECT * FROM vip_member_posts WHERE creator_id = ? AND status IN (' + marks +++ ') ORDER BY published_at DESC, created_at DESC').all(creatorId, ...st);++ };++ const recent = async ({ limit = 50 } = {}) =>++ await db.prepare('SELECT * FROM vip_member_posts ORDER BY created_at DESC LIMIT ?').all(limit);++++ /** The tiers this creator sells (their published plans' Billing tiers). */++ const offeredTiers = async (creatorId) => (await db.prepare(++ "SELECT DISTINCT billing_tier FROM vip_plans WHERE creator_id = ? AND status = 'published' AND billing_kind IS NOT NULL ORDER BY billing_tier"++ ).all(creatorId)).map((r) => Number(r.billing_tier));++++ /** The tiers of one post, as numbers. */++ const tiersOf = (post) => {++ const list = json(post && post.tiers, []);++ return Array.isArray(list) ? list.map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= MAX_TIERS) : [];++ };++++ /**++ * The tier a member holds: the plan their membership was bought under (VIP's record of the terms++ * they joined); a subscription made elsewhere (Billing sells those at tier 1) is tier 1.++ */++ async function memberTier(memberSubject, creatorId) {++ const m = await memberships.get(memberSubject, creatorId);++ if (!m || !m.plan_id) return 1;++ const plan = await plans.byId(m.plan_id);++ if (!plan || !plan.billing_kind) return 1;++ return Number(plan.billing_tier) || 1;++ }++++ /**++ * May this reader read this post? → { unlocked, reason, tier }.++ * The entitlement is Billing's (projected, then confirmed when stale); anything VIP cannot confirm is++ * refused, so a membership that ended never opens a post.++ */++ async function access({ post, memberSubject, creatorSubject, creatorId, mode = 'auto', traceparent, entitlement = null }) {++ const tiers = tiersOf(post);++ if (!memberSubject) return { unlocked: false, reason: 'signed_out', tier: null };++ // The entitlement may be the one the page already has (Billing's truth, projected); otherwise it is asked for.++ const ent = entitlement || await entitlements.check(memberSubject, creatorSubject, { mode, traceparent });++ if (!ent || ent.status !== 'active') return { unlocked: false, reason: 'not_a_member', tier: null, entitlement: ent };++ const tier = await memberTier(memberSubject, creatorId);++ 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 tiers = tiersOf(post);++ const shown = {++ id: post.id, creator_id: post.creator_id, title: post.title, tiers, status: post.status,++ image: post.image_id ? { id: post.image_id, url: post.image_url } : null,++ 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;++ }++++ /**++ * Write a post for the members of one or more of the creator's tiers.++ * tiers: the numbers to write for; they must be tiers the creator sells, so every member of a tier can++ * join it and a "Become a member" link always leads somewhere.++ */++ async function create({ creatorId, title, body, tiers, image = null, actor = null, traceparent }) {++ const creator = await creators.byId(creatorId);++ if (!creator) fail(404, 'vip.creator_not_found', 'no creator ' + creatorId);++ if (creator.kind !== 'creator') fail(422, 'vip.invalid_input', 'members-only posts belong to a creator');++ const cleanTitle = text(title, 'title', 140, { required: true });++ const cleanBody = text(body, 'body', 20000, { required: true });++ 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, 'vip.invalid_input', 'choose at least one tier for the post');++ const offered = await offeredTiers(creatorId);++ if (!offered.length) fail(422, 'vip.post_no_tiers', 'publish a plan first: a post is written for the members of a tier');++ const unknown = numbers.filter((t) => !offered.includes(t));++ if (unknown.length) fail(422, 'vip.post_unknown_tier', 'you do not sell tier ' + unknown.join(', '));++ const at = iso(now());++ const id = prefixedId('vmp', now());++ await db.prepare('INSERT INTO vip_member_posts (id, creator_id, title, body, body_html, tiers, image_id, image_url, ' +++ "status, created_by, created_at, updated_at, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?)").run(++ id, creatorId, cleanTitle, cleanBody, renderMarkdown(cleanBody), JSON.stringify(numbers),++ image && image.id ? image.id : null, image && image.url ? image.url : null, actor, at, at, at);++ return await byId(id);++ }++++ /** Hide a post (the creator's own call, or a staff moderation): 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, 'vip.post_not_found', 'no post ' + id);++ if (post.status === 'hidden') return post;++ const at = iso(now());++ await db.prepare("UPDATE vip_member_posts SET status = 'hidden', hidden_at = ?, hidden_by = ?, hide_reason = ?, updated_at = ? WHERE id = ?")++ .run(at, actor, reason ? String(reason).slice(0, 300) : null, at, id);++ return await byId(id);++ }++++ /** Show a hidden post again: the staff may always, a creator only the posts they hid themselves. */++ async function unhide(id, { actor = null, staff = false } = {}) {++ const post = await byId(id);++ if (!post) fail(404, 'vip.post_not_found', 'no post ' + id);++ if (post.status !== 'hidden') return post;++ if (!staff && post.hidden_by && actor && post.hidden_by !== actor) {++ fail(403, 'vip.not_yours', 'a post hidden by staff stays hidden');++ }++ const at = iso(now());++ await db.prepare("UPDATE vip_member_posts SET status = 'published', hidden_at = NULL, hidden_by = NULL, hide_reason = NULL, updated_at = ? WHERE id = ?")++ .run(at, id);++ return await byId(id);++ }++++ return { byId, rows, recent, create, hide, unhide, present, access, memberTier, offeredTiers, tiersOf };++}++++module.exports = { createPosts };nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++++/**++ * OpenVibe.Media Object API v2 client — the image on a members-only post. The bytes live in Media under++ * VIP's tenant (the namespace VIP's service token holds media.object.upload for), owned by the creator who++ * wrote the post (X-OV-Subject); VIP keeps the med_ reference and the public URL.++ *++ * POST /api/v2/<app>/objects init { kind, visibility, size_bytes, mime_type, filename, content_hash }++ * PUT /api/v2/<app>/objects/:id/content the bytes++ * POST /api/v2/<app>/objects/:id/complete Media checks size, hash and type → ready++ *++ * Public bytes are served at <media>/o/:id. The tenant (config.media.app, "vip" by default) must exist in++ * Media and VIP's service principal must hold media.object.upload for it.++ */++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++++function createMediaClient({ config, fetchImpl = globalThis.fetch } = {}) {++ const configured = !!(config.oauth && config.oauth.clientSecret);++ const tokens = configured ? serviceAuth.createTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.media',++ scope: 'media.object.upload',++ fetchImpl,++ }) : null;++ const base = `${config.media.internalUrl}/api/v2/${config.media.app}/objects`;++++ async function call(method, path, { json, body, headers = {} } = {}, retried = false) {++ const h = { Accept: 'application/json', ...headers, ...(await tokens.authHeaders()) };++ if (json !== undefined) h['Content-Type'] = 'application/json';++ const res = await fetchImpl(`${base}${path}`, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : body, signal: AbortSignal.timeout(60_000) });++ if (res.status === 401 && !retried) { tokens.invalidate(); return await call(method, path, { json, body, headers }, true); }++ const data = await res.json().catch(() => null);++ if (!res.ok) {++ const err = new Error(`Media ${method} ${path || '/'} ${res.status}: ${(data && (data.detail || data.error)) || 'failed'}`);++ err.status = res.status; err.code = data && data.code;++ throw err;++ }++ return data || {};++ }++++ /**++ * Store one image. → { id (med_…), url, size_bytes, mime }++ * owner: the person (usr_…) the object belongs to; kind: 'post_image'; visibility 'unlisted': served to++ * anyone with the link (the object id is unguessable), never listed on openvibe.media — the post decides++ * who sees the link.++ */++ async function uploadImage({ buffer, mime, filename, owner = null, kind = 'post_image', visibility = 'unlisted', source = 'vip.member_post' }) {++ if (!configured) { const e = new Error("An image needs VIP's service principal (OV_OAUTH_CLIENT_SECRET)"); e.status = 503; throw e; }++ const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');++ const init = await call('POST', '', {++ json: { kind, visibility, size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256, metadata: { source } },++ headers: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ? { 'X-OV-Subject': owner } : {},++ });++ const id = init.id || (init.object && init.object.id);++ if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) throw Object.assign(new Error('Media returned no object id'), { status: 502 });++ await call('PUT', `/${id}/content`, { body: buffer, headers: { 'Content-Type': mime } });++ await call('POST', `/${id}/complete`, { json: { content_hash: sha256 } });++ return { id, url: `${config.media.url}/o/${id}`, size_bytes: buffer.length, mime };++ }++++ return { configured, uploadImage };++}++++module.exports = { createMediaClient };@@@ -8,6 +8,7 @@* no begging copy.*/const showcase = require('openvibe-shared/showcase');++const glyphs = require('openvibe-shared/glyphs');const { esc } = require('../util');const fmtDate = (s) => {@@@ -68,6 +69,10 @@ function homeFaqJsonLd() {}const S = require('./short');++// 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>';const avatar = (networkUrl, username, size) => `<img class="vip-avatar" src="${esc(networkUrl)}/avatar/${encodeURIComponent(username)}?s=${size}" width="${size}" height="${size}" alt="" loading="lazy" decoding="async">`;const plural = (n, one, many) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;@@@ -165,8 +170,61 @@ if (e.active) unlockPro(); // expired, unknown or unreachable all answer no`,return S.foldSection(body, 'id="questions"', 'questions-fold');}++/**++ * The creator page's members-only posts. The title is public; the text is only ever rendered for a++ * member of a tier the post is written for (the route decides that, after the entitlement check).++ */++function postsSection(posts) {++ if (!posts.length) return '';++ const items = posts.map((p) => {++ const tiers = p.tiers.length ? p.tiers.map((t) => `Tier ${t}`).join(', ') : 'a tier';++ const meta = `<p class="muted small">${esc(tiers)}${p.published_at ? ` · ${fmtDate(p.published_at)}` : ''}</p>`;++ const image = p.image && p.image.url ? `<p><img src="${esc(p.image.url)}" alt="" style="max-width:100%;height:auto"></p>` : '';++ // A locked post: the line, and a way in only when a tier that reads it can be joined. A button++ // that leads nowhere is worse than no button.++ const body = p.unlocked++ ? `<div class="post-body">${p.body_html}</div>`++ : `<p class="muted">${lockGlyph}This post is for the members of ${esc(tiers)}${p.join_url ? '.' : ', which is not open to join yet.'}</p>`++ + (p.join_url ? `<p><a class="button" href="${esc(p.join_url)}">Become a member</a></p>` : '');++ return `<article class="card post"><h3>${esc(p.title)}</h3>${meta}${image}${body}</article>`;++ }).join('');++ return `<section class="posts" aria-labelledby="posts-h"><h2 id="posts-h">Members-only posts</h2>++<p class="muted small">Posts for the members of a tier. The title is public; the text is not.</p>++<div class="post-list">${items}</div></section>`;++}++++/**++ * The dashboard's posts: the composer, the creator's own posts (with the text they wrote) and, for++ * staff, the moderation list over every creator's posts.++ */++function postsAdmin({ posts, tiers, moderation, token, base }) {++ const composer = tiers.length ? `<details class="card"><summary>New post</summary>++<form method="post" action="${base}/posts" enctype="multipart/form-data">${csrf(token)}++<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 class="check"><input type="checkbox" name="tiers" value="${t}"> Tier ${t}</label>`).join('')}</fieldset>++<label>Image (optional) <input type="file" name="image" accept="image/png,image/jpeg,image/webp,image/gif"></label>++<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 type="submit">Publish post</button></form></details>`++ : '<p class="muted">Publish a plan 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(', '))} · ${fmtDate(p.published_at)} · <span class="pill">${esc(p.status)}</span></p>++${p.image && p.image.url ? `<p><img src="${esc(p.image.url)}" alt="" style="max-width:100%;height:auto"></p>` : ''}++<div class="post-body">${p.body_html}</div>++<form method="post" action="${base}/posts/${esc(p.id)}/${p.status === 'hidden' ? 'unhide' : 'hide'}" class="inline">${csrf(token)}<button type="submit" class="button-quiet">${p.status === 'hidden' ? 'Show again' : 'Hide'}</button></form>++</article>`).join('') : '<p class="muted">No posts yet.</p>';++ const mod = moderation && moderation.length ? `<section><h2>Moderation</h2>++<ul class="rules">${moderation.map((m) => `<li><b>${esc(m.title)}</b> · ${esc(m.creator)} · ${esc(m.tiers.map((t) => `Tier ${t}`).join(', '))} · <span class="pill">${esc(m.status)}</span>++<form method="post" action="/dashboard/moderation/posts/${esc(m.id)}/hide" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Hide</button></form>++<form method="post" action="/dashboard/moderation/posts/${esc(m.id)}/unhide" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Show again</button></form></li>`).join('')}</ul></section>` : '';++ return `<section><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>${mod}`;++}++/** The creator's public plan page. */--function creatorPage({ creator, plans, viewer, member, rates, providers, token, query, isOwner }) {++function creatorPage({ creator, plans, viewer, member, rates, providers, token, query, isOwner, posts = [] }) {const name = creator.display_name || creator.username;const status = member && member.status === 'active'? `<div class="card member-box"><p><b>You are a member.</b> ${member.cancel_at_period_end ? `Your membership ends on ${fmtDate(member.expires_at)}.` : `Paid through ${fmtDate(member.expires_at)}.`}</p>@@@ -201,7 +259,8 @@ ${join}${creator.bio ? `<p>${esc(creator.bio)}</p>` : ''}</section>${notice(query)}${status}--<section class="plans">${planCards}</section>`;++<section class="plans">${planCards}</section>++${postsSection(posts)}`;}/** Public terms history of one plan: every published version, unchanged. */@@@ -295,7 +354,7 @@ function shareCard({ creator, embeds, hasPlan }) {<label>Card for developers (JSON, open to any site) <input readonly value="${esc(embeds.card)}"></label></section>`;}--function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base, embeds, priceTable = null }) {++function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base, embeds, priceTable = null, posts = [], postTiers = [], moderation = [] }) {if (!viewer) {return `<section class="card narrow"><h1>Creator dashboard</h1><p>Sign in with your OpenVibe account to offer a membership plan.</p><p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;@@@ -337,7 +396,7 @@ ${ownPerks.length ? `<ul class="perk-admin">${ownPerks.map((p) => `<li><details><label>Kind <select name="kind">${['badge', 'emote', 'gated_content', 'room', 'role', 'other'].map((k) => `<option>${k}</option>`).join('')}</select></label><label>Product bindings, one "product binding" per line (e.g. <code>chat badge</code>) <textarea name="bindings" rows="2"></textarea></label><button type="submit">Create perk</button></form></details></section>--${isNetwork ? '' : `<section><h2>Members</h2>++${isNetwork ? '' : `${postsAdmin({ posts, tiers: postTiers, moderation, token, base })}<section><h2>Members</h2>${members.length ? `<table class="members"><thead><tr><th>Member</th><th>Plan version</th><th>Paid through</th></tr></thead><tbody>${members.map((m) => `<tr><td><code>${esc(m.member.id)}</code></td><td>${m.membership && m.membership.plan_version ? `${esc(m.membership.plan_version.name)} v${esc(m.membership.plan_version.version)}` : '—'}</td><td>${fmtDate(m.current_period_end)}${m.cancel_at_period_end ? ' (ends)' : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No active members.</p>'}<p class="muted small">${membersSource === 'billing' ? 'From OpenVibe.Billing.' : 'OpenVibe.Billing did not answer; this is VIP\'s recent projection and may be incomplete.'}</p></section><section><h2>Members-only resources</h2>@@@ -357,5 +416,5 @@ ${rules.length ? `<ul class="rules">${rules.map((r) => `<li><code>${esc(r.resourmodule.exports = {HOME_TITLE, HOME_DESCRIPTION, HOME_FAQ, homeFaqJsonLd,-- errorPage, home, creatorPage, planHistory, memberPage, checkoutPage, dashboard, fmtDate,++ errorPage, home, creatorPage, planHistory, memberPage, checkoutPage, dashboard, postsSection, fmtDate,};@@@ -18,6 +18,7 @@ const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');const seo = require('openvibe-shared/seo');const express = require('express');++const multer = require('multer');const { VipError, bool } = require('../util');const { viewerMiddleware } = require('./session');const { createForms } = require('./forms');@@@ -43,6 +44,11 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {const isStaff = (v) => !!v && v.staff === true;router.use(viewerMiddleware(userAuth));const body = express.urlencoded({ extended: false, limit: '32kb' });++ // The post composer posts a form with an optional image: multipart, one file, 8 MB, in memory.++ const uploadImage = multer({++ storage: multer.memoryStorage(),++ limits: { fileSize: 8 * 1024 * 1024, files: 1, fields: 24, fieldSize: 64 * 1024 },++ }).single('image');let ratesCache = { at: 0, value: null };async function rates() {@@@ -89,8 +95,11 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {const lines = (v) => String(v || '').split('\n').map((s) => s.trim()).filter(Boolean);/** A POST handler: viewer + anti-forgery token, VipError → redirect with the message. */-- function post(path, handler, { backTo } = {}) {-- router.post(path, body, async (req, res, next) => {++ function post(path, handler, { backTo, upload = null } = {}) {++ // A route with an upload takes the multipart parser first and the urlencoded one after it, so a++ // form without a file still works.++ const parser = upload ? [upload, body] : [body];++ router.post(path, ...parser, async (req, res, next) => {const to = typeof backTo === 'function' ? backTo(req) : (backTo || req.path);if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(to)}`);if (!forms.verify(req.viewer.subject, req.body && req.body._csrf)) return back(res, to, 'error', 'That form expired. Please try again.');@@@ -258,12 +267,27 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {members = (await Promise.all((await entitlements.projectedMembers(creator.subject)).map(async (pr) => ({ member: { type: 'user', id: pr.member_subject }, current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, membership: await memberships.present(byMember.get(pr.member_subject)) }))));}}++ // The creator's own posts (with their text) and, for staff, every creator's posts to moderate.++ const postRows = isNetwork ? [] : await domain.posts.rows({ creatorId: creator.id, includeHidden: true });++ const postTiers = isNetwork ? [] : await domain.posts.offeredTiers(creator.id);++ const moderation = isStaff(req.viewer) ? await Promise.all((await domain.posts.recent({ limit: 50 })).map(async (row) => {++ const owner = await creators.byId(row.creator_id);++ return {++ id: row.id, title: row.title, status: row.status, tiers: domain.posts.tiersOf(row),++ creator: owner ? (owner.display_name || owner.username || owner.subject) : 'a creator',++ };++ })) : [];send(res, 200, {title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,body: pages.dashboard({viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource, priceTable,rules: isNetwork ? [] : await Promise.all((await policies.list(creator.id)).map(policies.present)), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',embeds: !isNetwork && creator.username ? domain.cards.urls(creator) : null,++ posts: postRows.map((row) => ({++ id: row.id, title: row.title, tiers: domain.posts.tiersOf(row), status: row.status, published_at: row.published_at,++ image: row.image_id ? { id: row.image_id, url: row.image_url } : null, body_html: row.body_html,++ })),++ postTiers, moderation,}),});} catch (e) { next(e); }@@@ -342,9 +366,52 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {await policies.disable(rule.id);back(res, to, 'ok', 'Rule removed.');}, opts);++ // A post for the members of one or more of the creator's tiers (an optional image goes to Media).++ post(`${base}/posts`, async (req, res, to) => {++ const c = await dashCreator(req);++ const tiers = list(req.body.tiers).map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= 10);++ let image = null;++ if (req.file && req.file.buffer && req.file.buffer.length) {++ try {++ image = await domain.media.uploadImage({ buffer: req.file.buffer, mime: req.file.mimetype, filename: req.file.originalname, owner: c.subject });++ } catch (e) {++ throw new VipError(503, 'vip.image_failed', `The image could not be stored: ${e.detail || e.message}`);++ }++ }++ await domain.posts.create({ creatorId: c.id, title: req.body.title, body: req.body.body, tiers, image, actor: req.viewer.subject, traceparent: req.ov.traceparent });++ back(res, to, 'ok', 'Post published.');++ }, { ...opts, upload: uploadImage });++ post(`${base}/posts/:id/hide`, async (req, res, to) => {++ const row = await domain.posts.byId(req.params.id);++ await own(req, row);++ await domain.posts.hide(row.id, { actor: req.viewer.subject });++ back(res, to, 'ok', 'Post hidden. Nobody reads it now.');++ }, opts);++ post(`${base}/posts/:id/unhide`, async (req, res, to) => {++ const row = await domain.posts.byId(req.params.id);++ await own(req, row);++ await domain.posts.unhide(row.id, { actor: req.viewer.subject });++ back(res, to, 'ok', 'Post shown again.');++ }, opts);}}++ // Staff moderation: hide or show any creator's post (the way staff manage network plans).++ post('/dashboard/moderation/posts/:id/hide', async (req, res, to) => {++ if (!isStaff(req.viewer)) throw new VipError(403, 'vip.not_staff', 'only staff moderate posts');++ const row = await domain.posts.byId(req.params.id);++ if (!row) throw new VipError(404, 'vip.post_not_found', 'no such post');++ await domain.posts.hide(row.id, { actor: req.viewer.subject, reason: 'hidden by staff' });++ back(res, to, 'ok', 'Post hidden.');++ }, { backTo: '/dashboard' });++ post('/dashboard/moderation/posts/:id/unhide', async (req, res, to) => {++ if (!isStaff(req.viewer)) throw new VipError(403, 'vip.not_staff', 'only staff moderate posts');++ const row = await domain.posts.byId(req.params.id);++ if (!row) throw new VipError(404, 'vip.post_not_found', 'no such post');++ await domain.posts.unhide(row.id, { actor: req.viewer.subject, staff: true });++ back(res, to, 'ok', 'Post shown again.');++ }, { backTo: '/dashboard' });++// ── Creator pages ────────────────────────────────────────router.get('/:username', async (req, res, next) => {try {@@@ -356,11 +423,33 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {const isOwner = !!(req.viewer && req.viewer.subject === c.subject);const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;const name = c.display_name || c.username;++ // The posts: the title and the tier are public, the text only for a member of that tier.++ const posts = [];++ for (const row of await domain.posts.rows({ creatorId: c.id })) {++ const tiers = domain.posts.tiersOf(row);++ // The plan that opens this post: the cheapest tier that reads it and can be joined (a tier++ // above it reads it too). When no such plan can be joined, the post offers no way in — a++ // primary button must never lead to a dead end.++ const needed = tiers.length ? Math.min(...tiers) : null;++ const target = needed == null ? null : list.filter((p) => p.purchasable && p.tier && p.tier >= needed).sort((a, b) => a.tier - b.tier)[0];++ const access = isOwner++ ? { unlocked: true }++ : await domain.posts.access({++ post: row, memberSubject: req.viewer ? req.viewer.subject : null, creatorSubject: c.subject, creatorId: c.id,++ entitlement: member, traceparent: req.ov.traceparent,++ });++ posts.push({++ id: row.id, title: row.title, tiers, published_at: row.published_at,++ image: row.image_id ? { id: row.image_id, url: row.image_url } : null,++ unlocked: !!access.unlocked, body_html: access.unlocked ? row.body_html : null,++ join_url: target ? `/${encodeURIComponent(c.username)}#plan-${encodeURIComponent(target.slug)}` : null,++ });++ }send(res, 200, {title: `${name} memberships`, active: 'creator', canonicalPath: `/${c.username}`, viewer: req.viewer,description: c.bio || `Membership plans from ${name} on OpenVibe.`,robots: list.length ? 'index,follow' : 'noindex,follow',-- body: pages.creatorPage({ creator: creators.present(c), plans: list, viewer: req.viewer, member, rates: list.some((p) => p.purchasable) ? await rates() : null, providers: config.billing.providers, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query, isOwner }),++ body: pages.creatorPage({ creator: creators.present(c), plans: list, viewer: req.viewer, member, rates: list.some((p) => p.purchasable) ? await rates() : null, providers: config.billing.providers, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query, isOwner, posts }),});} catch (e) { next(e); }});@@@ -59,6 +59,8 @@ const envelope = (id, type, payload) => ({ event_id: id, event_type: type, sourcON CONFLICT (member_subject, creator_id) DO NOTHING`, [member.subject, creatorId, new Date().toISOString()]);assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [member.subject]), 1);assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', [member.subject]), 1);++ const post = await t.call('POST', '/api/v1/creators/' + creator.subject + '/posts', { user: creator, body: { title: 'Crew notes', body: 'The set list for Friday.', tiers: [1] } });++ assert.strictEqual(post.status, 201, JSON.stringify(post.json));});test('the member\'s export carries their memberships, entitlements and paid periods, and nobody else\'s', async () => {@@@ -94,6 +96,8 @@ const envelope = (id, type, payload) => ({ event_id: id, event_type: type, sourcconst row = await db.maybe('SELECT * FROM vip_creators WHERE id = $1', [creatorId]);assert.deepStrictEqual([row.status, row.username, row.display_name, row.bio], ['suspended', null, null, null]);assert.strictEqual(await count('SELECT count(*) FROM vip_plans WHERE creator_id = $1 AND created_by IS NULL', [creatorId]), 1);++ assert.strictEqual(await count('SELECT count(*) FROM vip_member_posts WHERE creator_id = $1 AND created_by IS NULL', [creatorId]), 1, 'the creator\'s posts lose their author');++ assert.ok(await count('SELECT count(*) FROM vip_member_posts WHERE creator_id = $1', [creatorId]) >= 1, 'the posts stay with the creator row');assert.ok(await count('SELECT count(*) FROM vip_plan_versions WHERE plan_id = $1', [plan.id]) >= 1, 'the terms members joined under stay');assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [other.subject]), 1);const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE2/confirmations');@@@ -83,6 +83,27 @@ async function boot(opts = {}) {const res = await fetch(base + p, { method, headers, body: bodyStr, redirect: 'manual' });return { status: res.status, location: res.headers.get('location'), text: await res.text() };}++ /**++ * An SSR request with a multipart form (fields and at most one file), the way the post composer++ * sends an image. file: { field, filename, contentType, data }.++ */++ async function pageMultipart(p, { user, fields = {}, file = null } = {}) {++ const boundary = '----vip' + Math.random().toString(16).slice(2);++ const parts = [];++ for (const [k, v] of Object.entries(fields)) {++ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));++ }++ if (file) {++ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${file.field}"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`));++ parts.push(Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data)));++ parts.push(Buffer.from('\r\n'));++ }++ parts.push(Buffer.from(`--${boundary}--\r\n`));++ const headers = { 'Content-Type': `multipart/form-data; boundary=${boundary}` };++ if (user) headers.Cookie = `ov_token=${network.signUser(user)}`;++ const res = await fetch(base + p, { method: 'POST', headers, body: Buffer.concat(parts), redirect: 'manual' });++ return { status: res.status, location: res.headers.get('location'), text: await res.text() };++ }const csrf = (html) => { const m = /name="_csrf" value="([^"]+)"/.exec(html); return m ? m[1] : null; };const outboxEvents = async (type) => (await domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => !type || e.event_type === type);@@@ -95,7 +116,7 @@ async function boot(opts = {}) {if (!opts.testdb) await testdb.close(); // a caller that handed its own database closes it}-- return { app, base, config, domain, network, billing, clock, logs, call, deliver, deliverAll, page, csrf, outboxEvents, close, testdb, EVENTS_SECRET };++ return { app, base, config, domain, network, billing, clock, logs, call, deliver, deliverAll, page, pageMultipart, csrf, outboxEvents, close, testdb, EVENTS_SECRET };}/** Tiny test harness: sequential async tests, a summary, exit code. */nnew file mode 100644@@@ -0,0 +1,322 @@++'use strict';++// Members-only posts on a creator page (owner's ask, plan 2026-10-22 §4 "Creator money: Tips, VIP"):++// a creator writes a post for the members of one or more of their tiers. The title is public; the text++// is served only after the entitlement check, and never to a signed-out visitor or a non-member.++const assert = require('assert');++const http = require('http');++const { boot, harness } = require('./helpers/app');++const { renderMarkdown } = require('../server/domain/markdown');++const pages = require('../server/web/pages');++const { createDomain } = require('../server/domain');++const { createPosts } = require('../server/domain/posts');++const { createMediaClient } = require('../server/media-client');++const { createWebRoutes } = require('../server/web/routes');++++const { test, run } = harness('members-only posts');++++/** A stand-in for OpenVibe.Media's Object API v2 (the image on a post). */++async function startMedia() {++ const objects = new Map();++ const server = http.createServer(async (req, res) => {++ const chunks = [];++ for await (const c of req) chunks.push(c);++ const raw = Buffer.concat(chunks);++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ const m = req.url.match(/^\/api\/v2\/[^/]+\/objects(?:\/([^/]+))?(?:\/(content|complete))?$/);++ if (!m) return json(404, { detail: 'not found' });++ const [, id, step] = m;++ if (!id) {++ const obj = { id: 'med_' + 'A'.repeat(26), size: raw.length, ready: false };++ objects.set(obj.id, obj);++ return json(201, { id: obj.id, status: 'pending' });++ }++ const obj = objects.get(id);++ if (!obj) return json(404, { detail: 'no such object' });++ if (step === 'content') { obj.size = raw.length; return json(200, { id, size_bytes: raw.length }); }++ if (step === 'complete') { obj.ready = true; return json(200, { id, status: 'ready' }); }++ return json(200, { id, status: obj.ready ? 'ready' : 'pending' });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, objects, close: () => new Promise((r) => server.close(r)) };++}++++/** A creator with a tier-1 and a tier-2 plan published. */++async function publishTiers(t, creator, tiers = [1, 2]) {++ for (const tier of tiers) {++ const r = await t.call('POST', '/api/v1/plans', {++ body: { creator: { type: 'user', id: creator.subject }, name: `Tier ${tier} plan`, publish: true, tier },++ cap: ['vip.plan.create'],++ });++ assert.strictEqual(r.status, 201, `plan tier ${tier}: ${JSON.stringify(r.json)}`);++ }++}++++/** A post written by the creator, through their own dashboard. */++async function writePost(t, creator, { title, body, tiers = ['1'] }) {++ const page = await t.page('/dashboard', { user: creator });++ const token = t.csrf(page.text);++ assert.ok(token, 'the dashboard has a form token');++ const res = await t.page('/dashboard/posts', { user: creator, method: 'POST', form: { title, body, tiers: tiers.join(','), _csrf: token } });++ assert.strictEqual(res.status, 303, `post ${title}: ${res.text.slice(0, 200)}`);++ return res;++}++++test('a non-member and a signed-out visitor see the title, the tier and a way to join — never the text', async () => {++ const t = await boot();++ try {++ const creator = t.network.newUser('lena');++ await publishTiers(t, creator, [1]);++ await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['1'] });++++ const out = await t.page('/lena');++ assert.strictEqual(out.status, 200);++ assert.ok(out.text.includes('Studio notes'), 'the title is public');++ assert.ok(out.text.includes('Tier 1'), 'the tier it needs is shown');++ assert.ok(out.text.includes('Become a member'), 'there is a way to join');++ assert.ok(!out.text.includes('60% patience'), 'the text is not on the page');++++ // The API never hands the text to a signed-out caller.++ const list = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts', { token: null });++ assert.strictEqual(list.status, 200);++ assert.strictEqual(list.json.posts.length, 1);++ assert.strictEqual(list.json.posts[0].title, 'Studio notes');++ assert.strictEqual(list.json.posts[0].body, undefined, 'the list carries no text');++ assert.strictEqual(list.json.posts[0].locked, true);++ const body = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + list.json.posts[0].id + '/body', { token: null });++ assert.strictEqual(body.status, 401, 'a signed-out caller gets nothing');++++ // A signed-in non-member neither.++ const stranger = t.network.newUser('nils');++ const strangerBody = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + list.json.posts[0].id + '/body', { user: stranger });++ assert.strictEqual(strangerBody.status, 403);++ assert.ok(!JSON.stringify(strangerBody.json).includes('60% patience'));++ } finally { await t.close(); }++});++++test('a member of the tier reads the text, on the page and through the API', async () => {++ const t = await boot();++ try {++ const creator = t.network.newUser('lena');++ const member = t.network.newUser('mika');++ await publishTiers(t, creator, [1]);++ await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['1'] });++++ const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });++ await t.deliverAll(paid.events);++++ const out = await t.page('/lena', { user: member });++ assert.ok(out.text.includes('Studio notes'));++ assert.ok(out.text.includes('60% patience'), 'a member reads the text');++++ const list = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts', { user: member });++ const post = list.json.posts[0];++ assert.strictEqual(post.locked, false);++ const body = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + post.id + '/body', { user: member });++ assert.strictEqual(body.status, 200);++ assert.ok(body.json.body.includes('60% patience'));++ assert.ok(body.json.body_html.includes('<p>'), 'the Markdown is rendered to safe HTML');++ } finally { await t.close(); }++});++++test('a member of another tier does not read the text', async () => {++ const t = await boot();++ try {++ const creator = t.network.newUser('lena');++ const member = t.network.newUser('mika');++ await publishTiers(t, creator, [1, 2]);++ await writePost(t, creator, { title: 'Backstage', body: 'Only for the second row.', tiers: ['2'] });++++ const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });++ await t.deliverAll(paid.events);++++ const out = await t.page('/lena', { user: member });++ assert.ok(out.text.includes('Backstage'), 'the title is public');++ assert.ok(!out.text.includes('Only for the second row.'), 'the text is not');++++ const list = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts', { user: member });++ const post = list.json.posts[0];++ assert.strictEqual(post.locked, true);++ const body = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + post.id + '/body', { user: member });++ assert.strictEqual(body.status, 403);++ } finally { await t.close(); }++});++++test('the creator reads their own post, on the page and on the dashboard', async () => {++ const t = await boot();++ try {++ const creator = t.network.newUser('lena');++ await publishTiers(t, creator, [1]);++ await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['1'] });++++ const page = await t.page('/lena', { user: creator });++ assert.ok(page.text.includes('60% patience'), 'the creator reads their own text');++ const dash = await t.page('/dashboard', { user: creator });++ assert.ok(dash.text.includes('Studio notes'), 'the dashboard lists the post');++ assert.ok(dash.text.includes('60% patience'), 'the dashboard shows the creator their own text');++ } finally { await t.close(); }++});++++test('staff hide a post: it is gone from the page and from the API, and the creator cannot bring it back', async () => {++ const t = await boot();++ try {++ const creator = t.network.newUser('lena');++ const member = t.network.newUser('mika');++ const staff = t.network.newUser('mod', { role: 'admin' });++ await publishTiers(t, creator, [1]);++ await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['1'] });++ const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });++ await t.deliverAll(paid.events);++++ const list = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts', { user: member });++ const id = list.json.posts[0].id;++++ const hidden = await t.call('POST', '/api/v1/posts/' + id + '/hide', { user: staff });++ assert.strictEqual(hidden.status, 200);++ assert.strictEqual(hidden.json.post.status, 'hidden');++++ const page = await t.page('/lena', { user: member });++ assert.ok(!page.text.includes('Studio notes'), 'a hidden post is not listed');++ assert.ok(!page.text.includes('60% patience'));++ const body = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + id + '/body', { user: member });++ assert.strictEqual(body.status, 404, 'a hidden post serves no text');++++ const dash = await t.page('/dashboard', { user: creator });++ assert.ok(dash.text.includes('Studio notes'), 'the creator still sees their post');++ assert.ok(dash.text.includes('hidden'), 'and sees that it is hidden');++++ // The creator may not undo a staff decision; staff may.++ const back = await t.call('POST', '/api/v1/posts/' + id + '/unhide', { user: creator });++ assert.strictEqual(back.status, 403);++ const again = await t.call('POST', '/api/v1/posts/' + id + '/unhide', { user: staff });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json.post.status, 'published');++ const shown = await t.page('/lena', { user: member });++ assert.ok(shown.text.includes('60% patience'), 'the post is readable again');++ } finally { await t.close(); }++});++++test('an image on a post goes to Media and is shown to the members of the tier', async () => {++ const media = await startMedia();++ const t = await boot({ env: { OV_MEDIA_INTERNAL_URL: media.url, OV_MEDIA_URL: 'https://media.test' } });++ try {++ const creator = t.network.newUser('lena');++ const member = t.network.newUser('mika');++ await publishTiers(t, creator, [1]);++ const dash = await t.page('/dashboard', { user: creator });++ const token = t.csrf(dash.text);++ const png = Buffer.from('89504e470d0a1a0a', 'hex');++ const res = await t.pageMultipart('/dashboard/posts', {++ user: creator,++ fields: { title: 'Cover art', body: 'The sleeve.', tiers: '1', _csrf: token },++ file: { field: 'image', filename: 'cover.png', contentType: 'image/png', data: png },++ });++ assert.strictEqual(res.status, 303, res.text.slice(0, 200));++ assert.strictEqual(media.objects.size, 1, 'the image was stored in Media');++ const object = [...media.objects.values()][0];++ assert.strictEqual(object.ready, true, 'Media has the whole object');++++ const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });++ await t.deliverAll(paid.events);++ const page = await t.page('/lena', { user: member });++ assert.ok(page.text.includes(`https://media.test/o/${object.id}`), 'the image is on the page');++ assert.ok(page.text.includes('The sleeve.'));++ } finally { await t.close(); await media.close(); }++});++++test('the Markdown a creator writes is rendered safely', async () => {++ const t = await boot();++ try {++ const creator = t.network.newUser('lena');++ await publishTiers(t, creator, [1]);++ await writePost(t, creator, { title: 'Markup', body: '**bold** <script>alert(1)</script>', tiers: ['1'] });++ const out = await t.page('/lena', { user: creator });++ assert.ok(out.text.includes('<strong>bold</strong>'), 'the marks are rendered');++ assert.ok(!out.text.includes('<script>alert(1)</script>'), 'no markup is carried through');++ } finally { await t.close(); }++});++++test('the renderer escapes everything but the marks, and the page shows a locked post without its text', async () => {++ // The Markdown renderer: only the marks survive, never markup.++ const html = renderMarkdown('## Heading\n\n**bold** and [link](https://a.test) and <script>alert(1)</script>\n\n- one\n- two');++ assert.ok(html.includes('<h4>Heading</h4>'), 'a heading is rendered one step down the page headings');++ assert.ok(html.includes('<strong>bold</strong>'));++ assert.ok(html.includes('<a href="https://a.test" rel="noopener noreferrer nofollow">link</a>'));++ assert.ok(!html.includes('<script>'), 'no markup is carried through');++ assert.ok(html.includes('<script>'), 'the markup is shown as text');++ assert.ok(renderMarkdown('[x](javascript:alert(1))').includes('javascript:alert(1)'), 'a javascript: link stays plain text');++ assert.ok(!renderMarkdown('[x](javascript:alert(1))').includes('<a '), 'and never becomes a link');++++ // The page: a locked post shows its title, its tier and the way to join — and no text.++ const locked = pages.postsSection([{ title: 'Studio notes', tiers: [1], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: false, body_html: null, join_url: '/lena#plan-front-row' }]);++ assert.ok(locked.includes('Studio notes'));++ assert.ok(locked.includes('Tier 1'));++ assert.ok(locked.includes('Become a member'));++ assert.ok(!locked.includes('post-body'), 'a locked post carries no text');++ const open = pages.postsSection([{ title: 'Studio notes', tiers: [1], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: true, body_html: '<p>The mix.</p>', join_url: '/lena' }]);++ assert.ok(open.includes('<p>The mix.</p>'), 'a member reads the text');++ // A locked post whose tier cannot be joined: the line, no button.++ const closed = pages.postsSection([{ title: 'Studio notes', tiers: [2], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: false, body_html: null, join_url: null }]);++ assert.ok(closed.includes('This post is for the members of Tier 2, which is not open to join yet.'), 'the line says the tier is not open');++ assert.ok(!closed.includes('Become a member'), 'no button that leads nowhere');++ assert.ok(closed.includes('aria-hidden="true"'), 'the lock glyph is decorative');++});++++test('a post is written for a tier the creator sells, and the domain refuses the others', async () => {++ const t = await boot();++ try {++ const creator = t.network.newUser('lena');++ await publishTiers(t, creator, [1]);++ const c = await t.domain.creators.bySubject(creator.subject);++ // The domain, built the way the app builds it.++ const domain = createDomain({ db: t.domain.db, config: t.config, outbox: t.domain.outbox, billing: t.domain.billing, now: t.domain.now, log: console, media: t.domain.media });++ assert.ok(domain.posts, 'the domain has posts');++ const posts = createPosts({ db: t.domain.db, now: t.domain.now, creators: t.domain.creators, plans: t.domain.plans, memberships: t.domain.memberships, entitlements: t.domain.entitlements });++ const row = await posts.create({ creatorId: c.id, title: 'Notes', body: 'Hello', tiers: [1], actor: creator.subject });++ assert.ok(row.id);++ assert.deepStrictEqual(posts.tiersOf(row), [1]);++ await assert.rejects(() => posts.create({ creatorId: c.id, title: 'Notes', body: 'Hello', tiers: [4], actor: creator.subject }), /tier/i);++ await assert.rejects(() => posts.create({ creatorId: c.id, title: '', body: 'Hello', tiers: [1], actor: creator.subject }), /title/i);++ // The routes are registered: the composer and the moderation.++ const walk = (stack, out = []) => {++ for (const l of stack || []) {++ if (l.route) out.push(Object.keys(l.route.methods)[0] + ' ' + l.route.path);++ else if (l.handle && l.handle.stack) walk(l.handle.stack, out);++ }++ return out;++ };++ const paths = walk(t.app._router.stack);++ assert.ok(paths.includes('post /dashboard/posts'), 'the composer is a route');++ assert.ok(paths.includes('post /dashboard/posts/:id/hide'));++ assert.ok(paths.includes('post /dashboard/moderation/posts/:id/hide'));++ // The Media client refuses without a service principal rather than losing the image quietly.++ const client = createMediaClient({ config: { ...t.config, oauth: { ...t.config.oauth, clientSecret: '' } } });++ assert.strictEqual(client.configured, false);++ await assert.rejects(() => client.uploadImage({ buffer: Buffer.from('x'), mime: 'image/png', filename: 'x.png' }), /service principal/);++ } finally { await t.close(); }++});++++test('a locked post offers a way to join only when a tier that reads it can be joined', async () => {++ const t = await boot();++ try {++ const creator = t.network.newUser('lena');++ const stranger = t.network.newUser('nils');++ // Tier 1 sells; tier 2 has no price, so it cannot be joined.++ await publishTiers(t, creator, [1, 2]);++ await writePost(t, creator, { title: 'Joinable', body: 'For the first row.', tiers: ['1'] });++ await writePost(t, creator, { title: 'Not joinable', body: 'For the second row.', tiers: ['2'] });++++ const out = await t.page('/lena', { user: stranger });++ // The tier-1 post: the button, and it points at the plan that opens it.++ assert.strictEqual((out.text.match(/Become a member/g) || []).length, 1, 'only the joinable post offers a way in');++ assert.ok(out.text.includes('/lena#plan-tier-1-plan'), 'the link points at the plan that reads it');++ // The tier-2 post: no button, and the line says why.++ assert.ok(out.text.includes('This post is for the members of Tier 2, which is not open to join yet.'));++ assert.ok(!out.text.includes('/lena#plan-tier-2-plan'), 'no link to a plan that cannot be joined');++ assert.ok(!out.text.includes('For the first row.'), 'no text of a locked post, either way');++ assert.ok(!out.text.includes('For the second row.'));++ // The lock glyph before the line, drawn by the shared glyph set and hidden from a screen reader.++ assert.ok(/aria-hidden="true"><rect x="5" y="11"/.test(out.text), 'the shared lock glyph is on the line');++ } finally { await t.close(); }++});++++run();
@@ -32,6 +32,11 @@ VIP_FORM_SECRET=
@@ -32,6 +32,11 @@ VIP_FORM_SECRET=
# ── OpenVibe.Billing (entitlement truth, checkout, cancel) ──
# ── OpenVibe.Billing (entitlement truth, checkout, cancel) ──
BILLING_URL=http://127.0.0.1:4600
BILLING_URL=http://127.0.0.1:4600
+
+# OpenVibe.Media: the image on a members-only post (the bytes live in Media, under the VIP_MEDIA_APP tenant).
+OV_MEDIA_URL=https://openvibe.media
+OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
+VIP_MEDIA_APP=vip
# BILLING_AUDIENCE=openvibe.billing
# BILLING_AUDIENCE=openvibe.billing
# VIP_BILLING_TIMEOUT_MS=5000
# VIP_BILLING_TIMEOUT_MS=5000
# Providers offered on plan pages (must be enabled on Billing); `credit` pays from Vibes credit.
# Providers offered on plan pages (must be enabled on Billing); `credit` pays from Vibes credit.
@@ -59,6 +59,7 @@ transactions, not an authority for anything.
@@ -59,6 +59,7 @@ transactions, not an authority for anything.
and its events `billing.entitlement.changed`, `billing.subscription.canceled`, `billing.transaction.reversed`.
and its events `billing.entitlement.changed`, `billing.subscription.canceled`, `billing.transaction.reversed`.
- **OpenVibe.Network** — RS256 service and user tokens (JWKS), OAuth sign-in for the pages.
- **OpenVibe.Network** — RS256 service and user tokens (JWKS), OAuth sign-in for the pages.
- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.
- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.
+- **OpenVibe.Media** — the image on a members-only post (`media.object.upload`, the `vip` tenant): the bytes live in Media, VIP keeps the `med_` reference. Without a service principal the post is written without an image.
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
- **openvibe-contracts** v0.76.0, **openvibe-sdk** v0.28.0 (outbox, inbox, delivery signatures, per-actor
- **openvibe-contracts** v0.76.0, **openvibe-sdk** v0.28.0 (outbox, inbox, delivery signatures, per-actor
limits), **openvibe-shared** v2.2.0 (Frame, legal pages, release manifest and update metrics, metrics,
limits), **openvibe-shared** v2.2.0 (Frame, legal pages, release manifest and update metrics, metrics,
@@ -188,6 +189,33 @@ The three Events subscriptions exist.
@@ -188,6 +189,33 @@ The three Events subscriptions exist.
- Cancel is the member's own action: VIP confirms with Billing that the subscription is theirs, then
- Cancel is the member's own action: VIP confirms with Billing that the subscription is theirs, then
Billing `POST /subscriptions/:id/cancel` (at period end).
Billing `POST /subscriptions/:id/cancel` (at period end).
+### Members-only posts (the creator's own posts)
+
+A creator writes posts for the members of one or more of their tiers (Billing's tiers of that creator,
+`vip_plans.billing_tier`). A post carries a title, a body in Markdown (rendered to safe HTML: the
+source can never carry markup) and an optional image (a Media object — the bytes live in
+OpenVibe.Media under VIP's tenant, the post keeps the `med_` reference).
+
+Who may read the text is never stored: it is answered on every request by the entitlement check
+(Billing's truth, projected) and the tier the member holds. The list of posts never carries a body, and
+the body is served only after that check. So:
+
+* a signed-out visitor and a non-member see the title, the tier it needs and a way to join — never the text;
+* a member of a tier reads a post written for their tier or for a lower one (a higher tier includes the lower);
+* a member of another tier reads nothing;
+* a hidden post is not served at all — not its title, not its text.
+
+A locked post offers a way in only when a tier that reads it can be joined: the "Become a member" button
+points at the cheapest such plan. When none can be joined (the plan has no price yet, or is not sold
+through Billing), the post says so — "This post is for the members of Tier 2, which is not open to join
+yet." — and offers no button, because a primary button must never lead to a dead end. The line carries
+the shared lock glyph.
+
+The creator reads their own posts (on their page and on the dashboard). Staff hide any creator's post,
+the way they manage network plans; a creator may hide their own, but cannot bring back a post staff
+hid. A post's author reference is anonymized when the creator's account is deleted (ADR-033), like
+their plans and perks.
+
### Events (openvibe-sdk outbox, same transaction as the change)
### Events (openvibe-sdk outbox, same transaction as the change)
| Event | When | Payload |
| Event | When | Payload |
@@ -227,6 +255,15 @@ handle or `network`.
@@ -227,6 +255,15 @@ handle or `network`.
| `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |
| `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |
| `POST /policies/evaluate` `{subject, resource, owner, rule_id?, mode?, fallback?}` — `owner` = the creator the PRODUCT says owns the resource (usr_ id or `network`); only that creator's rule applies, none → `owner_required`. `fallback` `{ requirement: 'member', binding?: 'product:binding' }` = the product's default gate, used only when the owner has no rule (below) | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement, fallback? }` — **fails closed** |
| `POST /policies/evaluate` `{subject, resource, owner, rule_id?, mode?, fallback?}` — `owner` = the creator the PRODUCT says owns the resource (usr_ id or `network`); only that creator's rule applies, none → `owner_required`. `fallback` `{ requirement: 'member', binding?: 'product:binding' }` = the product's default gate, used only when the owner has no rule (below) | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement, fallback? }` — **fails closed** |
| `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |
| `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |
+| `GET /creators/:ref/posts` | — (public) | owner sees the hidden posts too | the posts of a creator: title, tiers, image, date — never the text |
+| `GET /creators/:ref/posts/:id` | — (public) | owner / staff | one post; the text only for a member of its tier (or the creator, or staff) |
+| `GET /creators/:ref/posts/:id/body` | — | the member of the tier, the creator, staff | the text: 401 signed out, 403 not a member, 404 hidden |
+| `POST /creators/:ref/posts` | `vip.post.create` | the creator | write a post `{title, body, tiers}` for one or more of the creator's tiers |
+| `POST /posts/:id/hide`, `POST /posts/:id/unhide` | `vip.post.moderate` | the creator, staff | hide a post (nobody reads it any more), show it again |
+
+The `vip.post.*` ids are not in openvibe-contracts yet, so no service can hold them: people (the
+creator, staff) write and moderate posts without a capability. A service that wants to needs a
+contracts entry first.
Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,
Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,
`invalid_fallback`, `creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`,
`invalid_fallback`, `creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`,
@@ -328,9 +365,11 @@ memberships* card gives the creator the badge and widget HTML and the card URL.
@@ -328,9 +365,11 @@ memberships* card gives the creator the badge and widget HTML and the card URL.
## Pages (server-rendered, useful without JavaScript, OpenVibe Frame)
## Pages (server-rendered, useful without JavaScript, OpenVibe Frame)
`/` directory of creators with published plans · `/:username` a creator's plans (terms, perks,
`/` directory of creators with published plans · `/:username` a creator's plans (terms, perks,
-Billing's price and period from `GET /api/v1/rates`, join form) · `/:username/plans/:slug` public terms
+Billing's price and period from `GET /api/v1/rates`, join form) and their members-only posts (the
+title and the tier are public, the text only for a member of that tier) · `/:username/plans/:slug` public terms
history · `/me` your memberships, the version you joined under, cancel, badge preference ·
history · `/me` your memberships, the version you joined under, cancel, badge preference ·
-`/dashboard` plans, versions, perks and bindings, members, members-only resources, the badge and
+`/dashboard` plans, versions, perks and bindings, members, members-only resources, the members-only
+posts (write, hide) and, for staff, the moderation list, the badge and
widget to share and the member-count switch (`?as=network` for staff) · `/embed/:username/…` (above) · `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` · `/terms`, `/privacy`, `/dmca`
widget to share and the member-count switch (`?as=network` for staff) · `/embed/:username/…` (above) · `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` · `/terms`, `/privacy`, `/dmca`
(openvibe-shared legal). Navbar from `https://openvibe.network/shared/navbar.js`, SSR footer and
(openvibe-shared legal). Navbar from `https://openvibe.network/shared/navbar.js`, SSR footer and
`<noscript>` navigation from openvibe-shared. Forms carry HMAC anti-forgery tokens
`<noscript>` navigation from openvibe-shared. Forms carry HMAC anti-forgery tokens
@@ -426,6 +465,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -426,6 +465,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
| a creator's membership card, badge and widget are public-data only, the same for everyone, embeddable and inert | `test/embeds.test.js` (published plans only, no subjects or binding configs, no cookie read or set, CORS, SVG escaping and sandbox, the widget's frame-ancestors and hashed style, the creator's member-count switch, Billing outage keeps the last count or shows none) |
| a creator's membership card, badge and widget are public-data only, the same for everyone, embeddable and inert | `test/embeds.test.js` (published plans only, no subjects or binding configs, no cookie read or set, CORS, SVG escaping and sandbox, the widget's frame-ancestors and hashed style, the creator's member-count switch, Billing outage keeps the last count or shows none) |
| `/llms-full.txt` carries the full text of the home, creator and plan pages, and is cached for an hour | `test/llms-full.test.js`, `test/api.test.js` (discovery artifacts) |
| `/llms-full.txt` carries the full text of the home, creator and plan pages, and is cached for an hour | `test/llms-full.test.js`, `test/api.test.js` (discovery artifacts) |
| IndexNow is off without `INDEXNOW_KEY` (no key route, nothing sent); with one the key file is served at `/<key>.txt` as `text/plain` and publishing, editing and archiving a plan ping the creator page, the terms page and the sitemap; a draft and an unchanged edit never ping | `test/indexnow.test.js` |
| IndexNow is off without `INDEXNOW_KEY` (no key route, nothing sent); with one the key file is served at `/<key>.txt` as `text/plain` and publishing, editing and archiving a plan ping the creator page, the terms page and the sitemap; a draft and an unchanged edit never ping | `test/indexnow.test.js` |
+| members-only posts: a member of the tier reads the text, a non-member and a signed-out visitor do not (page and API), the creator reads their own, staff hide any post, and the Markdown never carries markup | `test/member-posts.test.js` (the tier rules, the API, the moderation, the Media image, the renderer) |
Not yet demonstrated (needs the other services): convergence **across consuming products** end to end
Not yet demonstrated (needs the other services): convergence **across consuming products** end to end
(Chat, Community, Blog and Wiki call VIP in code; Live does not), and a real Billing/Events round trip
(Chat, Community, Blog and Wiki call VIP in code; Live does not), and a real Billing/Events round trip
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+-- phase: expand
+-- Members-only posts (owner's ask, plan 2026-10-22 §4 "Creator money: Tips, VIP": members-only posts on
+-- creator pages). A creator writes a post for the members of one or more of their tiers: the tiers are
+-- Billing's tiers of that creator (vip_plans.billing_tier), the title and body are the creator's, and an
+-- optional image is a Media object (med_…, the bytes live in OpenVibe.Media).
+--
+-- The body is never read with the list of posts: it is served only after the entitlement check
+-- (server/domain/posts.js), so a signed-out visitor and a non-member never receive it.
+CREATE TABLE IF NOT EXISTS vip_member_posts (
+ id text COLLATE "C" PRIMARY KEY,
+ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),
+ title text COLLATE "C" NOT NULL,
+ body text COLLATE "C" NOT NULL,
+ body_html text COLLATE "C" NOT NULL,
+ tiers text COLLATE "C" NOT NULL DEFAULT '[]',
+ image_id text COLLATE "C",
+ image_url text COLLATE "C",
+ status text COLLATE "C" NOT NULL DEFAULT 'published' CHECK (status IN ('published', 'hidden')),
+ created_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ published_at text COLLATE "C",
+ hidden_at text COLLATE "C",
+ hidden_by text COLLATE "C",
+ hide_reason text COLLATE "C"
+);
+CREATE INDEX IF NOT EXISTS idx_vip_member_posts_creator ON vip_member_posts (creator_id, published_at DESC);
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
+ "multer": "^2.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",
@@ -91,6 +92,12 @@
@@ -91,6 +92,12 @@
}
}
}
}
},
},
+ "node_modules/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+ "license": "MIT"
+ },
"node_modules/array-flatten": {
"node_modules/array-flatten": {
"version": "1.1.1",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -140,6 +147,17 @@
@@ -140,6 +147,17 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause"
},
},
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
"node_modules/bytes": {
"node_modules/bytes": {
"version": "3.1.2",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1068,6 +1086,24 @@
@@ -1068,6 +1086,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/multer": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",
+ "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",
+ "license": "MIT",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.6.0",
+ "type-is": "^1.6.18"
+ },
+ "engines": {
+ "node": ">= 10.16.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/negotiator": {
"node_modules/negotiator": {
"version": "0.6.3",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1589,6 +1625,14 @@
@@ -1589,6 +1625,14 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/toidentifier": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -30,6 +30,7 @@
@@ -30,6 +30,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
+ "multer": "^2.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.112.1",
@@ -27,6 +27,9 @@ const CAP = {
@@ -27,6 +27,9 @@ const CAP = {
policySet: 'vip.resource.policy.set',
policySet: 'vip.resource.policy.set',
policyEvaluate: 'vip.resource.policy.evaluate',
policyEvaluate: 'vip.resource.policy.evaluate',
members: 'vip.creator.members.list',
members: 'vip.creator.members.list',
+ postCreate: 'vip.post.create',
+ postList: 'vip.post.list',
+ postModerate: 'vip.post.moderate',
};
};
function v1Router({ domain, apiAuth, limits }) {
function v1Router({ domain, apiAuth, limits }) {
@@ -381,6 +384,85 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -381,6 +384,85 @@ function v1Router({ domain, apiAuth, limits }) {
}
}
}));
}));
+ // ── Members-only posts ───────────────────────────────────
+ // The text of a post is served only after the entitlement check (Billing's truth): the list never
+ // carries it, and /posts/:id/body answers the member of the tier the post is written for.
+ const postCreator = async (req) => {
+ const c = await findCreator(req.params.ref);
+ if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');
+ return c;
+ };
+ const postRow = async (req, c) => {
+ const row = await domain.posts.byId(req.params.id);
+ if (!row || row.creator_id !== c.id) fail(404, 'vip.post_not_found', 'no such post');
+ return row;
+ };
+ /** May this caller read the text of this post? The creator and staff may; a member of the tier may. */
+ const postAccess = async (req, c, row) => {
+ const p = req.principal;
+ if (p.kind === 'user' && (p.subject === c.subject || isStaff(p))) return { unlocked: true, tier: null };
+ if (p.kind !== 'user') return { unlocked: false, reason: 'not_a_member', tier: null };
+ return await domain.posts.access({ post: row, memberSubject: p.subject, creatorSubject: c.subject, creatorId: c.id, traceparent: trace(req) });
+ };
+ r.get('/creators/:ref/posts', wrap(async (req, res) => {
+ const c = await postCreator(req);
+ const own = mayManage(req.principal, c, CAP.postList);
+ const posts = [];
+ for (const row of await domain.posts.rows({ creatorId: c.id, includeHidden: own })) {
+ const access = await postAccess(req, c, row);
+ posts.push(domain.posts.present(row, { unlocked: !!access.unlocked, tier: access.tier || null }));
+ }
+ res.json({ creator: creators.present(c), posts });
+ }));
+ r.get('/creators/:ref/posts/:id', wrap(async (req, res) => {
+ const c = await postCreator(req);
+ const row = await postRow(req, c);
+ const own = mayManage(req.principal, c, CAP.postList);
+ if (row.status === 'hidden' && !own) fail(404, 'vip.post_not_found', 'no such post');
+ const access = await postAccess(req, c, row);
+ res.json({ post: domain.posts.present(row, { unlocked: !!access.unlocked, tier: access.tier || null }) });
+ }));
+ // The text itself: only a member of the tier the post is written for (or the creator, or staff).
+ r.get('/creators/:ref/posts/:id/body', wrap(async (req, res) => {
+ const c = await postCreator(req);
+ const row = await postRow(req, c);
+ needAuth(req.principal);
+ const staff = req.principal.kind === 'user' && isStaff(req.principal);
+ if (row.status === 'hidden' && !(staff || req.principal.subject === c.subject)) fail(404, 'vip.post_not_found', 'no such post');
+ const access = await postAccess(req, c, row);
+ if (!access.unlocked) fail(403, 'vip.member_only', 'this post is for the members of another tier');
+ res.json({ post_id: row.id, tier: access.tier || null, body: row.body, body_html: row.body_html });
+ }));
+ r.post('/creators/:ref/posts', create, wrap(async (req, res) => {
+ const b = req.body || {};
+ const c = await postCreator(req);
+ requireManage(req, c, CAP.postCreate);
+ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ let image = null;
+ if (b.image && b.image.id) {
+ const url = b.image.url ? String(b.image.url) : null;
+ if (url && !url.startsWith(domain.config.media.url)) fail(422, 'vip.invalid_input', 'the image must be an object stored in OpenVibe.Media');
+ image = { id: String(b.image.id), url };
+ }
+ const row = await domain.posts.create({ creatorId: c.id, title: b.title, body: b.body, tiers: b.tiers, image, actor, traceparent: trace(req) });
+ res.status(201).json({ post: domain.posts.present(row, { unlocked: true }) });
+ }));
+ // Moderation (the way staff hide a post): the creator hides their own, staff hides anyone's.
+ r.post('/posts/:id/hide', create, wrap(async (req, res) => {
+ const row = await domain.posts.byId(req.params.id);
+ if (!row) fail(404, 'vip.post_not_found', 'no such post');
+ requireManage(req, await creators.byId(row.creator_id), CAP.postModerate);
+ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ res.json({ post: domain.posts.present(await domain.posts.hide(row.id, { actor, reason: req.body && req.body.reason })) });
+ }));
+ r.post('/posts/:id/unhide', create, wrap(async (req, res) => {
+ const row = await domain.posts.byId(req.params.id);
+ if (!row) fail(404, 'vip.post_not_found', 'no such post');
+ requireManage(req, await creators.byId(row.creator_id), CAP.postModerate);
+ const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
+ res.json({ post: domain.posts.present(await domain.posts.unhide(row.id, { actor, staff: isStaff(req.principal) })) });
+ }));
+
return r;
return r;
}
}
@@ -25,6 +25,7 @@ const { loadConfig } = require('./config');
@@ -25,6 +25,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 { createMediaClient } = require('./media-client');
const { createVipOutbox } = require('./events/outbox');
const { createVipOutbox } = require('./events/outbox');
const { consumerRouter } = require('./events/consumer');
const { consumerRouter } = require('./events/consumer');
const accountDataLib = require('./domain/account-data');
const accountDataLib = require('./domain/account-data');
@@ -60,7 +61,9 @@ async function createApp(opts = {}) {
@@ -60,7 +61,9 @@ async function createApp(opts = {}) {
const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({
const indexnow = opts.indexnow !== undefined ? opts.indexnow : createIndexNow({
host: config.baseUrl, key: config.indexnow.key, fetch: fetchImpl, log,
host: config.baseUrl, key: config.indexnow.key, fetch: fetchImpl, log,
});
});
- const domain = createDomain({ db, config, outbox, billing, now, log, indexnow });
+ // OpenVibe.Media (the image on a members-only post). Tests inject a stand-in through opts.media.
+ const media = opts.media || createMediaClient({ config, fetchImpl });
+ const domain = createDomain({ db, config, outbox, billing, now, log, indexnow, media });
const apiAuth = createApiAuth({ config, keys, userAuth });
const apiAuth = createApiAuth({ config, keys, userAuth });
const release = require('openvibe-shared/release').createRelease({ service: 'vip', root: path.join(__dirname, '..') });
const release = require('openvibe-shared/release').createRelease({ service: 'vip', root: path.join(__dirname, '..') });
setRelease(release.release);
setRelease(release.release);
@@ -75,6 +75,14 @@ function loadConfig(env = process.env) {
@@ -75,6 +75,14 @@ function loadConfig(env = process.env) {
graceMs: int(env.VIP_PROJECTION_GRACE_MS, 60 * 1000),
graceMs: int(env.VIP_PROJECTION_GRACE_MS, 60 * 1000),
},
},
+ // OpenVibe.Media: the image on a members-only post. The bytes live in Media under VIP's tenant
+ // (the namespace VIP's service token holds media.object.upload for); VIP keeps the med_ reference.
+ media: {
+ url: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),
+ internalUrl: trim(env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
+ app: String(env.VIP_MEDIA_APP || 'vip').trim() || 'vip',
+ },
+
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),
@@ -29,6 +29,9 @@ const TABLES = [
@@ -29,6 +29,9 @@ const TABLES = [
{ table: 'vip_plan_versions', subject: 'created_by', file: 'plan-versions.json', columns: ['id', 'plan_id', 'version', 'name', 'description', 'benefits', 'terms', 'change_note', 'created_at', 'published_at'], erase: { keep: 'plan versions are the immutable terms members joined under' } },
{ table: 'vip_plan_versions', subject: 'created_by', file: 'plan-versions.json', columns: ['id', 'plan_id', 'version', 'name', 'description', 'benefits', 'terms', 'change_note', 'created_at', 'published_at'], erase: { keep: 'plan versions are the immutable terms members joined under' } },
{ table: 'vip_perks', subject: 'created_by', file: 'perks.json', columns: ['id', 'creator_id', 'key', 'name', 'description', 'kind', 'status', 'created_at'], erase: anonymize },
{ table: 'vip_perks', subject: 'created_by', file: 'perks.json', columns: ['id', 'creator_id', 'key', 'name', 'description', 'kind', 'status', 'created_at'], erase: anonymize },
{ table: 'vip_gated_resource_rules', subject: 'created_by', file: null, erase: anonymize },
{ table: 'vip_gated_resource_rules', subject: 'created_by', file: null, erase: anonymize },
+ // The posts a creator wrote: theirs to take away with them (the author reference goes), but the posts
+ // themselves stay with the creator row, which is suspended and no longer served.
+ { table: 'vip_member_posts', subject: 'created_by', file: 'posts.json', columns: ['id', 'creator_id', 'title', 'body', 'tiers', 'status', 'image_id', 'published_at', 'created_at'], order: 'created_at', erase: anonymize },
];
];
/** The creator row stays (it is referenced; counted as retained above) but is suspended and loses what names the person. */
/** The creator row stays (it is referenced; counted as retained above) but is suspended and loses what names the person. */
@@ -11,8 +11,9 @@ const { createPolicies } = require('./policies');
@@ -11,8 +11,9 @@ const { createPolicies } = require('./policies');
const { createCheckout } = require('./checkout');
const { createCheckout } = require('./checkout');
const { createCards } = require('./cards');
const { createCards } = require('./cards');
const { createPrices } = require('./prices');
const { createPrices } = require('./prices');
+const { createPosts } = require('./posts');
-function createDomain({ db, config, outbox, billing, now = () => Date.now(), log = console, indexnow = null }) {
+function createDomain({ db, config, outbox, billing, now = () => Date.now(), log = console, indexnow = null, media = null }) {
const creators = createCreators({ db, now });
const creators = createCreators({ db, now });
const perks = createPerks({ db, now });
const perks = createPerks({ db, now });
const plans = createPlans({ db, now, outbox, creators, perks, baseUrl: config.baseUrl, indexnow });
const plans = createPlans({ db, now, outbox, creators, perks, baseUrl: config.baseUrl, indexnow });
@@ -23,8 +24,10 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), log
@@ -23,8 +24,10 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), log
const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements, periods });
const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements, periods });
const prices = createPrices({ config, billing, now });
const prices = createPrices({ config, billing, now });
const cards = createCards({ db, config, creators, plans, perks, billing, prices, now, log });
const cards = createCards({ db, config, creators, plans, perks, billing, prices, now, log });
+ // The posts a creator writes for the members of their tiers (readers are decided by the entitlement check).
+ const posts = createPosts({ db, now, creators, plans, memberships, entitlements });
const tx = async (fn) => await db.tx(fn);
const tx = async (fn) => await db.tx(fn);
- return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, periods, entitlements, policies, checkout, cards, prices };
+ return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, periods, entitlements, policies, checkout, cards, prices, posts, media };
}
}
module.exports = { createDomain };
module.exports = { createDomain };
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 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+(.*)$/.exec(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,143 @@
@@ -0,0 +1,143 @@
+'use strict';
+
+/**
+ * vip_member_posts: the posts a creator writes for the members of their tiers.
+ *
+ * A post names the tiers it is for (Billing's tiers of that creator, vip_plans.billing_tier) and carries
+ * the title, the Markdown body with its safe HTML, and an optional image (a Media object). Who may read
+ * it is never stored: it is answered by the entitlement check (Billing's truth, projected by
+ * server/domain/entitlements.js) 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 higher tier includes the lower);
+ * - 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 { fail, prefixedId, iso, json, text } = require('../util');
+const { renderMarkdown } = require('./markdown');
+
+const MAX_TIERS = 10;
+
+function createPosts({ db, now, creators, plans, memberships, entitlements }) {
+ const byId = async (id) => (id ? await db.prepare('SELECT * FROM vip_member_posts WHERE id = ?').get(id) || null : null);
+ const rows = async ({ creatorId, includeHidden = false }) => {
+ const st = includeHidden ? ['published', 'hidden'] : ['published'];
+ const marks = st.map(() => '?').join(',');
+ return await db.prepare('SELECT * FROM vip_member_posts WHERE creator_id = ? AND status IN (' + marks +
+ ') ORDER BY published_at DESC, created_at DESC').all(creatorId, ...st);
+ };
+ const recent = async ({ limit = 50 } = {}) =>
+ await db.prepare('SELECT * FROM vip_member_posts ORDER BY created_at DESC LIMIT ?').all(limit);
+
+ /** The tiers this creator sells (their published plans' Billing tiers). */
+ const offeredTiers = async (creatorId) => (await db.prepare(
+ "SELECT DISTINCT billing_tier FROM vip_plans WHERE creator_id = ? AND status = 'published' AND billing_kind IS NOT NULL ORDER BY billing_tier"
+ ).all(creatorId)).map((r) => Number(r.billing_tier));
+
+ /** The tiers of one post, as numbers. */
+ const tiersOf = (post) => {
+ const list = json(post && post.tiers, []);
+ return Array.isArray(list) ? list.map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= MAX_TIERS) : [];
+ };
+
+ /**
+ * The tier a member holds: the plan their membership was bought under (VIP's record of the terms
+ * they joined); a subscription made elsewhere (Billing sells those at tier 1) is tier 1.
+ */
+ async function memberTier(memberSubject, creatorId) {
+ const m = await memberships.get(memberSubject, creatorId);
+ if (!m || !m.plan_id) return 1;
+ const plan = await plans.byId(m.plan_id);
+ if (!plan || !plan.billing_kind) return 1;
+ return Number(plan.billing_tier) || 1;
+ }
+
+ /**
+ * May this reader read this post? → { unlocked, reason, tier }.
+ * The entitlement is Billing's (projected, then confirmed when stale); anything VIP cannot confirm is
+ * refused, so a membership that ended never opens a post.
+ */
+ async function access({ post, memberSubject, creatorSubject, creatorId, mode = 'auto', traceparent, entitlement = null }) {
+ const tiers = tiersOf(post);
+ if (!memberSubject) return { unlocked: false, reason: 'signed_out', tier: null };
+ // The entitlement may be the one the page already has (Billing's truth, projected); otherwise it is asked for.
+ const ent = entitlement || await entitlements.check(memberSubject, creatorSubject, { mode, traceparent });
+ if (!ent || ent.status !== 'active') return { unlocked: false, reason: 'not_a_member', tier: null, entitlement: ent };
+ const tier = await memberTier(memberSubject, creatorId);
+ 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 tiers = tiersOf(post);
+ const shown = {
+ id: post.id, creator_id: post.creator_id, title: post.title, tiers, status: post.status,
+ image: post.image_id ? { id: post.image_id, url: post.image_url } : null,
+ 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;
+ }
+
+ /**
+ * Write a post for the members of one or more of the creator's tiers.
+ * tiers: the numbers to write for; they must be tiers the creator sells, so every member of a tier can
+ * join it and a "Become a member" link always leads somewhere.
+ */
+ async function create({ creatorId, title, body, tiers, image = null, actor = null, traceparent }) {
+ const creator = await creators.byId(creatorId);
+ if (!creator) fail(404, 'vip.creator_not_found', 'no creator ' + creatorId);
+ if (creator.kind !== 'creator') fail(422, 'vip.invalid_input', 'members-only posts belong to a creator');
+ const cleanTitle = text(title, 'title', 140, { required: true });
+ const cleanBody = text(body, 'body', 20000, { required: true });
+ 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, 'vip.invalid_input', 'choose at least one tier for the post');
+ const offered = await offeredTiers(creatorId);
+ if (!offered.length) fail(422, 'vip.post_no_tiers', 'publish a plan first: a post is written for the members of a tier');
+ const unknown = numbers.filter((t) => !offered.includes(t));
+ if (unknown.length) fail(422, 'vip.post_unknown_tier', 'you do not sell tier ' + unknown.join(', '));
+ const at = iso(now());
+ const id = prefixedId('vmp', now());
+ await db.prepare('INSERT INTO vip_member_posts (id, creator_id, title, body, body_html, tiers, image_id, image_url, ' +
+ "status, created_by, created_at, updated_at, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?)").run(
+ id, creatorId, cleanTitle, cleanBody, renderMarkdown(cleanBody), JSON.stringify(numbers),
+ image && image.id ? image.id : null, image && image.url ? image.url : null, actor, at, at, at);
+ return await byId(id);
+ }
+
+ /** Hide a post (the creator's own call, or a staff moderation): 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, 'vip.post_not_found', 'no post ' + id);
+ if (post.status === 'hidden') return post;
+ const at = iso(now());
+ await db.prepare("UPDATE vip_member_posts SET status = 'hidden', hidden_at = ?, hidden_by = ?, hide_reason = ?, updated_at = ? WHERE id = ?")
+ .run(at, actor, reason ? String(reason).slice(0, 300) : null, at, id);
+ return await byId(id);
+ }
+
+ /** Show a hidden post again: the staff may always, a creator only the posts they hid themselves. */
+ async function unhide(id, { actor = null, staff = false } = {}) {
+ const post = await byId(id);
+ if (!post) fail(404, 'vip.post_not_found', 'no post ' + id);
+ if (post.status !== 'hidden') return post;
+ if (!staff && post.hidden_by && actor && post.hidden_by !== actor) {
+ fail(403, 'vip.not_yours', 'a post hidden by staff stays hidden');
+ }
+ const at = iso(now());
+ await db.prepare("UPDATE vip_member_posts SET status = 'published', hidden_at = NULL, hidden_by = NULL, hide_reason = NULL, updated_at = ? WHERE id = ?")
+ .run(at, id);
+ return await byId(id);
+ }
+
+ return { byId, rows, recent, create, hide, unhide, present, access, memberTier, offeredTiers, tiersOf };
+}
+
+module.exports = { createPosts };
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+
+/**
+ * OpenVibe.Media Object API v2 client — the image on a members-only post. The bytes live in Media under
+ * VIP's tenant (the namespace VIP's service token holds media.object.upload for), owned by the creator who
+ * wrote the post (X-OV-Subject); VIP keeps the med_ reference and the public URL.
+ *
+ * POST /api/v2/<app>/objects init { kind, visibility, size_bytes, mime_type, filename, content_hash }
+ * PUT /api/v2/<app>/objects/:id/content the bytes
+ * POST /api/v2/<app>/objects/:id/complete Media checks size, hash and type → ready
+ *
+ * Public bytes are served at <media>/o/:id. The tenant (config.media.app, "vip" by default) must exist in
+ * Media and VIP's service principal must hold media.object.upload for it.
+ */
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+
+function createMediaClient({ config, fetchImpl = globalThis.fetch } = {}) {
+ const configured = !!(config.oauth && config.oauth.clientSecret);
+ const tokens = configured ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.media',
+ scope: 'media.object.upload',
+ fetchImpl,
+ }) : null;
+ const base = `${config.media.internalUrl}/api/v2/${config.media.app}/objects`;
+
+ async function call(method, path, { json, body, headers = {} } = {}, retried = false) {
+ const h = { Accept: 'application/json', ...headers, ...(await tokens.authHeaders()) };
+ if (json !== undefined) h['Content-Type'] = 'application/json';
+ const res = await fetchImpl(`${base}${path}`, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : body, signal: AbortSignal.timeout(60_000) });
+ if (res.status === 401 && !retried) { tokens.invalidate(); return await call(method, path, { json, body, headers }, true); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok) {
+ const err = new Error(`Media ${method} ${path || '/'} ${res.status}: ${(data && (data.detail || data.error)) || 'failed'}`);
+ err.status = res.status; err.code = data && data.code;
+ throw err;
+ }
+ return data || {};
+ }
+
+ /**
+ * Store one image. → { id (med_…), url, size_bytes, mime }
+ * owner: the person (usr_…) the object belongs to; kind: 'post_image'; visibility 'unlisted': served to
+ * anyone with the link (the object id is unguessable), never listed on openvibe.media — the post decides
+ * who sees the link.
+ */
+ async function uploadImage({ buffer, mime, filename, owner = null, kind = 'post_image', visibility = 'unlisted', source = 'vip.member_post' }) {
+ if (!configured) { const e = new Error("An image needs VIP's service principal (OV_OAUTH_CLIENT_SECRET)"); e.status = 503; throw e; }
+ const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
+ const init = await call('POST', '', {
+ json: { kind, visibility, size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256, metadata: { source } },
+ headers: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner || '')) ? { 'X-OV-Subject': owner } : {},
+ });
+ const id = init.id || (init.object && init.object.id);
+ if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) throw Object.assign(new Error('Media returned no object id'), { status: 502 });
+ await call('PUT', `/${id}/content`, { body: buffer, headers: { 'Content-Type': mime } });
+ await call('POST', `/${id}/complete`, { json: { content_hash: sha256 } });
+ return { id, url: `${config.media.url}/o/${id}`, size_bytes: buffer.length, mime };
+ }
+
+ return { configured, uploadImage };
+}
+
+module.exports = { createMediaClient };
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* no begging copy.
* no begging copy.
*/
*/
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const glyphs = require('openvibe-shared/glyphs');
const { esc } = require('../util');
const { esc } = require('../util');
const fmtDate = (s) => {
const fmtDate = (s) => {
@@ -68,6 +69,10 @@ function homeFaqJsonLd() {
@@ -68,6 +69,10 @@ function homeFaqJsonLd() {
}
}
const S = require('./short');
const S = require('./short');
+// 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>';
const avatar = (networkUrl, username, size) => `<img class="vip-avatar" src="${esc(networkUrl)}/avatar/${encodeURIComponent(username)}?s=${size}" width="${size}" height="${size}" alt="" loading="lazy" decoding="async">`;
const avatar = (networkUrl, username, size) => `<img class="vip-avatar" src="${esc(networkUrl)}/avatar/${encodeURIComponent(username)}?s=${size}" width="${size}" height="${size}" alt="" loading="lazy" decoding="async">`;
const plural = (n, one, many) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
const plural = (n, one, many) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
@@ -165,8 +170,61 @@ if (e.active) unlockPro(); // expired, unknown or unreachable all answer no`,
@@ -165,8 +170,61 @@ if (e.active) unlockPro(); // expired, unknown or unreachable all answer no`,
return S.foldSection(body, 'id="questions"', 'questions-fold');
return S.foldSection(body, 'id="questions"', 'questions-fold');
}
}
+/**
+ * The creator page's members-only posts. The title is public; the text is only ever rendered for a
+ * member of a tier the post is written for (the route decides that, after the entitlement check).
+ */
+function postsSection(posts) {
+ if (!posts.length) return '';
+ const items = posts.map((p) => {
+ const tiers = p.tiers.length ? p.tiers.map((t) => `Tier ${t}`).join(', ') : 'a tier';
+ const meta = `<p class="muted small">${esc(tiers)}${p.published_at ? ` · ${fmtDate(p.published_at)}` : ''}</p>`;
+ const image = p.image && p.image.url ? `<p><img src="${esc(p.image.url)}" alt="" style="max-width:100%;height:auto"></p>` : '';
+ // A locked post: the line, and a way in only when a tier that reads it can be joined. A button
+ // that leads nowhere is worse than no button.
+ const body = p.unlocked
+ ? `<div class="post-body">${p.body_html}</div>`
+ : `<p class="muted">${lockGlyph}This post is for the members of ${esc(tiers)}${p.join_url ? '.' : ', which is not open to join yet.'}</p>`
+ + (p.join_url ? `<p><a class="button" href="${esc(p.join_url)}">Become a member</a></p>` : '');
+ return `<article class="card post"><h3>${esc(p.title)}</h3>${meta}${image}${body}</article>`;
+ }).join('');
+ return `<section class="posts" aria-labelledby="posts-h"><h2 id="posts-h">Members-only posts</h2>
+<p class="muted small">Posts for the members of a tier. The title is public; the text is not.</p>
+<div class="post-list">${items}</div></section>`;
+}
+
+/**
+ * The dashboard's posts: the composer, the creator's own posts (with the text they wrote) and, for
+ * staff, the moderation list over every creator's posts.
+ */
+function postsAdmin({ posts, tiers, moderation, token, base }) {
+ const composer = tiers.length ? `<details class="card"><summary>New post</summary>
+<form method="post" action="${base}/posts" enctype="multipart/form-data">${csrf(token)}
+<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 class="check"><input type="checkbox" name="tiers" value="${t}"> Tier ${t}</label>`).join('')}</fieldset>
+<label>Image (optional) <input type="file" name="image" accept="image/png,image/jpeg,image/webp,image/gif"></label>
+<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 type="submit">Publish post</button></form></details>`
+ : '<p class="muted">Publish a plan 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(', '))} · ${fmtDate(p.published_at)} · <span class="pill">${esc(p.status)}</span></p>
+${p.image && p.image.url ? `<p><img src="${esc(p.image.url)}" alt="" style="max-width:100%;height:auto"></p>` : ''}
+<div class="post-body">${p.body_html}</div>
+<form method="post" action="${base}/posts/${esc(p.id)}/${p.status === 'hidden' ? 'unhide' : 'hide'}" class="inline">${csrf(token)}<button type="submit" class="button-quiet">${p.status === 'hidden' ? 'Show again' : 'Hide'}</button></form>
+</article>`).join('') : '<p class="muted">No posts yet.</p>';
+ const mod = moderation && moderation.length ? `<section><h2>Moderation</h2>
+<ul class="rules">${moderation.map((m) => `<li><b>${esc(m.title)}</b> · ${esc(m.creator)} · ${esc(m.tiers.map((t) => `Tier ${t}`).join(', '))} · <span class="pill">${esc(m.status)}</span>
+<form method="post" action="/dashboard/moderation/posts/${esc(m.id)}/hide" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Hide</button></form>
+<form method="post" action="/dashboard/moderation/posts/${esc(m.id)}/unhide" class="inline">${csrf(token)}<button type="submit" class="button-quiet">Show again</button></form></li>`).join('')}</ul></section>` : '';
+ return `<section><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>${mod}`;
+}
+
/** The creator's public plan page. */
/** The creator's public plan page. */
-function creatorPage({ creator, plans, viewer, member, rates, providers, token, query, isOwner }) {
+function creatorPage({ creator, plans, viewer, member, rates, providers, token, query, isOwner, posts = [] }) {
const name = creator.display_name || creator.username;
const name = creator.display_name || creator.username;
const status = member && member.status === 'active'
const status = member && member.status === 'active'
? `<div class="card member-box"><p><b>You are a member.</b> ${member.cancel_at_period_end ? `Your membership ends on ${fmtDate(member.expires_at)}.` : `Paid through ${fmtDate(member.expires_at)}.`}</p>
? `<div class="card member-box"><p><b>You are a member.</b> ${member.cancel_at_period_end ? `Your membership ends on ${fmtDate(member.expires_at)}.` : `Paid through ${fmtDate(member.expires_at)}.`}</p>
@@ -201,7 +259,8 @@ ${join}
@@ -201,7 +259,8 @@ ${join}
${creator.bio ? `<p>${esc(creator.bio)}</p>` : ''}</section>
${creator.bio ? `<p>${esc(creator.bio)}</p>` : ''}</section>
${notice(query)}
${notice(query)}
${status}
${status}
-<section class="plans">${planCards}</section>`;
+<section class="plans">${planCards}</section>
+${postsSection(posts)}`;
}
}
/** Public terms history of one plan: every published version, unchanged. */
/** Public terms history of one plan: every published version, unchanged. */
@@ -295,7 +354,7 @@ function shareCard({ creator, embeds, hasPlan }) {
@@ -295,7 +354,7 @@ function shareCard({ creator, embeds, hasPlan }) {
<label>Card for developers (JSON, open to any site) <input readonly value="${esc(embeds.card)}"></label></section>`;
<label>Card for developers (JSON, open to any site) <input readonly value="${esc(embeds.card)}"></label></section>`;
}
}
-function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base, embeds, priceTable = null }) {
+function dashboard({ viewer, creator, isNetwork, isStaff, plans, allPerks, ownPerks, members, membersSource, rules, token, query, base, embeds, priceTable = null, posts = [], postTiers = [], moderation = [] }) {
if (!viewer) {
if (!viewer) {
return `<section class="card narrow"><h1>Creator dashboard</h1><p>Sign in with your OpenVibe account to offer a membership plan.</p>
return `<section class="card narrow"><h1>Creator dashboard</h1><p>Sign in with your OpenVibe account to offer a membership plan.</p>
<p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;
<p><a class="button" href="/auth/login?next=%2Fdashboard">Sign in</a></p></section>`;
@@ -337,7 +396,7 @@ ${ownPerks.length ? `<ul class="perk-admin">${ownPerks.map((p) => `<li><details>
@@ -337,7 +396,7 @@ ${ownPerks.length ? `<ul class="perk-admin">${ownPerks.map((p) => `<li><details>
<label>Kind <select name="kind">${['badge', 'emote', 'gated_content', 'room', 'role', 'other'].map((k) => `<option>${k}</option>`).join('')}</select></label>
<label>Kind <select name="kind">${['badge', 'emote', 'gated_content', 'room', 'role', 'other'].map((k) => `<option>${k}</option>`).join('')}</select></label>
<label>Product bindings, one "product binding" per line (e.g. <code>chat badge</code>) <textarea name="bindings" rows="2"></textarea></label>
<label>Product bindings, one "product binding" per line (e.g. <code>chat badge</code>) <textarea name="bindings" rows="2"></textarea></label>
<button type="submit">Create perk</button></form></details></section>
<button type="submit">Create perk</button></form></details></section>
-${isNetwork ? '' : `<section><h2>Members</h2>
+${isNetwork ? '' : `${postsAdmin({ posts, tiers: postTiers, moderation, token, base })}<section><h2>Members</h2>
${members.length ? `<table class="members"><thead><tr><th>Member</th><th>Plan version</th><th>Paid through</th></tr></thead><tbody>${members.map((m) => `<tr><td><code>${esc(m.member.id)}</code></td><td>${m.membership && m.membership.plan_version ? `${esc(m.membership.plan_version.name)} v${esc(m.membership.plan_version.version)}` : '—'}</td><td>${fmtDate(m.current_period_end)}${m.cancel_at_period_end ? ' (ends)' : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No active members.</p>'}
${members.length ? `<table class="members"><thead><tr><th>Member</th><th>Plan version</th><th>Paid through</th></tr></thead><tbody>${members.map((m) => `<tr><td><code>${esc(m.member.id)}</code></td><td>${m.membership && m.membership.plan_version ? `${esc(m.membership.plan_version.name)} v${esc(m.membership.plan_version.version)}` : '—'}</td><td>${fmtDate(m.current_period_end)}${m.cancel_at_period_end ? ' (ends)' : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No active members.</p>'}
<p class="muted small">${membersSource === 'billing' ? 'From OpenVibe.Billing.' : 'OpenVibe.Billing did not answer; this is VIP\'s recent projection and may be incomplete.'}</p></section>
<p class="muted small">${membersSource === 'billing' ? 'From OpenVibe.Billing.' : 'OpenVibe.Billing did not answer; this is VIP\'s recent projection and may be incomplete.'}</p></section>
<section><h2>Members-only resources</h2>
<section><h2>Members-only resources</h2>
@@ -357,5 +416,5 @@ ${rules.length ? `<ul class="rules">${rules.map((r) => `<li><code>${esc(r.resour
@@ -357,5 +416,5 @@ ${rules.length ? `<ul class="rules">${rules.map((r) => `<li><code>${esc(r.resour
module.exports = {
module.exports = {
HOME_TITLE, HOME_DESCRIPTION, HOME_FAQ, homeFaqJsonLd,
HOME_TITLE, HOME_DESCRIPTION, HOME_FAQ, homeFaqJsonLd,
- errorPage, home, creatorPage, planHistory, memberPage, checkoutPage, dashboard, fmtDate,
+ errorPage, home, creatorPage, planHistory, memberPage, checkoutPage, dashboard, postsSection, fmtDate,
};
};
@@ -18,6 +18,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -18,6 +18,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const express = require('express');
const express = require('express');
+const multer = require('multer');
const { VipError, bool } = require('../util');
const { VipError, bool } = require('../util');
const { viewerMiddleware } = require('./session');
const { viewerMiddleware } = require('./session');
const { createForms } = require('./forms');
const { createForms } = require('./forms');
@@ -43,6 +44,11 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
@@ -43,6 +44,11 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
const isStaff = (v) => !!v && v.staff === true;
const isStaff = (v) => !!v && v.staff === true;
router.use(viewerMiddleware(userAuth));
router.use(viewerMiddleware(userAuth));
const body = express.urlencoded({ extended: false, limit: '32kb' });
const body = express.urlencoded({ extended: false, limit: '32kb' });
+ // The post composer posts a form with an optional image: multipart, one file, 8 MB, in memory.
+ const uploadImage = multer({
+ storage: multer.memoryStorage(),
+ limits: { fileSize: 8 * 1024 * 1024, files: 1, fields: 24, fieldSize: 64 * 1024 },
+ }).single('image');
let ratesCache = { at: 0, value: null };
let ratesCache = { at: 0, value: null };
async function rates() {
async function rates() {
@@ -89,8 +95,11 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
@@ -89,8 +95,11 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
const lines = (v) => String(v || '').split('\n').map((s) => s.trim()).filter(Boolean);
const lines = (v) => String(v || '').split('\n').map((s) => s.trim()).filter(Boolean);
/** A POST handler: viewer + anti-forgery token, VipError → redirect with the message. */
/** A POST handler: viewer + anti-forgery token, VipError → redirect with the message. */
- function post(path, handler, { backTo } = {}) {
- router.post(path, body, async (req, res, next) => {
+ function post(path, handler, { backTo, upload = null } = {}) {
+ // A route with an upload takes the multipart parser first and the urlencoded one after it, so a
+ // form without a file still works.
+ const parser = upload ? [upload, body] : [body];
+ router.post(path, ...parser, async (req, res, next) => {
const to = typeof backTo === 'function' ? backTo(req) : (backTo || req.path);
const to = typeof backTo === 'function' ? backTo(req) : (backTo || req.path);
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(to)}`);
if (!req.viewer) return res.redirect(303, `/auth/login?next=${encodeURIComponent(to)}`);
if (!forms.verify(req.viewer.subject, req.body && req.body._csrf)) return back(res, to, 'error', 'That form expired. Please try again.');
if (!forms.verify(req.viewer.subject, req.body && req.body._csrf)) return back(res, to, 'error', 'That form expired. Please try again.');
@@ -258,12 +267,27 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
@@ -258,12 +267,27 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
members = (await Promise.all((await entitlements.projectedMembers(creator.subject)).map(async (pr) => ({ member: { type: 'user', id: pr.member_subject }, current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, membership: await memberships.present(byMember.get(pr.member_subject)) }))));
members = (await Promise.all((await entitlements.projectedMembers(creator.subject)).map(async (pr) => ({ member: { type: 'user', id: pr.member_subject }, current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, membership: await memberships.present(byMember.get(pr.member_subject)) }))));
}
}
}
}
+ // The creator's own posts (with their text) and, for staff, every creator's posts to moderate.
+ const postRows = isNetwork ? [] : await domain.posts.rows({ creatorId: creator.id, includeHidden: true });
+ const postTiers = isNetwork ? [] : await domain.posts.offeredTiers(creator.id);
+ const moderation = isStaff(req.viewer) ? await Promise.all((await domain.posts.recent({ limit: 50 })).map(async (row) => {
+ const owner = await creators.byId(row.creator_id);
+ return {
+ id: row.id, title: row.title, status: row.status, tiers: domain.posts.tiersOf(row),
+ creator: owner ? (owner.display_name || owner.username || owner.subject) : 'a creator',
+ };
+ })) : [];
send(res, 200, {
send(res, 200, {
title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,
title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,
body: pages.dashboard({
body: pages.dashboard({
viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource, priceTable,
viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource, priceTable,
rules: isNetwork ? [] : await Promise.all((await policies.list(creator.id)).map(policies.present)), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',
rules: isNetwork ? [] : await Promise.all((await policies.list(creator.id)).map(policies.present)), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',
embeds: !isNetwork && creator.username ? domain.cards.urls(creator) : null,
embeds: !isNetwork && creator.username ? domain.cards.urls(creator) : null,
+ posts: postRows.map((row) => ({
+ id: row.id, title: row.title, tiers: domain.posts.tiersOf(row), status: row.status, published_at: row.published_at,
+ image: row.image_id ? { id: row.image_id, url: row.image_url } : null, body_html: row.body_html,
+ })),
+ postTiers, moderation,
}),
}),
});
});
} catch (e) { next(e); }
} catch (e) { next(e); }
@@ -342,9 +366,52 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
@@ -342,9 +366,52 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
await policies.disable(rule.id);
await policies.disable(rule.id);
back(res, to, 'ok', 'Rule removed.');
back(res, to, 'ok', 'Rule removed.');
}, opts);
}, opts);
+ // A post for the members of one or more of the creator's tiers (an optional image goes to Media).
+ post(`${base}/posts`, async (req, res, to) => {
+ const c = await dashCreator(req);
+ const tiers = list(req.body.tiers).map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= 10);
+ let image = null;
+ if (req.file && req.file.buffer && req.file.buffer.length) {
+ try {
+ image = await domain.media.uploadImage({ buffer: req.file.buffer, mime: req.file.mimetype, filename: req.file.originalname, owner: c.subject });
+ } catch (e) {
+ throw new VipError(503, 'vip.image_failed', `The image could not be stored: ${e.detail || e.message}`);
+ }
+ }
+ await domain.posts.create({ creatorId: c.id, title: req.body.title, body: req.body.body, tiers, image, actor: req.viewer.subject, traceparent: req.ov.traceparent });
+ back(res, to, 'ok', 'Post published.');
+ }, { ...opts, upload: uploadImage });
+ post(`${base}/posts/:id/hide`, async (req, res, to) => {
+ const row = await domain.posts.byId(req.params.id);
+ await own(req, row);
+ await domain.posts.hide(row.id, { actor: req.viewer.subject });
+ back(res, to, 'ok', 'Post hidden. Nobody reads it now.');
+ }, opts);
+ post(`${base}/posts/:id/unhide`, async (req, res, to) => {
+ const row = await domain.posts.byId(req.params.id);
+ await own(req, row);
+ await domain.posts.unhide(row.id, { actor: req.viewer.subject });
+ back(res, to, 'ok', 'Post shown again.');
+ }, opts);
}
}
}
}
+ // Staff moderation: hide or show any creator's post (the way staff manage network plans).
+ post('/dashboard/moderation/posts/:id/hide', async (req, res, to) => {
+ if (!isStaff(req.viewer)) throw new VipError(403, 'vip.not_staff', 'only staff moderate posts');
+ const row = await domain.posts.byId(req.params.id);
+ if (!row) throw new VipError(404, 'vip.post_not_found', 'no such post');
+ await domain.posts.hide(row.id, { actor: req.viewer.subject, reason: 'hidden by staff' });
+ back(res, to, 'ok', 'Post hidden.');
+ }, { backTo: '/dashboard' });
+ post('/dashboard/moderation/posts/:id/unhide', async (req, res, to) => {
+ if (!isStaff(req.viewer)) throw new VipError(403, 'vip.not_staff', 'only staff moderate posts');
+ const row = await domain.posts.byId(req.params.id);
+ if (!row) throw new VipError(404, 'vip.post_not_found', 'no such post');
+ await domain.posts.unhide(row.id, { actor: req.viewer.subject, staff: true });
+ back(res, to, 'ok', 'Post shown again.');
+ }, { backTo: '/dashboard' });
+
// ── Creator pages ────────────────────────────────────────
// ── Creator pages ────────────────────────────────────────
router.get('/:username', async (req, res, next) => {
router.get('/:username', async (req, res, next) => {
try {
try {
@@ -356,11 +423,33 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
@@ -356,11 +423,33 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
const isOwner = !!(req.viewer && req.viewer.subject === c.subject);
const isOwner = !!(req.viewer && req.viewer.subject === c.subject);
const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
const name = c.display_name || c.username;
const name = c.display_name || c.username;
+ // The posts: the title and the tier are public, the text only for a member of that tier.
+ const posts = [];
+ for (const row of await domain.posts.rows({ creatorId: c.id })) {
+ const tiers = domain.posts.tiersOf(row);
+ // The plan that opens this post: the cheapest tier that reads it and can be joined (a tier
+ // above it reads it too). When no such plan can be joined, the post offers no way in — a
+ // primary button must never lead to a dead end.
+ const needed = tiers.length ? Math.min(...tiers) : null;
+ const target = needed == null ? null : list.filter((p) => p.purchasable && p.tier && p.tier >= needed).sort((a, b) => a.tier - b.tier)[0];
+ const access = isOwner
+ ? { unlocked: true }
+ : await domain.posts.access({
+ post: row, memberSubject: req.viewer ? req.viewer.subject : null, creatorSubject: c.subject, creatorId: c.id,
+ entitlement: member, traceparent: req.ov.traceparent,
+ });
+ posts.push({
+ id: row.id, title: row.title, tiers, published_at: row.published_at,
+ image: row.image_id ? { id: row.image_id, url: row.image_url } : null,
+ unlocked: !!access.unlocked, body_html: access.unlocked ? row.body_html : null,
+ join_url: target ? `/${encodeURIComponent(c.username)}#plan-${encodeURIComponent(target.slug)}` : null,
+ });
+ }
send(res, 200, {
send(res, 200, {
title: `${name} memberships`, active: 'creator', canonicalPath: `/${c.username}`, viewer: req.viewer,
title: `${name} memberships`, active: 'creator', canonicalPath: `/${c.username}`, viewer: req.viewer,
description: c.bio || `Membership plans from ${name} on OpenVibe.`,
description: c.bio || `Membership plans from ${name} on OpenVibe.`,
robots: list.length ? 'index,follow' : 'noindex,follow',
robots: list.length ? 'index,follow' : 'noindex,follow',
- body: pages.creatorPage({ creator: creators.present(c), plans: list, viewer: req.viewer, member, rates: list.some((p) => p.purchasable) ? await rates() : null, providers: config.billing.providers, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query, isOwner }),
+ body: pages.creatorPage({ creator: creators.present(c), plans: list, viewer: req.viewer, member, rates: list.some((p) => p.purchasable) ? await rates() : null, providers: config.billing.providers, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query, isOwner, posts }),
});
});
} catch (e) { next(e); }
} catch (e) { next(e); }
});
});
@@ -59,6 +59,8 @@ const envelope = (id, type, payload) => ({ event_id: id, event_type: type, sourc
@@ -59,6 +59,8 @@ const envelope = (id, type, payload) => ({ event_id: id, event_type: type, sourc
ON CONFLICT (member_subject, creator_id) DO NOTHING`, [member.subject, creatorId, new Date().toISOString()]);
ON CONFLICT (member_subject, creator_id) DO NOTHING`, [member.subject, creatorId, new Date().toISOString()]);
assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [member.subject]), 1);
assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [member.subject]), 1);
assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', [member.subject]), 1);
assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', [member.subject]), 1);
+ const post = await t.call('POST', '/api/v1/creators/' + creator.subject + '/posts', { user: creator, body: { title: 'Crew notes', body: 'The set list for Friday.', tiers: [1] } });
+ assert.strictEqual(post.status, 201, JSON.stringify(post.json));
});
});
test('the member\'s export carries their memberships, entitlements and paid periods, and nobody else\'s', async () => {
test('the member\'s export carries their memberships, entitlements and paid periods, and nobody else\'s', async () => {
@@ -94,6 +96,8 @@ const envelope = (id, type, payload) => ({ event_id: id, event_type: type, sourc
@@ -94,6 +96,8 @@ const envelope = (id, type, payload) => ({ event_id: id, event_type: type, sourc
const row = await db.maybe('SELECT * FROM vip_creators WHERE id = $1', [creatorId]);
const row = await db.maybe('SELECT * FROM vip_creators WHERE id = $1', [creatorId]);
assert.deepStrictEqual([row.status, row.username, row.display_name, row.bio], ['suspended', null, null, null]);
assert.deepStrictEqual([row.status, row.username, row.display_name, row.bio], ['suspended', null, null, null]);
assert.strictEqual(await count('SELECT count(*) FROM vip_plans WHERE creator_id = $1 AND created_by IS NULL', [creatorId]), 1);
assert.strictEqual(await count('SELECT count(*) FROM vip_plans WHERE creator_id = $1 AND created_by IS NULL', [creatorId]), 1);
+ assert.strictEqual(await count('SELECT count(*) FROM vip_member_posts WHERE creator_id = $1 AND created_by IS NULL', [creatorId]), 1, 'the creator\'s posts lose their author');
+ assert.ok(await count('SELECT count(*) FROM vip_member_posts WHERE creator_id = $1', [creatorId]) >= 1, 'the posts stay with the creator row');
assert.ok(await count('SELECT count(*) FROM vip_plan_versions WHERE plan_id = $1', [plan.id]) >= 1, 'the terms members joined under stay');
assert.ok(await count('SELECT count(*) FROM vip_plan_versions WHERE plan_id = $1', [plan.id]) >= 1, 'the terms members joined under stay');
assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [other.subject]), 1);
assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [other.subject]), 1);
const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE2/confirmations');
const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE2/confirmations');
@@ -83,6 +83,27 @@ async function boot(opts = {}) {
@@ -83,6 +83,27 @@ async function boot(opts = {}) {
const res = await fetch(base + p, { method, headers, body: bodyStr, redirect: 'manual' });
const res = await fetch(base + p, { method, headers, body: bodyStr, redirect: 'manual' });
return { status: res.status, location: res.headers.get('location'), text: await res.text() };
return { status: res.status, location: res.headers.get('location'), text: await res.text() };
}
}
+ /**
+ * An SSR request with a multipart form (fields and at most one file), the way the post composer
+ * sends an image. file: { field, filename, contentType, data }.
+ */
+ async function pageMultipart(p, { user, fields = {}, file = null } = {}) {
+ const boundary = '----vip' + Math.random().toString(16).slice(2);
+ const parts = [];
+ for (const [k, v] of Object.entries(fields)) {
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
+ }
+ if (file) {
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${file.field}"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`));
+ parts.push(Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data)));
+ parts.push(Buffer.from('\r\n'));
+ }
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
+ const headers = { 'Content-Type': `multipart/form-data; boundary=${boundary}` };
+ if (user) headers.Cookie = `ov_token=${network.signUser(user)}`;
+ const res = await fetch(base + p, { method: 'POST', headers, body: Buffer.concat(parts), redirect: 'manual' });
+ return { status: res.status, location: res.headers.get('location'), text: await res.text() };
+ }
const csrf = (html) => { const m = /name="_csrf" value="([^"]+)"/.exec(html); return m ? m[1] : null; };
const csrf = (html) => { const m = /name="_csrf" value="([^"]+)"/.exec(html); return m ? m[1] : null; };
const outboxEvents = async (type) => (await domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => !type || e.event_type === type);
const outboxEvents = async (type) => (await domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => !type || e.event_type === type);
@@ -95,7 +116,7 @@ async function boot(opts = {}) {
@@ -95,7 +116,7 @@ async function boot(opts = {}) {
if (!opts.testdb) await testdb.close(); // a caller that handed its own database closes it
if (!opts.testdb) await testdb.close(); // a caller that handed its own database closes it
}
}
- return { app, base, config, domain, network, billing, clock, logs, call, deliver, deliverAll, page, csrf, outboxEvents, close, testdb, EVENTS_SECRET };
+ return { app, base, config, domain, network, billing, clock, logs, call, deliver, deliverAll, page, pageMultipart, csrf, outboxEvents, close, testdb, EVENTS_SECRET };
}
}
/** Tiny test harness: sequential async tests, a summary, exit code. */
/** Tiny test harness: sequential async tests, a summary, exit code. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,322 @@
@@ -0,0 +1,322 @@
+'use strict';
+// Members-only posts on a creator page (owner's ask, plan 2026-10-22 §4 "Creator money: Tips, VIP"):
+// a creator writes a post for the members of one or more of their tiers. The title is public; the text
+// is served only after the entitlement check, and never to a signed-out visitor or a non-member.
+const assert = require('assert');
+const http = require('http');
+const { boot, harness } = require('./helpers/app');
+const { renderMarkdown } = require('../server/domain/markdown');
+const pages = require('../server/web/pages');
+const { createDomain } = require('../server/domain');
+const { createPosts } = require('../server/domain/posts');
+const { createMediaClient } = require('../server/media-client');
+const { createWebRoutes } = require('../server/web/routes');
+
+const { test, run } = harness('members-only posts');
+
+/** A stand-in for OpenVibe.Media's Object API v2 (the image on a post). */
+async function startMedia() {
+ const objects = new Map();
+ const server = http.createServer(async (req, res) => {
+ const chunks = [];
+ for await (const c of req) chunks.push(c);
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ const m = req.url.match(/^\/api\/v2\/[^/]+\/objects(?:\/([^/]+))?(?:\/(content|complete))?$/);
+ if (!m) return json(404, { detail: 'not found' });
+ const [, id, step] = m;
+ if (!id) {
+ const obj = { id: 'med_' + 'A'.repeat(26), size: raw.length, ready: false };
+ objects.set(obj.id, obj);
+ return json(201, { id: obj.id, status: 'pending' });
+ }
+ const obj = objects.get(id);
+ if (!obj) return json(404, { detail: 'no such object' });
+ if (step === 'content') { obj.size = raw.length; return json(200, { id, size_bytes: raw.length }); }
+ if (step === 'complete') { obj.ready = true; return json(200, { id, status: 'ready' }); }
+ return json(200, { id, status: obj.ready ? 'ready' : 'pending' });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, objects, close: () => new Promise((r) => server.close(r)) };
+}
+
+/** A creator with a tier-1 and a tier-2 plan published. */
+async function publishTiers(t, creator, tiers = [1, 2]) {
+ for (const tier of tiers) {
+ const r = await t.call('POST', '/api/v1/plans', {
+ body: { creator: { type: 'user', id: creator.subject }, name: `Tier ${tier} plan`, publish: true, tier },
+ cap: ['vip.plan.create'],
+ });
+ assert.strictEqual(r.status, 201, `plan tier ${tier}: ${JSON.stringify(r.json)}`);
+ }
+}
+
+/** A post written by the creator, through their own dashboard. */
+async function writePost(t, creator, { title, body, tiers = ['1'] }) {
+ const page = await t.page('/dashboard', { user: creator });
+ const token = t.csrf(page.text);
+ assert.ok(token, 'the dashboard has a form token');
+ const res = await t.page('/dashboard/posts', { user: creator, method: 'POST', form: { title, body, tiers: tiers.join(','), _csrf: token } });
+ assert.strictEqual(res.status, 303, `post ${title}: ${res.text.slice(0, 200)}`);
+ return res;
+}
+
+test('a non-member and a signed-out visitor see the title, the tier and a way to join — never the text', async () => {
+ const t = await boot();
+ try {
+ const creator = t.network.newUser('lena');
+ await publishTiers(t, creator, [1]);
+ await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['1'] });
+
+ const out = await t.page('/lena');
+ assert.strictEqual(out.status, 200);
+ assert.ok(out.text.includes('Studio notes'), 'the title is public');
+ assert.ok(out.text.includes('Tier 1'), 'the tier it needs is shown');
+ assert.ok(out.text.includes('Become a member'), 'there is a way to join');
+ assert.ok(!out.text.includes('60% patience'), 'the text is not on the page');
+
+ // The API never hands the text to a signed-out caller.
+ const list = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts', { token: null });
+ assert.strictEqual(list.status, 200);
+ assert.strictEqual(list.json.posts.length, 1);
+ assert.strictEqual(list.json.posts[0].title, 'Studio notes');
+ assert.strictEqual(list.json.posts[0].body, undefined, 'the list carries no text');
+ assert.strictEqual(list.json.posts[0].locked, true);
+ const body = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + list.json.posts[0].id + '/body', { token: null });
+ assert.strictEqual(body.status, 401, 'a signed-out caller gets nothing');
+
+ // A signed-in non-member neither.
+ const stranger = t.network.newUser('nils');
+ const strangerBody = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + list.json.posts[0].id + '/body', { user: stranger });
+ assert.strictEqual(strangerBody.status, 403);
+ assert.ok(!JSON.stringify(strangerBody.json).includes('60% patience'));
+ } finally { await t.close(); }
+});
+
+test('a member of the tier reads the text, on the page and through the API', async () => {
+ const t = await boot();
+ try {
+ const creator = t.network.newUser('lena');
+ const member = t.network.newUser('mika');
+ await publishTiers(t, creator, [1]);
+ await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['1'] });
+
+ const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });
+ await t.deliverAll(paid.events);
+
+ const out = await t.page('/lena', { user: member });
+ assert.ok(out.text.includes('Studio notes'));
+ assert.ok(out.text.includes('60% patience'), 'a member reads the text');
+
+ const list = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts', { user: member });
+ const post = list.json.posts[0];
+ assert.strictEqual(post.locked, false);
+ const body = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + post.id + '/body', { user: member });
+ assert.strictEqual(body.status, 200);
+ assert.ok(body.json.body.includes('60% patience'));
+ assert.ok(body.json.body_html.includes('<p>'), 'the Markdown is rendered to safe HTML');
+ } finally { await t.close(); }
+});
+
+test('a member of another tier does not read the text', async () => {
+ const t = await boot();
+ try {
+ const creator = t.network.newUser('lena');
+ const member = t.network.newUser('mika');
+ await publishTiers(t, creator, [1, 2]);
+ await writePost(t, creator, { title: 'Backstage', body: 'Only for the second row.', tiers: ['2'] });
+
+ const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });
+ await t.deliverAll(paid.events);
+
+ const out = await t.page('/lena', { user: member });
+ assert.ok(out.text.includes('Backstage'), 'the title is public');
+ assert.ok(!out.text.includes('Only for the second row.'), 'the text is not');
+
+ const list = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts', { user: member });
+ const post = list.json.posts[0];
+ assert.strictEqual(post.locked, true);
+ const body = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + post.id + '/body', { user: member });
+ assert.strictEqual(body.status, 403);
+ } finally { await t.close(); }
+});
+
+test('the creator reads their own post, on the page and on the dashboard', async () => {
+ const t = await boot();
+ try {
+ const creator = t.network.newUser('lena');
+ await publishTiers(t, creator, [1]);
+ await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['1'] });
+
+ const page = await t.page('/lena', { user: creator });
+ assert.ok(page.text.includes('60% patience'), 'the creator reads their own text');
+ const dash = await t.page('/dashboard', { user: creator });
+ assert.ok(dash.text.includes('Studio notes'), 'the dashboard lists the post');
+ assert.ok(dash.text.includes('60% patience'), 'the dashboard shows the creator their own text');
+ } finally { await t.close(); }
+});
+
+test('staff hide a post: it is gone from the page and from the API, and the creator cannot bring it back', async () => {
+ const t = await boot();
+ try {
+ const creator = t.network.newUser('lena');
+ const member = t.network.newUser('mika');
+ const staff = t.network.newUser('mod', { role: 'admin' });
+ await publishTiers(t, creator, [1]);
+ await writePost(t, creator, { title: 'Studio notes', body: 'The secret of the mix: 60% patience.', tiers: ['1'] });
+ const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });
+ await t.deliverAll(paid.events);
+
+ const list = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts', { user: member });
+ const id = list.json.posts[0].id;
+
+ const hidden = await t.call('POST', '/api/v1/posts/' + id + '/hide', { user: staff });
+ assert.strictEqual(hidden.status, 200);
+ assert.strictEqual(hidden.json.post.status, 'hidden');
+
+ const page = await t.page('/lena', { user: member });
+ assert.ok(!page.text.includes('Studio notes'), 'a hidden post is not listed');
+ assert.ok(!page.text.includes('60% patience'));
+ const body = await t.call('GET', '/api/v1/creators/' + creator.subject + '/posts/' + id + '/body', { user: member });
+ assert.strictEqual(body.status, 404, 'a hidden post serves no text');
+
+ const dash = await t.page('/dashboard', { user: creator });
+ assert.ok(dash.text.includes('Studio notes'), 'the creator still sees their post');
+ assert.ok(dash.text.includes('hidden'), 'and sees that it is hidden');
+
+ // The creator may not undo a staff decision; staff may.
+ const back = await t.call('POST', '/api/v1/posts/' + id + '/unhide', { user: creator });
+ assert.strictEqual(back.status, 403);
+ const again = await t.call('POST', '/api/v1/posts/' + id + '/unhide', { user: staff });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json.post.status, 'published');
+ const shown = await t.page('/lena', { user: member });
+ assert.ok(shown.text.includes('60% patience'), 'the post is readable again');
+ } finally { await t.close(); }
+});
+
+test('an image on a post goes to Media and is shown to the members of the tier', async () => {
+ const media = await startMedia();
+ const t = await boot({ env: { OV_MEDIA_INTERNAL_URL: media.url, OV_MEDIA_URL: 'https://media.test' } });
+ try {
+ const creator = t.network.newUser('lena');
+ const member = t.network.newUser('mika');
+ await publishTiers(t, creator, [1]);
+ const dash = await t.page('/dashboard', { user: creator });
+ const token = t.csrf(dash.text);
+ const png = Buffer.from('89504e470d0a1a0a', 'hex');
+ const res = await t.pageMultipart('/dashboard/posts', {
+ user: creator,
+ fields: { title: 'Cover art', body: 'The sleeve.', tiers: '1', _csrf: token },
+ file: { field: 'image', filename: 'cover.png', contentType: 'image/png', data: png },
+ });
+ assert.strictEqual(res.status, 303, res.text.slice(0, 200));
+ assert.strictEqual(media.objects.size, 1, 'the image was stored in Media');
+ const object = [...media.objects.values()][0];
+ assert.strictEqual(object.ready, true, 'Media has the whole object');
+
+ const paid = await t.billing.pay(member.subject, creator.subject, { tier: 1 });
+ await t.deliverAll(paid.events);
+ const page = await t.page('/lena', { user: member });
+ assert.ok(page.text.includes(`https://media.test/o/${object.id}`), 'the image is on the page');
+ assert.ok(page.text.includes('The sleeve.'));
+ } finally { await t.close(); await media.close(); }
+});
+
+test('the Markdown a creator writes is rendered safely', async () => {
+ const t = await boot();
+ try {
+ const creator = t.network.newUser('lena');
+ await publishTiers(t, creator, [1]);
+ await writePost(t, creator, { title: 'Markup', body: '**bold** <script>alert(1)</script>', tiers: ['1'] });
+ const out = await t.page('/lena', { user: creator });
+ assert.ok(out.text.includes('<strong>bold</strong>'), 'the marks are rendered');
+ assert.ok(!out.text.includes('<script>alert(1)</script>'), 'no markup is carried through');
+ } finally { await t.close(); }
+});
+
+test('the renderer escapes everything but the marks, and the page shows a locked post without its text', async () => {
+ // The Markdown renderer: only the marks survive, never markup.
+ const html = renderMarkdown('## Heading\n\n**bold** and [link](https://a.test) and <script>alert(1)</script>\n\n- one\n- two');
+ assert.ok(html.includes('<h4>Heading</h4>'), 'a heading is rendered one step down the page headings');
+ assert.ok(html.includes('<strong>bold</strong>'));
+ assert.ok(html.includes('<a href="https://a.test" rel="noopener noreferrer nofollow">link</a>'));
+ assert.ok(!html.includes('<script>'), 'no markup is carried through');
+ assert.ok(html.includes('<script>'), 'the markup is shown as text');
+ assert.ok(renderMarkdown('[x](javascript:alert(1))').includes('javascript:alert(1)'), 'a javascript: link stays plain text');
+ assert.ok(!renderMarkdown('[x](javascript:alert(1))').includes('<a '), 'and never becomes a link');
+
+ // The page: a locked post shows its title, its tier and the way to join — and no text.
+ const locked = pages.postsSection([{ title: 'Studio notes', tiers: [1], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: false, body_html: null, join_url: '/lena#plan-front-row' }]);
+ assert.ok(locked.includes('Studio notes'));
+ assert.ok(locked.includes('Tier 1'));
+ assert.ok(locked.includes('Become a member'));
+ assert.ok(!locked.includes('post-body'), 'a locked post carries no text');
+ const open = pages.postsSection([{ title: 'Studio notes', tiers: [1], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: true, body_html: '<p>The mix.</p>', join_url: '/lena' }]);
+ assert.ok(open.includes('<p>The mix.</p>'), 'a member reads the text');
+ // A locked post whose tier cannot be joined: the line, no button.
+ const closed = pages.postsSection([{ title: 'Studio notes', tiers: [2], published_at: '2026-01-02T00:00:00.000Z', image: null, unlocked: false, body_html: null, join_url: null }]);
+ assert.ok(closed.includes('This post is for the members of Tier 2, which is not open to join yet.'), 'the line says the tier is not open');
+ assert.ok(!closed.includes('Become a member'), 'no button that leads nowhere');
+ assert.ok(closed.includes('aria-hidden="true"'), 'the lock glyph is decorative');
+});
+
+test('a post is written for a tier the creator sells, and the domain refuses the others', async () => {
+ const t = await boot();
+ try {
+ const creator = t.network.newUser('lena');
+ await publishTiers(t, creator, [1]);
+ const c = await t.domain.creators.bySubject(creator.subject);
+ // The domain, built the way the app builds it.
+ const domain = createDomain({ db: t.domain.db, config: t.config, outbox: t.domain.outbox, billing: t.domain.billing, now: t.domain.now, log: console, media: t.domain.media });
+ assert.ok(domain.posts, 'the domain has posts');
+ const posts = createPosts({ db: t.domain.db, now: t.domain.now, creators: t.domain.creators, plans: t.domain.plans, memberships: t.domain.memberships, entitlements: t.domain.entitlements });
+ const row = await posts.create({ creatorId: c.id, title: 'Notes', body: 'Hello', tiers: [1], actor: creator.subject });
+ assert.ok(row.id);
+ assert.deepStrictEqual(posts.tiersOf(row), [1]);
+ await assert.rejects(() => posts.create({ creatorId: c.id, title: 'Notes', body: 'Hello', tiers: [4], actor: creator.subject }), /tier/i);
+ await assert.rejects(() => posts.create({ creatorId: c.id, title: '', body: 'Hello', tiers: [1], actor: creator.subject }), /title/i);
+ // The routes are registered: the composer and the moderation.
+ const walk = (stack, out = []) => {
+ for (const l of stack || []) {
+ if (l.route) out.push(Object.keys(l.route.methods)[0] + ' ' + l.route.path);
+ else if (l.handle && l.handle.stack) walk(l.handle.stack, out);
+ }
+ return out;
+ };
+ const paths = walk(t.app._router.stack);
+ assert.ok(paths.includes('post /dashboard/posts'), 'the composer is a route');
+ assert.ok(paths.includes('post /dashboard/posts/:id/hide'));
+ assert.ok(paths.includes('post /dashboard/moderation/posts/:id/hide'));
+ // The Media client refuses without a service principal rather than losing the image quietly.
+ const client = createMediaClient({ config: { ...t.config, oauth: { ...t.config.oauth, clientSecret: '' } } });
+ assert.strictEqual(client.configured, false);
+ await assert.rejects(() => client.uploadImage({ buffer: Buffer.from('x'), mime: 'image/png', filename: 'x.png' }), /service principal/);
+ } finally { await t.close(); }
+});
+
+test('a locked post offers a way to join only when a tier that reads it can be joined', async () => {
+ const t = await boot();
+ try {
+ const creator = t.network.newUser('lena');
+ const stranger = t.network.newUser('nils');
+ // Tier 1 sells; tier 2 has no price, so it cannot be joined.
+ await publishTiers(t, creator, [1, 2]);
+ await writePost(t, creator, { title: 'Joinable', body: 'For the first row.', tiers: ['1'] });
+ await writePost(t, creator, { title: 'Not joinable', body: 'For the second row.', tiers: ['2'] });
+
+ const out = await t.page('/lena', { user: stranger });
+ // The tier-1 post: the button, and it points at the plan that opens it.
+ assert.strictEqual((out.text.match(/Become a member/g) || []).length, 1, 'only the joinable post offers a way in');
+ assert.ok(out.text.includes('/lena#plan-tier-1-plan'), 'the link points at the plan that reads it');
+ // The tier-2 post: no button, and the line says why.
+ assert.ok(out.text.includes('This post is for the members of Tier 2, which is not open to join yet.'));
+ assert.ok(!out.text.includes('/lena#plan-tier-2-plan'), 'no link to a plan that cannot be joined');
+ assert.ok(!out.text.includes('For the first row.'), 'no text of a locked post, either way');
+ assert.ok(!out.text.includes('For the second row.'));
+ // The lock glyph before the line, drawn by the shared glyph set and hidden from a screen reader.
+ assert.ok(/aria-hidden="true"><rect x="5" y="11"/.test(out.text), 'the shared lock glyph is on the line');
+ } finally { await t.close(); }
+});
+
+run();