openvibers/openvibe.vip Commits

Members-only posts on creator pages (#188)

OP OpenVibers committed 7 days ago · 4b0f854719 · 18 files, +1036 −14

* Members-only posts on creator pages

A creator writes a post for the members of one or more of their tiers (Billing's tiers
of that creator). The post carries a title, a Markdown body rendered to safe HTML and an
optional image (a Media object; the bytes live in OpenVibe.Media, VIP keeps the med_
reference).

Who may read the text is never stored: it is answered on every request by the entitlement
check 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, a non-member and a member of
another tier get the title, the tier it needs and a way to join — never the text. A hidden
post is not served at all. The creator reads their own posts; staff hide any creator's
post the way they manage network plans, and a creator cannot bring back a post staff hid.

Expand-only: migration 0006 adds vip_member_posts and touches no existing table.

* A locked post offers a way to join only when one can be joined

A locked post offered a primary "Become a member" button even when the tier it
needs cannot be joined: the plan said "This plan has no price yet, so it cannot
be joined" and the post still sent the reader to it. A primary button must never
lead to a dead end.

The link now points at the cheapest tier that reads the post and can be joined (a
tier above it reads it too). When no such tier can be joined, the post offers no
button and says so instead: "This post is for the members of Tier 2, which is not
open to join yet." The line carries the shared lock glyph (openvibe-shared/glyphs),
drawn on the server and hidden from a screen reader.

Changes

ddiff --git a/.env.example b/.env.example
iindex a5855d7..0badf9e 100644
---- a/.env.example
++++ b/.env.example
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 5a8303a..f53c12a 100644
---- a/README.md
++++ b/README.md
@@@ -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-actor
limits), **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, then
Billing `POST /subscriptions/:id/cancel` (at period end).
++### Members-only posts (the creator's own posts)
++
++A creator writes posts for the members of one or more of their tiers (Billing's tiers of that creator,
++`vip_plans.billing_tier`). A post carries a title, a body in Markdown (rendered to safe HTML: the
++source can never carry markup) and an optional image (a Media object — the bytes live in
++OpenVibe.Media under VIP's tenant, the post keeps the `med_` reference).
++
++Who may read the text is never stored: it is answered on every request by the entitlement check
++(Billing's truth, projected) and the tier the member holds. The list of posts never carries a body, and
++the body is served only after that check. So:
++
++* a signed-out visitor and a non-member see the title, the tier it needs and a way to join — never the text;
++* a member of a tier reads a post written for their tier or for a lower one (a higher tier includes the lower);
++* a member of another tier reads nothing;
++* a hidden post is not served at all — not its title, not its text.
++
++A locked post offers a way in only when a tier that reads it can be joined: the "Become a member" button
++points at the cheapest such plan. When none can be joined (the plan has no price yet, or is not sold
++through Billing), the post says so — "This post is for the members of Tier 2, which is not open to join
++yet." — and offers no button, because a primary button must never lead to a dead end. The line carries
++the shared lock glyph.
++
++The creator reads their own posts (on their page and on the dashboard). Staff hide any creator's post,
++the way they manage network plans; a creator may hide their own, but cannot bring back a post staff
++hid. A post's author reference is anonymized when the creator's account is deleted (ADR-033), like
++their plans and perks.
++
### Events (openvibe-sdk outbox, same transaction as the change)
| Event | When | Payload |
@@@ -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 terms
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`
(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 trip
ddiff --git a/migrations/0006_member_posts.sql b/migrations/0006_member_posts.sql
nnew file mode 100644
iindex 0000000..05f9734
---- /dev/null
++++ b/migrations/0006_member_posts.sql
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
iindex f303af3..99197e7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex e9cc587..9bce6e5 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex abb9508..1ce973f 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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;
}
ddiff --git a/server/app.js b/server/app.js
iindex 7d1efce..faf552c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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);
ddiff --git a/server/config.js b/server/config.js
iindex ca5ee2f..de5ced6 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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),
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
iindex 70d01a5..7ac4a10 100644
---- a/server/domain/account-data.js
++++ b/server/domain/account-data.js
@@@ -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. */
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 57de205..ead5f4e 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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(), log
const 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 };
ddiff --git a/server/domain/markdown.js b/server/domain/markdown.js
nnew file mode 100644
iindex 0000000..250d06d
---- /dev/null
++++ b/server/domain/markdown.js
@@@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
++const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESCAPES[c]);
++
++const PLACEHOLDER = '\u0000';
++
++/** The inline marks of one line of text (already escaped). */
++function inline(src) {
++ const held = [];
++ const keep = (html) => { held.push(html); return PLACEHOLDER + (held.length - 1) + PLACEHOLDER; };
++ let s = escapeHtml(src);
++ // Inline code first: its content is shown as it is, never as markup.
++ s = s.replace(/`([^`]+)`/g, (_m, code) => keep('<code>' + code + '</code>'));
++ // Links: only http and https survive; anything else stays as plain text.
++ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => (/^https?:\/\//i.test(url)
++ ? keep('<a href="' + url + '" rel="noopener noreferrer nofollow">' + label + '</a>')
++ : m));
++ s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
++ s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>');
++ s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
++ s = s.replace(/(^|[^\w])_([^_\n]+)_/g, '$1<em>$2</em>');
++ return s.replace(new RegExp(PLACEHOLDER + '(\\d+)' + PLACEHOLDER, 'g'), (_m, i) => held[Number(i)]);
++}
++
++/**
++ * The Markdown of a post → safe HTML.
++ * @param {string} src the creator's Markdown
++ * @param {object} [opts] { headingShift } how far headings move down (a post's title is an h3 on the page)
++ */
++function renderMarkdown(src, { headingShift = 2 } = {}) {
++ const lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
++ const out = [];
++ let i = 0;
++ const flushParagraph = (buf) => { if (buf.length) { out.push('<p>' + inline(buf.join(' ')) + '</p>'); buf.length = 0; } };
++ const para = [];
++ while (i < lines.length) {
++ const line = lines[i];
++ if (/^\s*```\s*$/.test(line)) { // a fenced code block: shown as it is, never as markup
++ flushParagraph(para);
++ const code = [];
++ i += 1;
++ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1; }
++ i += 1;
++ out.push('<pre><code>' + escapeHtml(code.join('\n')) + '</code></pre>');
++ continue;
++ }
++ if (!line.trim()) { flushParagraph(para); i += 1; continue; }
++ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
++ if (heading) {
++ flushParagraph(para);
++ const level = Math.min(6, heading[1].length + headingShift);
++ out.push('<h' + level + '>' + inline(heading[2].trim()) + '</h' + level + '>');
++ i += 1;
++ continue;
++ }
++ if (/^\s*(?:\*{3,}|-{3,}|_{3,})\s*$/.test(line)) { flushParagraph(para); out.push('<hr>'); i += 1; continue; }
++ if (/^\s*>\s?/.test(line)) {
++ flushParagraph(para);
++ const quote = [];
++ while (i < lines.length && /^\s*>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^\s*>\s?/, '')); i += 1; }
++ out.push('<blockquote><p>' + inline(quote.join(' ')) + '</p></blockquote>');
++ continue;
++ }
++ const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
++ const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
++ if (bullet || numbered) {
++ flushParagraph(para);
++ const tag = bullet ? 'ul' : 'ol';
++ const items = [];
++ while (i < lines.length) {
++ const b = /^\s*[-*+]\s+(.*)$/.exec(lines[i]);
++ const n = /^\s*\d+[.)]\s+(.*)$/.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 };
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
nnew file mode 100644
iindex 0000000..7e8ecbb
---- /dev/null
++++ b/server/domain/posts.js
@@@ -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 };
ddiff --git a/server/media-client.js b/server/media-client.js
nnew file mode 100644
iindex 0000000..86fe231
---- /dev/null
++++ b/server/media-client.js
@@@ -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 };
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex e2ea571..6040556 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -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.resour
module.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,
};
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 153cd78..81730e7 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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); }
});
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex be72333..c0fa7ca 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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()]);
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, sourc
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.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');
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 942ee55..1d77e4e 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -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. */
ddiff --git a/test/member-posts.test.js b/test/member-posts.test.js
nnew file mode 100644
iindex 0000000..f843187
---- /dev/null
++++ b/test/member-posts.test.js
@@@ -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('&lt;script&gt;'), '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();