openvibers/openvibe.community Commits
Members-only discussion through OpenVibe.VIP: a forum space or a single thread can be for one creator's VIP members
Changes
@@@ -37,6 +37,16 @@ OV_MEDIA_URL=https://openvibe.media# with a service token (the OAuth client needs media.object.upload for audience openvibe.media).OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100++# OpenVibe.VIP: members-only forum spaces and threads. Community asks POST /api/v1/policies/evaluate++# with a service token (the OAuth client needs vip.resource.policy.evaluate for audience openvibe.vip).++# Without it, or with VIP down, only the owner and moderators get in (fails closed).++OV_VIP_INTERNAL_URL=http://127.0.0.1:4620++OV_VIP_URL=https://openvibe.vip++# How long a VIP yes / no / failure is cached (ms); the yes TTL bounds how long access outlives a membership.++# VIP_CACHE_TTL_MS=30000++# VIP_CACHE_DENY_TTL_MS=10000++# VIP_CACHE_UNAVAILABLE_TTL_MS=2000++# Who owns pastes:# live (default) /api/pastes/* proxies to OpenVibe.Live, pages read through Live# community this site's own database is the authority (native API, pages, raw text)@@@ -31,6 +31,7 @@ Pastes moved to Community in roadmap Wave 5. `PASTES_AUTHORITY` picks who owns t| Identity / SSO | **OpenVibe.Network** (OAuth2 + RS256 JWKS) | OAuth client `community` || Raw text, screenshots | OpenVibe.Media public host | 302 from `/p/:slug/raw`, `/p/:slug/screenshot` || Shared chrome, themes | `https://openvibe.network/shared/*.js` | loaded in every page |++| VIP memberships (members-only spaces/threads) | **OpenVibe.VIP** (`POST /api/v1/policies/evaluate`) | `OV_VIP_INTERNAL_URL`, service token (audience `openvibe.vip`, `vip.resource.policy.evaluate`) |Why go through Live and not straight to Media: the visitor's JWT names the account by its**Network** id, while the `user_id` Media stores for pastes is **Live's** own — different@@@ -320,14 +321,51 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):| --- | --- || `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space || `GET /spaces/:space/threads?sort=&page=&limit=` | Threads (server pagination: `page`, `pages`, `total`) |--| `POST /spaces/:space/threads` `{ title, body }` | New thread (`community.post.create` for services) |++| `POST /spaces/:space/threads` `{ title, body, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members || `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) || `DELETE /spaces/:space/threads/:slug` | Author or moderator || `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply || `POST /spaces/:space/threads/:slug/votes` `{ value: 1\|-1\|0 }` | Vote || `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked? }` | Moderators |++| `PUT /spaces/:space/members-only` `{ owner: 'usr_…' \| null }` | Moderators: gate a space to a creator's VIP members (or open it) |++| `PUT /spaces/:space/threads/:slug/members-only` `{ owner: 'usr_…' \| true \| null }` | The author (to their own members) or moderators (any creator) || `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |++### Members-only spaces and threads (OpenVibe.VIP)++++A space or a single thread can be for one creator's [OpenVibe.VIP](https://github.com/OpenVibers/OpenVibe.VIP)++members: `members_only_owner` holds the creator's Network subject (`usr_…`). Moderators gate spaces++(for any creator) and threads; a thread's author gates it to their own members (and opens it again),++by the API, the `members_only` box on the new-thread form, or the button on the thread page.++++- **Reading and posting need an active entitlement**: listing a gated space's threads, reading a++ thread, starting a thread, replying, voting, editing and post history. Community asks VIP++ `POST /api/v1/policies/evaluate` `{ subject, resource: { service: 'community', type: 'space'|'thread', id },++ owner, fallback: { requirement: 'member', binding: 'community:members_only' } }` — the owner is the++ one Community stored, and the creator's own VIP rule for that resource (plan or perk requirement) wins++ over the default gate. A gated thread in a gated space needs both.++- **The owner and discussion moderators always pass** (the owner without asking VIP).++- **Fails closed**: signed out, not a member, VIP down, a refused token or no `OV_OAUTH_CLIENT_SECRET`++ are all `403 vip.members_only` `{ reason, gate, members_only: { owner, owner_username, join_url } }`.++ Pages show a `noindex` teaser (space name, or the thread title) with the join link to the creator's++ page on openvibe.vip, never a post.++- **Listings** keep a gated thread's title with `members_only` (never a body); gated spaces are listed++ with their join link.++- **Never public**: gated spaces and threads stay out of Pulse (not recorded, removed when gated later,++ and filtered at read time), the Discord relay (not queued; pending deliveries dropped; re-checked at++ send), the sitemap, the RSS feeds (a gated space's feed is 404) and JSON-LD; a member's view of a++ gated thread is `noindex` as well.++- **Convergence**: answers are cached per viewer and resource (`server/vip/`, VIP's `createVipCache`):++ a "yes" at most `VIP_CACHE_TTL_MS` (30 s), a "no" 10 s, a failure 2 s. Community has no Events inbox,++ so **once VIP stops granting** (it applied Billing's `billing.entitlement.changed` and emitted++ `vip.membership.changed`) **Community stops within 30 s**; the end-to-end bound from Billing's change++ is VIP's (seconds with events, at most `VIP_PROJECTION_MAX_AGE_MS` + 30 s without; see the table in++ OpenVibe.VIP's README). `app.locals.vip.cache.handleEvent(envelope)` converges at once if an Events++ subscription is added later. `test/members-only.test.js` proves both.++- The client (`server/vip/vip-client.js`) is OpenVibe.VIP's `client/vip-client.js` vendored at 2accbeb++ until VIP publishes a tag to pin.++- **Grant needed** on the Network: `community` → `vip.resource.policy.evaluate` on `openvibe.vip`.++Votes (comments and threads) are one UPSERT per person plus a score recomputed from the voterows in the same IMMEDIATE transaction (`server/votes.js`), so concurrent votes cannot drifta score.@@@ -428,6 +466,10 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0| *(any name)* e.g. `DISCORD_WEBHOOK_FEEDBACK` | — | A Discord webhook URL, named by a relay mapping's `webhook_url_ref` || `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts || `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |++| `OV_VIP_INTERNAL_URL` | `http://127.0.0.1:4620` | OpenVibe.VIP's API (members-only spaces and threads) |++| `OV_VIP_URL` | `https://openvibe.vip` | Public VIP site, for join links |++| `VIP_TIMEOUT_MS` | `2000` | One VIP call |++| `VIP_CACHE_TTL_MS` / `VIP_CACHE_DENY_TTL_MS` / `VIP_CACHE_UNAVAILABLE_TTL_MS` | `30000` / `10000` / `2000` | How long a yes / no / failure is cached (the yes TTL is the convergence bound) |## Run@@@ -487,6 +529,7 @@ server/comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),routes (the /c/:accessId page), live-import (Live's old VOD/clip comments)forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)++ vip/ OpenVibe.VIP gate for members-only spaces/threads (index.js) + the vendored clientpulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)relay/ Discord relay: discord.js (queue, worker, backoff), api (/api/v1/relay)http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS@@@ -496,7 +539,7 @@ server/limits.js per-person write limitsrender/layout.js page shell: SEO head, shared chrome, hashed assetsrender/pages.js home / browse / paste / new / my / error templates-- render/forum.js spaces / threads / thread / new-thread templates++ render/forum.js spaces / threads / thread / new-thread / members-only teaser templatesrender/pulse.js the /pulse pagerender/comments.js a comment thread's own pagerender/markdown.js the safe Markdown subset for posts@@@ -49,6 +49,7 @@ const { createCommentService } = require('./comments/service');const { createCommentsApi } = require('./comments/api');const { createCommentPages } = require('./comments/routes');const { createForumService } = require('./forum/service');++const { createVipGate } = require('./vip');const { createSpacesApi, createPostsApi } = require('./forum/api');const { createForumRoutes } = require('./forum/routes');const { createPulse } = require('./pulse/service');@@@ -118,11 +119,13 @@ function createApp(opts = {}) {webhookVars: config.discordRelay.webhookVars,...(opts.relayOptions || {}),});-- const forum = createForumService({ db, network, pulse, relay, limits: opts.forumLimits });++ // OpenVibe.VIP: members-only spaces and threads (fails closed without a client secret or VIP).++ const vip = opts.vip || createVipGate({ config, ...(opts.vipOptions || {}) });++ const forum = createForumService({ db, network, pulse, relay, vip, limits: opts.forumLimits });const community = config.pastesAuthority === 'community';const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });seo.useForum(forum);-- Object.assign(app.locals, { db, network, pulse, relay, forum, comments });++ Object.assign(app.locals, { db, network, pulse, relay, vip, forum, comments });if (opts.startRelay !== false) relay.start();// ── Paste authority ──────────────────────────────────────@@@ -60,6 +60,20 @@ module.exports = {apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games').split(',').map((s) => s.trim().replace(/\/$/, '')).filter(Boolean),++ // OpenVibe.VIP — members-only spaces and threads (forum/service.js). Every answer comes from++ // VIP's POST /api/v1/policies/evaluate (capability vip.resource.policy.evaluate, client++ // credentials of client `community`), cached per viewer: a "yes" at most ttlMs (the convergence++ // bound after VIP stops granting), a "no" denyTtlMs, a failure unavailableTtlMs. Without a++ // client secret or with VIP down, nobody but the owner and discussion moderators gets in.++ vip: {++ internalUrl: (process.env.OV_VIP_INTERNAL_URL || 'http://127.0.0.1:4620').replace(/\/$/, ''),++ publicUrl: (process.env.OV_VIP_URL || 'https://openvibe.vip').replace(/\/$/, ''),++ timeoutMs: parseInt(process.env.VIP_TIMEOUT_MS, 10) || 2000,++ ttlMs: parseInt(process.env.VIP_CACHE_TTL_MS, 10) || 30_000,++ denyTtlMs: parseInt(process.env.VIP_CACHE_DENY_TTL_MS, 10) || 10_000,++ unavailableTtlMs: parseInt(process.env.VIP_CACHE_UNAVAILABLE_TTL_MS, 10) || 2_000,++ },++// Discord relay (outbound: new threads → a Discord webhook per mapped space). Off by default.// Webhook URLs live in environment variables named by relay_mappings.webhook_url_ref.discordRelay: {@@@ -339,6 +339,8 @@ function openDb(file) {* bits); the sequential id is for services only, so threads cannot be enumerated. Threads made* before the column existed get one here.* comments.edited_at — when the author last edited the text (null: never edited).++ * spaces.members_only_owner, threads.members_only_owner — members-only for a creator's OpenVibe.VIP++ * members (their Network subject); NULL: not gated. See forum/service.js.*/function migrate(db) {const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));@@@ -351,6 +353,11 @@ function migrate(db) {db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_comment_threads_access ON comment_threads(access_id)');const commentCols = new Set(db.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));if (!commentCols.has('edited_at')) db.exec('ALTER TABLE comments ADD COLUMN edited_at DATETIME');++ // Members-only (OpenVibe.VIP): the creator (usr_…) whose members may read and post.++ for (const table of ['spaces', 'threads']) {++ const c = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((x) => x.name));++ if (!c.has('members_only_owner')) db.exec(`ALTER TABLE ${table} ADD COLUMN members_only_owner TEXT`);++ }}/** cth_ + 22 base64url characters (16 random bytes). */@@@ -13,10 +13,15 @@* POST /spaces/:space/threads/:slug/posts { body } reply* POST /spaces/:space/threads/:slug/votes { value } 1 | -1 | 0* PUT /spaces/:space/threads/:slug/state { pinned?, locked? } moderators++ * PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)++ * PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators++ * POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions** Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and-- * moderate with community.comment.moderate. Errors are problem+json.++ * moderate with community.comment.moderate. Errors are problem+json. A members-only space or thread++ * refuses readers and writers without the creator's VIP membership with 403 vip.members_only++ * ({ reason, gate, members_only: { owner, owner_username, join_url } }).*/const express = require('express');const contracts = require('openvibe-contracts');@@@ -42,6 +47,8 @@ function createSpacesApi({ forum, viewers }) {router.post('/:space/threads/:slug/posts', write, jsonBody, run((req) => forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));router.post('/:space/threads/:slug/votes', write, jsonBody, run((req) => forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));router.put('/:space/threads/:slug/state', serviceCap(MOD), jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.put('/:space/threads/:slug/members-only', writeOrMod, jsonBody, run((req) => forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.put('/:space/members-only', serviceCap(MOD), jsonBody, run((req) => forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));return router;@@@ -9,7 +9,10 @@* GET /s/:space/feed.xml RSS: latest threads of one public space* GET /s/:space/new POST /s/:space/new start a thread* GET /s/:space/t/:slug a thread (?page=)-- * POST /s/:space/t/:slug/reply | /vote | /state | /delete no-JS forms++ * POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms++ *++ * A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the++ * creator's join link (403), never its posts.** Form posts carry the ov_token cookie (SameSite=Lax, so other sites cannot post as the* visitor); an Origin header from somewhere else is refused as well.@@@ -31,7 +34,10 @@ function createForumRoutes({ forum, viewers, config }) {/** Render an ApiError as a page: sign-in for 401, the error page for the rest. */function failPage(req, res, err, next) {if (!(err instanceof ApiError)) return next(err);-- if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete)$/, ''));++ if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete|members-only)$/, ''));++ if (err.code === 'vip.members_only' && err.extra) {++ return html(res, forumPages.membersOnlyPage({ ...err.extra, user: req.user, next: req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|new)$/, '') }), 403);++ }const titles = { 404: 'Not found', 403: 'Not allowed', 429: 'Slow down' };return html(res, pages.errorPage({ status: err.status, title: titles[err.status] || 'That did not work', message: err.message }), err.status);}@@@ -43,7 +49,7 @@ function createForumRoutes({ forum, viewers, config }) {}router.get('/s', withViewer, wrap(async (req, res, next) => {-- try { html(res, forumPages.spacesPage({ ...forum.listSpaces(req.viewer), user: req.user })); } catch (err) { failPage(req, res, err, next); }++ try { html(res, forumPages.spacesPage({ ...(await forum.listSpaces(req.viewer)), user: req.user })); } catch (err) { failPage(req, res, err, next); }}));router.get('/s/feed.xml', (_req, res) => {@@@ -64,23 +70,26 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));-- router.get('/s/:space/new', withViewer, (req, res, next) => {++ router.get('/s/:space/new', withViewer, wrap(async (req, res, next) => {try {-- const { space } = forum.space(req.viewer, req.params.space);++ // A members-only space: the teaser, not the form, for someone who may not post there.++ await forum.listThreads(req.viewer, req.params.space, { limit: 1 });++ const { space } = await forum.space(req.viewer, req.params.space);html(res, forumPages.newThreadPage({ space, user: req.user }));} catch (err) { failPage(req, res, err, next); }-- });++ }));router.post('/s/:space/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };++ if ((req.body || {}).members_only === '1') values.members_only = true;try {const out = await forum.createThread(req.viewer, req.params.space, values);seo.resetCaches();res.redirect(303, out.thread.url);} catch (err) {-- if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);++ if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);try {-- const { space } = forum.space(req.viewer, req.params.space);++ const { space } = await forum.space(req.viewer, req.params.space);html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message }), err.status);} catch (e) { failPage(req, res, e, next); }}@@@ -102,7 +111,7 @@ function createForumRoutes({ forum, viewers, config }) {const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft });res.redirect(303, out.url);} catch (err) {-- if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);++ if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);try {// The reply form lives on the thread's last page; show it there with the error and the draft.const probe = await forum.getThread(req.viewer, req.params.space, req.params.slug, {});@@@ -114,12 +123,21 @@ function createForumRoutes({ forum, viewers, config }) {const back = (req) => `/s/${encodeURIComponent(req.params.space)}/t/${encodeURIComponent(req.params.slug)}`;-- router.post('/s/:space/t/:slug/vote', withViewer, sameOrigin, form, (req, res, next) => {++ router.post('/s/:space/t/:slug/vote', withViewer, sameOrigin, form, wrap(async (req, res, next) => {try {-- forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });++ await forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });res.redirect(303, back(req));} catch (err) { failPage(req, res, err, next); }-- });++ }));++++ // Members-only on/off (no JS): on gates the thread to its author's VIP members.++ router.post('/s/:space/t/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ try {++ await forum.setThreadMembersOnly(req.viewer, req.params.space, req.params.slug, { members_only: (req.body || {}).on === '1' ? true : null });++ seo.resetCaches();++ res.redirect(303, back(req));++ } catch (err) { failPage(req, res, err, next); }++ }));router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {const b = req.body || {};@@@ -16,6 +16,16 @@** Side effects of a new thread/post: a Pulse item (public spaces) and, for threads, a Discord* relay delivery per mapping (relay/discord.js; off unless DISCORD_RELAY_ENABLED).++ *++ * Members-only (OpenVibe.VIP): a space or a single thread can be for one creator's VIP members++ * (members_only_owner = the creator's usr_ subject). Reading its threads and posts, starting a++ * thread, replying, voting and editing need an active entitlement, asked of VIP++ * (POST /policies/evaluate with that owner, through vip/index.js's cache); a gated thread in a gated++ * space needs both. The owner and discussion moderators always pass; every doubt (signed out, VIP++ * down, no grant) is a 403 vip.members_only with the reason and a join link. Listings keep a gated++ * thread's title with its members_only flag, never a body; gated things never reach Pulse, the Discord++ * relay, sitemaps or feeds. Moderators gate spaces (for any creator) and threads; a thread's author++ * gates it to their own members.*/const store = require('./store');const { applyVote, myVotes, parseVote } = require('../votes');@@@ -24,6 +34,7 @@ const { createAuthors } = require('../identity/authors');const { createPersonLimiter } = require('../limits');const { discussionModerator } = require('../identity/capabilities');const { renderMarkdown } = require('../render/markdown');++const { isUserSubject } = require('../vip');const THREADS_PER_PAGE = 25;const POSTS_PER_PAGE = 50;@@@ -31,7 +42,7 @@ const TITLE_MIN = 3, TITLE_MAX = 200;const BODY_MAX = 40_000;const THREADS_PER_DAY = 20;--function createForumService({ db, network = null, pulse = null, relay = null, limits = {} } = {}) {++function createForumService({ db, network = null, pulse = null, relay = null, vip = null, limits = {} } = {}) {const authors = createAuthors({ db, network });const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });@@@ -49,6 +60,47 @@ function createForumService({ db, network = null, pulse = null, relay = null, lireturn moderator(v);}++ // ── members-only (OpenVibe.VIP) ──────────────────────────++ /** { owner, join_url } for a gated row, null for an open one. */++ async function membersOnly(owner, projections = null) {++ if (!owner) return null;++ const p = (projections || await authors.projectionsFor([owner])).get(owner);++ const username = p && p.username ? p.username : null;++ return { owner, owner_username: username, join_url: vip ? vip.joinUrl(owner, username) : null };++ }++++ /**++ * Throw 403 vip.members_only unless the viewer may use every gated level named: the space and/or++ * the thread. Moderators pass; the owner passes; the rest is VIP's answer (fail closed).++ */++ async function requireMembership(v, space, thread = null) {++ if (moderator(v)) return;++ const gates = [];++ if (space && space.members_only_owner) gates.push({ type: 'space', id: space.slug, owner: space.members_only_owner });++ if (thread && thread.members_only_owner) gates.push({ type: 'thread', id: String(thread.id), owner: thread.members_only_owner });++ for (const g of gates) {++ const subject = person(v) ? v.subject : null;++ const d = vip ? await vip.check({ subject, type: g.type, id: g.id, owner: g.owner })++ : (subject && subject === g.owner ? { allow: true } : { allow: false, reason: 'vip_unavailable' });++ if (d.allow) continue;++ const mo = await membersOnly(g.owner);++ fail(403, 'vip.members_only', `Only members of this creator's OpenVibe.VIP can read and post in this ${g.type}`, {++ reason: d.reason || 'denied', gate: g.type, members_only: mo,++ space: { slug: space.slug, name: space.name, description: space.description || null },++ thread: thread ? { slug: thread.slug, title: thread.title } : null,++ });++ }++ }++++ /** A creator subject a viewer may gate something to: themselves, or anyone for moderators. */++ function gateOwner(v, requested, fallbackOwner) {++ if (requested === null || requested === false || requested === undefined) return null;++ const owner = requested === true ? fallbackOwner : (typeof requested === 'object' ? requested.owner : requested);++ if (!isUserSubject(owner)) fail(400, 'members_only.invalid_owner', 'members_only.owner must be the creator\'s Network subject (usr_…)');++ if (!moderator(v) && !(person(v) && owner === v.subject)) fail(403, 'members_only.not_yours', 'You can only make things members-only for your own VIP members');++ return owner;++ }++/** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */function spaceFor(v, slug) {const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? store.getSpace(db, slug) : null;@@@ -96,8 +148,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, liconst threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;function shapeThread(t, space, v, projections, votes) {++ const mo = t.members_only_owner || null;++ const p = mo && projections ? projections.get(mo) : null;return {id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),++ members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count,last_activity_at: isoTime(t.last_activity_at), created_at: isoTime(t.created_at),@@@ -120,19 +175,30 @@ function createForumService({ db, network = null, pulse = null, relay = null, li}async function shapeThreads(rows, spaceOf, v) {-- const projections = await authors.projectionsFor(rows.map((r) => r.author_subject));++ const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner)]);const votes = myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);return rows.map((r) => shapeThread(r, spaceOf(r), v, projections, votes));}-- function shapeSpace(s) {++ function shapeSpace(s, mo = null) {return {slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,++ members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,thread_count: s.thread_count != null ? s.thread_count : undefined,last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,};}++ /** Newly gated threads leave Pulse and the Discord relay queue at once (reads re-check as well). */++ function hideGated(threadIds) {++ if (!threadIds.length) return;++ if (pulse) hook(() => { for (const id of threadIds) { pulse.threadGone(id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(id)) pulse.postGone(p.id); } });++ hook(() => {++ const del = db.prepare("DELETE FROM relay_deliveries WHERE thread_id = ? AND status = 'pending'");++ for (const id of threadIds) del.run(id);++ });++ }++function removeThread(v, thread) {if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');store.softDeleteThread(db, thread.id);@@@ -148,25 +214,31 @@ function createForumService({ db, network = null, pulse = null, relay = null, liisModerator: moderator,-- /** Spaces this viewer can open, with thread counts. */-- listSpaces(v) {++ /** Spaces this viewer can open, with thread counts (members-only ones carry members_only). */++ async listSpaces(v) {const vis = ['public'];if (person(v) || moderator(v)) vis.push('members');if (moderator(v)) vis.push('staff');-- return { spaces: store.listSpaces(db, vis).map(shapeSpace) };++ const rows = store.listSpaces(db, vis);++ const projections = await authors.projectionsFor(rows.map((r) => r.members_only_owner));++ return { spaces: await Promise.all(rows.map(async (r) => shapeSpace(r, await membersOnly(r.members_only_owner, projections)))) };},-- space(v, slug) { return { space: shapeSpace(spaceFor(v, slug)) }; },++ async space(v, slug) {++ const s = spaceFor(v, slug);++ return { space: shapeSpace(s, await membersOnly(s.members_only_owner)) };++ },/** A page of threads. ?sort=hot|new|top&page= */async listThreads(v, spaceSlug, q = {}) {const space = spaceFor(v, spaceSlug);++ await requireMembership(v, space);const sort = store.SORTS.includes(q.sort) ? q.sort : 'hot';const page = Math.max(parseInt(q.page, 10) || 1, 1);const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);const { rows, total } = store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date() });return {-- space: shapeSpace(space), sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),++ space: shapeSpace(space, await membersOnly(space.members_only_owner)), sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),threads: await shapeThreads(rows, () => space, v),};},@@@ -174,12 +246,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, li/** A thread with a page of its posts. ?page= */async getThread(v, spaceSlug, threadSlug, q = {}) {const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ await requireMembership(v, space, thread);const page = Math.max(parseInt(q.page, 10) || 1, 1);const { rows, total } = store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });-- const projections = await authors.projectionsFor([thread.author_subject, ...rows.map((p) => p.author_subject)]);++ const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner, space.members_only_owner, ...rows.map((p) => p.author_subject)]);const votes = myVotes(db, 'thread', [thread.id], v && v.subject);return {-- space: shapeSpace(space),++ space: shapeSpace(space, await membersOnly(space.members_only_owner, projections)),thread: shapeThread(thread, space, v, projections, votes),posts: rows.map((p) => shapePost(p, v, projections)),page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,@@@ -189,15 +262,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, lican_vote: person(v) && !thread.locked,can_moderate: moderator(v),can_delete: moderator(v) || (person(v) && thread.author_subject === v.subject),++ can_gate: moderator(v) || (person(v) && thread.author_subject === v.subject),},};},-- /** New thread { title, body } → { thread, post } */++ /**++ * New thread { title, body, members_only? } → { thread, post }. members_only: true gates it to++ * the author's own VIP members; { owner } names the creator (moderators, or the author themselves).++ */async createThread(v, spaceSlug, body = {}) {const space = spaceFor(v, spaceSlug);const w = writer(v);mayPostIn(v, space);++ await requireMembership(v, space);++ const gate = gateOwner(v, body.members_only, w.author);const title = cleanTitle(body.title);if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);const text = cleanBody(body.body != null ? body.body : body.body_markdown);@@@ -205,11 +284,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, lithreadLimiter.check(w.key, title);if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);}-- const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text });++ const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate });threadLimiter.record(w.key, title);if (pulse) hook(() => pulse.threadCreated(thread, space));if (relay) hook(() => relay.enqueueThread(thread, space));-- const projections = await authors.projectionsFor([thread.author_subject]);++ const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections) };},@@@ -218,6 +297,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, liconst { space, thread } = threadFor(v, spaceSlug, threadSlug);const w = writer(v);mayPostIn(v, space);++ await requireMembership(v, space, thread);if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');const text = cleanBody(body.body != null ? body.body : body.body_markdown);postLimiter.check(w.key, text);@@@ -233,7 +313,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, li/** Edit { body } — the author (not on a locked thread) or a moderator. */async editPost(v, postId, body = {}) {-- const { post, thread } = postFor(v, postId);++ const { post, thread, space } = postFor(v, postId);++ await requireMembership(v, space, thread);const mine = person(v) && post.author_subject === v.subject;if (!mine && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');@@@ -243,8 +324,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, li},/** Edit history — the author or a moderator. */-- postVersions(v, postId) {-- const { post } = postFor(v, postId);++ async postVersions(v, postId) {++ const { post, thread, space } = postFor(v, postId);++ await requireMembership(v, space, thread);if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');const list = store.listPostVersions(db, post.id).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };@@@ -267,11 +349,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, li},/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */-- voteThread(v, spaceSlug, threadSlug, body = {}) {++ async voteThread(v, spaceSlug, threadSlug, body = {}) {const value = parseVote(body.value);if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');-- const { thread } = threadFor(v, spaceSlug, threadSlug);++ const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ await requireMembership(v, space, thread);if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');voteLimiter.check(`s:${v.subject}`);const out = applyVote(db, 'thread', thread.id, v.subject, value);@@@ -292,6 +375,36 @@ function createForumService({ db, network = null, pulse = null, relay = null, lireturn { thread: shapeThread(next, space, v, projections, null) };},++ /**++ * Members-only for a thread { owner: 'usr_…' | null } (or { members_only: … }). Its author gates++ * it to themselves (and opens it again); moderators name any creator. Gating takes it out of++ * Pulse and cancels pending Discord relay deliveries.++ */++ async setThreadMembersOnly(v, spaceSlug, threadSlug, body = {}) {++ const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const mine = person(v) && thread.author_subject === v.subject;++ if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');++ await requireMembership(v, space, null);++ const requested = body.members_only !== undefined ? body.members_only : body.owner;++ const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject);++ if (!owner && thread.members_only_owner && !moderator(v) && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');++ const next = store.setThreadMembersOnly(db, thread.id, owner);++ if (owner) hideGated([thread.id]);++ const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);++ return { thread: shapeThread(next, space, v, projections, null) };++ },++++ /** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */++ async setSpaceMembersOnly(v, spaceSlug, body = {}) {++ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators make a space members-only');++ const space = spaceFor(v, spaceSlug);++ const requested = body.members_only !== undefined ? body.members_only : body.owner;++ const owner = gateOwner(v, requested === undefined ? null : requested, null);++ const next = store.setSpaceMembersOnly(db, space.id, owner);++ if (owner) hideGated(db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id).map((r) => r.id));++ return { space: shapeSpace(next, await membersOnly(next.members_only_owner)) };++ },++/** Latest threads in public spaces, with their opening post (sitemap, feeds). */recentPublic({ limit = 50, space = null } = {}) {const rows = store.recentThreads(db, { visibilities: ['public'], limit, spaceSlug: space });@@@ -299,7 +412,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, lireturn rows.map((t) => ({ ...t, opening: (opening.get(t.id) || {}).body_markdown || '' }));},-- publicSpaces() { return store.listSpaces(db, ['public']).map(shapeSpace); },++ /** Public, open spaces (sitemap, feeds): members-only spaces are left out. */++ publicSpaces() { return store.listSpaces(db, ['public']).filter((s) => !s.members_only_owner).map((s) => shapeSpace(s)); },};}@@@ -57,11 +57,11 @@ function getThreadBySlug(db, spaceId, slug) {}/** New thread + its opening post, in one transaction. → { thread, post } */--function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown }) {++function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null }) {return db.transaction(() => {const slug = uniqueSlug(db, space_id, slugify(title));-- const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin) VALUES (?, ?, ?, ?, ?)')-- .run(space_id, slug, title, author_subject, origin);++ const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner) VALUES (?, ?, ?, ?, ?, ?)')++ .run(space_id, slug, title, author_subject, origin, members_only_owner);const threadId = info.lastInsertRowid;const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)').run(threadId, author_subject, origin, body_markdown);@@@ -89,10 +89,11 @@ function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now =return { rows, total };}--/** Latest threads across spaces of the given visibilities (sitemap, feeds). */++/** Latest threads across spaces of the given visibilities (sitemap, feeds); members-only ones never. */function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {return db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_idWHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (? IS NULL OR s.slug = ?)++ AND s.members_only_owner IS NULL AND t.members_only_owner IS NULLORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);}@@@ -104,6 +105,16 @@ function setThreadFlags(db, id, { pinned, locked }) {return getThread(db, id);}++/** Members-only for a creator's VIP members (owner = their usr_ subject), or open (null). */++function setThreadMembersOnly(db, id, owner) {++ db.prepare('UPDATE threads SET members_only_owner = ? WHERE id = ?').run(owner || null, id);++ return getThread(db, id);++}++function setSpaceMembersOnly(db, id, owner) {++ db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(owner || null, id);++ return getSpaceById(db, id);++}++function softDeleteThread(db, id) {return db.prepare('UPDATE threads SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(id).changes;}@@@ -177,6 +188,6 @@ function countPostsSince(db, subject, sinceSql) {module.exports = {SORTS, slugify,listSpaces, getSpace, getSpaceById,-- getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, softDeleteThread, countThreadsSince,++ getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,};@@@ -76,14 +76,14 @@ function createPulse({ db, network = null, config = {} } = {}) {},pasteGone(slug) { forgetLocal('paste', slug); },threadCreated(thread, space) {-- if (!thread || !space || space.visibility !== 'public') return;++ if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;recordLocal('thread', thread.id, {title: thread.title, path: `/s/${space.slug}/t/${thread.slug}`,actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,});},postCreated(post, thread, space) {-- if (!post || !thread || !space || space.visibility !== 'public' || post.is_opening) return;++ if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;recordLocal('post', post.id, {title: `Re: ${thread.title}`, path: `/s/${space.slug}/t/${thread.slug}#post-${post.id}`,actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,@@@ -30,7 +30,7 @@ function removeItem(db, service, type, id) {/*** Newest first, keyset-paginated by (occurred_at, id). Community's own items are re-checked* against their source at read time — a paste made private, a deleted post or a thread in a-- * space that stopped being public never shows, even if nothing removed its item.++ * space that stopped being public (or became members-only) never shows, even if nothing removed its item.* opts.origin 'user' | 'ai' | 'system'* opts.before [occurred_at, id] of the last item already seen* → { rows, hasMore }@@@ -43,9 +43,11 @@ function listItems(db, { origin = null, before = null, limit = 30 } = {}) {AND (i.source_service <> 'community'OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public'))OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id-- WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.visibility = 'public'))++ WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.visibility = 'public'++ AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_id-- WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public')))++ WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'++ AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL)))ORDER BY i.occurred_at DESC, i.id DESCLIMIT @limit`).all({ origin, at: before ? before[0] : null, id: before ? before[1] : null, limit: limit + 1 });const hasMore = rows.length > limit;@@@ -51,6 +51,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glif (!enabled || !thread || !space) return 0;if (thread.origin === 'discord') return 0; // loop preventionif (space.visibility !== 'public') return 0; // members/staff spaces never leave the site++ if (space.members_only_owner || thread.members_only_owner) return 0; // nor do VIP members-only onesconst mappings = db.prepare("SELECT * FROM relay_mappings WHERE space_id = ? AND direction = 'out' AND enabled = 1").all(space.id);let queued = 0;const insert = db.prepare(`INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, ?, ?, ?)@@@ -108,6 +109,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = glconst space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;if (!thread || thread.deleted_at || !space) return record(d, { ok: false, error: 'thread is gone' });if (thread.origin === 'discord') return record(d, { ok: false, error: 'loop prevention: thread came from Discord' });++ if (space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return record(d, { ok: false, error: 'the thread is no longer public (members-only or a restricted space)' });if (!refAllowed(d.webhook_url_ref)) return record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });const url = env[d.webhook_url_ref];if (!url) return record(d, { ok: false, retry: true, error: `webhook URL variable ${d.webhook_url_ref} is not set` });@@@ -38,7 +38,10 @@ function pager(href, page, pages, labels = ['Newer', 'Older']) {</nav>`;}--const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '');++const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '') + vipBadge(s);++/** A members-only (OpenVibe.VIP) space or thread. */++const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span>` : '');++const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');// ── /s ───────────────────────────────────────────────────────function spacesPage({ spaces }) {@@@ -73,7 +76,7 @@ function threadRow(t, space) {return `<li class="thread-row"><span class="thread-score" title="Score">${num(t.score)}</span><div class="thread-main">-- <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>++ <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${vipBadge(t)}<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p></div></li>`;@@@ -81,7 +84,7 @@ function threadRow(t, space) {function spacePage({ space, threads, sort, page, pages, total, user }) {const tabs = Object.keys(SORT_LABELS).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');-- const indexable = space.visibility === 'public';++ const indexable = space.visibility === 'public' && !space.members_only;const body = `<header class="page-head"><nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav>@@@ -127,7 +130,8 @@ function voteForm(base, thread, viewer) {function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '' }) {const base = `/s/${space.slug}/t/${thread.slug}`;const opening = posts.find((p) => p.is_opening) || null;-- const indexable = space.visibility === 'public';++ const gated = !!(space.members_only || thread.members_only);++ const indexable = space.visibility === 'public' && !gated;const first = (page - 1) * perPage;const replyBlock = viewer.can_reply? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">@@@ -141,6 +145,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form><form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}++ ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}</div>` : '';const body = `@@@ -149,7 +154,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u<header class="thread-head">${voteForm(base, thread, viewer)}<div>-- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}</h1>++ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1><p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p></div></header>@@@ -158,7 +163,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}</article>`;-- const description = seo.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title;++ const description = gated ? `A thread for VIP members in ${space.name} on OpenVibe.Community.` : (seo.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);return renderPage({title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,description,@@@ -173,7 +178,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, ufooterVariant: 'compact',feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],jsonLd: [-- seo.threadLd({ space, thread, posts, opening, description }),++ ...(gated ? [] : [seo.threadLd({ space, thread, posts, opening, description })]),seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),],body,@@@ -186,6 +191,7 @@ function newThreadPage({ space, user, values = {}, error = null }) {${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label><label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>++ <label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label><div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button><span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>@@@ -208,4 +214,39 @@ ${form}`;});}--module.exports = { spacesPage, spacePage, threadPage, newThreadPage, spaceHref, who };++// ── members-only teaser (403) ────────────────────────────────++/**++ * What someone without the creator's VIP membership sees: the space (and thread title), why, and++ * the join link — never a post. `reason` is VIP's (not_signed_in, not_a_member, vip_unavailable, …).++ */++function membersOnlyPage({ space, thread = null, members_only: mo = null, reason, user, next = '/s' }) {++ const creator = mo && mo.owner_username ? `@${mo.owner_username}` : 'this creator';++ const why = reason === 'not_signed_in' || !user++ ? `<p><a class="btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> if you are already a member.</p>`++ : reason === 'vip_unavailable' || reason === 'entitlement_unknown'++ ? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>'++ : '<p class="muted">Your account does not have an active membership for this.</p>';++ const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(seo.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;++ const body = `++<header class="page-head">++ ${crumbs}++ <h1>${esc(thread ? thread.title : space.name)} <span class="badge badge-vis badge-vip"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span></h1>++ ${!thread && space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}++</header>++<section class="members-only">++ <p>This ${thread ? 'thread' : 'space'} is for members of ${esc(creator)} on OpenVibe.VIP. Members read and reply; everyone else sees this page.</p>++ <p>${vipJoin(mo)}</p>++ ${why}++</section>`;++ return renderPage({++ title: `${thread ? thread.title : space.name} · members only`,++ description: `For ${creator}'s OpenVibe.VIP members on OpenVibe.Community.`,++ canonicalPath: thread ? `/s/${space.slug}/t/${thread.slug}` : `/s/${space.slug}`,++ robots: 'noindex,nofollow',++ active: 'spaces',++ footerVariant: 'compact',++ body,++ });++}++++module.exports = { spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who };nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++++/**++ * Community's seam to OpenVibe.VIP: members-only forum spaces and threads.++ *++ * gate.check({ subject, type: 'space'|'thread', id, owner }) → { allow, reason }++ * POST /api/v1/policies/evaluate through the product cache (createVipCache), with the owner++ * Community stored on the row and the product's default gate++ * { requirement: 'member', binding: 'community:members_only' } — the creator's own VIP rule for++ * the resource, if they set one, wins. The owner passes without asking VIP.++ * gate.joinUrl(owner, username) → the creator's page on openvibe.vip++ * gate.cache invalidate / handleEvent (vip.membership.changed, …) / clear++ *++ * Convergence: Community has no Events inbox, so a cached "yes" outlives VIP's "no" by at most++ * config.vip.ttlMs (30 s by default); see OpenVibe.VIP's README for the end-to-end bound.++ * Fails closed: no client secret, VIP down, a refused token or a malformed answer is a "no".++ */++const { serviceAuth, ids } = require('openvibe-contracts');++const { createVipClient, createVipCache } = require('./vip-client');++++const FALLBACK = Object.freeze({ requirement: 'member', binding: 'community:members_only' });++++function createVipGate({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {++ const vipConfig = config.vip || {};++ const secret = config.oauth && config.oauth.clientSecret;++ const tokenClient = secret ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: secret,++ audience: 'openvibe.vip',++ scope: 'vip.resource.policy.evaluate',++ fetchImpl,++ }) : null;++ const client = createVipClient({++ baseUrl: vipConfig.internalUrl || 'http://127.0.0.1:4620',++ tokenClient,++ getToken: tokenClient ? null : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set'); },++ fetch: fetchImpl,++ timeoutMs: vipConfig.timeoutMs || 2000,++ log,++ });++ const cache = createVipCache({++ vip: client,++ ttlMs: vipConfig.ttlMs || 30_000,++ denyTtlMs: vipConfig.denyTtlMs || 10_000,++ unavailableTtlMs: vipConfig.unavailableTtlMs || 2_000,++ now,++ });++++ async function check({ subject, type, id, owner }) {++ if (!owner) return { allow: true, reason: 'open' };++ if (subject && subject === owner) return { allow: true, reason: 'owner' };++ if (!subject) return { allow: false, reason: 'not_signed_in' };++ const d = await cache.evaluate({ subject, resource: { service: 'community', type, id: String(id) }, owner, fallback: FALLBACK });++ return { allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };++ }++++ const base = (vipConfig.publicUrl || 'https://openvibe.vip').replace(/\/$/, '');++ const joinUrl = (owner, username) => `${base}/${encodeURIComponent(username || owner)}`;++++ return { check, joinUrl, cache, client, FALLBACK, bounds: cache.bounds };++}++++const isUserSubject = (s) => typeof s === 'string' && ids.isSubjectId('user', s);++++module.exports = { createVipGate, isUserSubject, FALLBACK };nnew file mode 100644@@@ -0,0 +1,235 @@++// Vendored verbatim from OpenVibe.VIP client/vip-client.js at 2accbeb (openvibe-vip/client). Replace++// with the pinned package (require('openvibe-vip/client')) once OpenVibe.VIP publishes a tag; do not edit here.++'use strict';++++/**++ * openvibe-vip/client — the consumer seam for products that honour VIP memberships (Blog, Wiki,++ * Chat, Live, Community, …). No dependencies; Node 18+ (global fetch) or any fetch you pass.++ *++ * const { createVipClient } = require('openvibe-vip/client');++ * const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl, clientId, clientSecret,++ * audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });++ * const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });++ *++ * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' }, owner: authorSubject,++ * fallback: { requirement: 'member', binding: 'blog:gated_post' } }); // the product's default gate++ * if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED++ *++ * const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…', product: 'chat' }); // e.active, e.product_perks++ * await vip.isMember('usr_…', 'usr_…'); // boolean, false on any doubt++ *++ * Every failure — VIP unreachable, a timeout, a refused token, a malformed answer — is a denial++ * ({ allow: false, reason: 'vip_unavailable' } / { status: 'unknown', active: false }), never an++ * exception and never a "yes". Pass mode: 'authoritative' for sensitive content: VIP then asks++ * OpenVibe.Billing directly instead of its projection.++ *++ * Put createVipCache (below) in front of it: it holds the convergence bound every product shares.++ */++++function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null, getToken = null, fetch: fetchImpl = globalThis.fetch, timeoutMs = 2000, log = null } = {}) {++ if (typeof fetchImpl !== 'function') throw new TypeError('createVipClient: a fetch implementation is required');++ const base = String(baseUrl).replace(/\/+$/, '');++++ async function headers() {++ if (tokenClient && typeof tokenClient.authHeaders === 'function') return tokenClient.authHeaders();++ if (typeof getToken === 'function') return { Authorization: `Bearer ${await getToken()}` };++ throw new Error('createVipClient: tokenClient or getToken is required');++ }++++ async function post(path, body, retried = false) {++ const res = await fetchImpl(`${base}${path}`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await headers()) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(timeoutMs),++ });++ if (res.status === 401 && !retried && tokenClient && typeof tokenClient.invalidate === 'function') { tokenClient.invalidate(); return post(path, body, true); }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || typeof data !== 'object') throw new Error(`VIP ${res.status}: ${(data && (data.code || data.detail)) || 'bad response'}`);++ return data;++ }++ const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };++++ /** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */++ async function evaluate({ subject = null, resource, owner = null, ruleId, mode, fallback } = {}) {++ try {++ const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, owner, rule_id: ruleId, mode, fallback });++ return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };++ } catch (err) {++ note('evaluate', err);++ return { allow: false, reason: 'vip_unavailable', error: err.message };++ }++ }++++ /** The entitlement of `subject` to `creator` (usr_ ids or SubjectRefs). */++ async function checkEntitlement({ subject, creator, mode, product } = {}) {++ try {++ const e = await post('/api/v1/entitlements/check', { subject, creator, mode, product });++ return { ...e, active: e.active === true && e.status === 'active' };++ } catch (err) {++ note('checkEntitlement', err);++ return { status: 'unknown', active: false, reason: 'vip_unavailable', error: err.message };++ }++ }++++ const isMember = async (subject, creator, opts = {}) => (await checkEntitlement({ subject, creator, mode: opts.mode })).active;++++ return { evaluate, checkEntitlement, isMember };++}++++/**++ * createVipCache({ vip, ttlMs, denyTtlMs, unavailableTtlMs, maxEntries, now }) — the product-side++ * cache every consumer puts in front of the client, with ONE convergence rule:++ *++ * A cached "yes" lives at most ttlMs (and never past the entitlement's expires_at or VIP's++ * valid_until). So once VIP stops granting — it has applied Billing's billing.entitlement.changed++ * and emitted vip.membership.changed — the product stops granting within ttlMs. handleEvent(envelope)++ * with either event (or billing.subscription.canceled / billing.transaction.reversed) drops that++ * member's entries for that creator at once, so a product that receives the events converges++ * immediately. A "no" lives denyTtlMs (a new member waits at most that long); a failure++ * (vip_unavailable / unknown) lives unavailableTtlMs, and a cached "yes" is never extended by a++ * failure: when VIP is down, entries expire into denials (fail closed).++ *++ * await cache.entitlement({ subject, creator, product }) → the checkEntitlement answer (cached)++ * cache.peekEntitlement({ subject, creator, product }) → the cached answer or undefined; never waits++ * (a miss starts a fetch in the background)++ * await cache.evaluate({ subject, resource, owner, fallback, mode }) → the evaluate answer (cached;++ * mode 'authoritative' is never cached)++ * cache.invalidate({ member, creator }), cache.handleEvent(envelope), cache.clear()++ */++function createVipCache({ vip, ttlMs = 30_000, denyTtlMs = 10_000, unavailableTtlMs = 2_000, maxEntries = 5000, now = () => Date.now() } = {}) {++ if (!vip || typeof vip.evaluate !== 'function' || typeof vip.checkEntitlement !== 'function') throw new TypeError('createVipCache: a VIP client is required');++ const entries = new Map(); // key → { value, expires, member, creator }++ const inflight = new Map(); // key → Promise++ let generation = 0; // bumped by clear(); an answer fetched before a clear is not stored++ const pairGen = new Map(); // `${member}|${creator}` → generation of its last invalidation++++ const idOf = (v) => (v && typeof v === 'object' ? (v.id != null ? String(v.id) : null) : (v != null && v !== '' ? String(v) : null));++ const pairKey = (member, creator) => `${member || ''}|${creator || ''}`;++++ function lifetime(value, allowed) {++ const t = now();++ if (value && (value.reason === 'vip_unavailable' || value.status === 'unknown' || value.reason === 'entitlement_unknown')) return unavailableTtlMs;++ if (!allowed) return denyTtlMs;++ let life = ttlMs;++ const ent = value.entitlement !== undefined ? value.entitlement : value;++ for (const at of [ent && ent.expires_at, ent && ent.valid_until]) {++ const ms = at ? Date.parse(at) : NaN;++ if (Number.isFinite(ms)) life = Math.min(life, ms - t);++ }++ if (ent && ent.stale) life = Math.min(life, unavailableTtlMs);++ return Math.max(0, life);++ }++++ function store(key, value, allowed, member, creator, startedGen, startedPair) {++ if (startedGen !== generation || (pairGen.get(pairKey(member, creator)) || 0) !== startedPair) return; // invalidated meanwhile++ const life = lifetime(value, allowed);++ if (life <= 0) { entries.delete(key); return; }++ if (entries.size >= maxEntries) {++ const t = now();++ for (const [k, e] of entries) if (e.expires <= t) entries.delete(k);++ while (entries.size >= maxEntries) entries.delete(entries.keys().next().value);++ }++ entries.set(key, { value, expires: now() + life, member, creator });++ }++++ function fresh(key) {++ const e = entries.get(key);++ if (!e) return undefined;++ if (e.expires <= now()) { entries.delete(key); return undefined; }++ return e.value;++ }++++ function load(key, member, creator, fetcher, allowedOf) {++ const hit = fresh(key);++ if (hit !== undefined) return Promise.resolve(hit);++ if (inflight.has(key)) return inflight.get(key);++ const g = generation;++ const pg = pairGen.get(pairKey(member, creator)) || 0;++ const p = (async () => {++ try {++ const value = await fetcher();++ store(key, value, allowedOf(value), member, creator, g, pg);++ return value;++ } finally { inflight.delete(key); }++ })();++ inflight.set(key, p);++ return p;++ }++++ function entitlement({ subject, creator, product = null, mode } = {}) {++ const member = idOf(subject);++ const owner = idOf(creator);++ if (!member || !owner) return Promise.resolve({ status: 'inactive', active: false, reason: 'not_signed_in' });++ if (mode === 'authoritative') return vip.checkEntitlement({ subject: member, creator: owner, mode, product: product || undefined });++ const key = `e|${member}|${owner}|${product || ''}`;++ return load(key, member, owner, () => vip.checkEntitlement({ subject: member, creator: owner, product: product || undefined }), (v) => v.active === true);++ }++++ function peekEntitlement(args = {}) {++ const member = idOf(args.subject);++ const owner = idOf(args.creator);++ if (!member || !owner) return undefined;++ const key = `e|${member}|${owner}|${args.product || ''}`;++ const hit = fresh(key);++ if (hit === undefined) entitlement(args).catch(() => {});++ return hit;++ }++++ function evaluate({ subject = null, resource, owner, fallback, mode } = {}) {++ const member = idOf(subject);++ const creator = idOf(owner);++ if (mode === 'authoritative' || !resource || typeof resource !== 'object') return vip.evaluate({ subject: member, resource, owner: creator, fallback, mode });++ if (!member) return Promise.resolve({ allow: false, reason: 'not_signed_in' });++ const fb = fallback == null ? '' : JSON.stringify(fallback);++ const key = `p|${member}|${creator || ''}|${resource.service}|${resource.type}|${resource.id}|${fb}`;++ return load(key, member, creator, () => vip.evaluate({ subject: member, resource, owner: creator, fallback }), (v) => v.allow === true);++ }++++ /** Drop what the cache knows about (member, creator); either may be omitted to drop more. */++ function invalidate({ member = null, creator = null } = {}) {++ const m = idOf(member);++ const c = idOf(creator);++ if (m && c) pairGen.set(pairKey(m, c), (pairGen.get(pairKey(m, c)) || 0) + 1);++ else generation++;++ let dropped = 0;++ for (const [k, e] of entries) {++ if ((!m || e.member === m) && (!c || e.creator === c)) { entries.delete(k); dropped++; }++ }++ return dropped;++ }++++ /**++ * An OpenVibe.Events envelope about a membership → invalidate that pair. Returns true when the++ * event was understood. vip.membership.changed { member, creator }; billing.entitlement.changed++ * { subject, streamer }; billing.subscription.canceled { subscription: { subscriber, streamer } };++ * billing.transaction.reversed { to_subject (member), from_subject (creator) }.++ */++ function handleEvent(envelope) {++ const e = envelope && envelope.event && envelope.event.event_type ? envelope.event : envelope;++ const p = (e && e.payload) || {};++ let member = null;++ let creator = null;++ switch (e && e.event_type) {++ case 'vip.membership.changed': member = idOf(p.member); creator = idOf(p.creator); break;++ case 'billing.entitlement.changed': member = idOf(p.subject); creator = idOf(p.streamer); break;++ case 'billing.subscription.canceled': member = idOf(p.subscription && p.subscription.subscriber); creator = idOf(p.subscription && p.subscription.streamer); break;++ case 'billing.transaction.reversed': member = idOf(p.to_subject); creator = idOf(p.from_subject); break;++ default: return false;++ }++ if (!member || !creator) return false;++ invalidate({ member, creator });++ return true;++ }++++ function clear() { generation++; entries.clear(); }++++ return {++ entitlement, peekEntitlement, evaluate, invalidate, handleEvent, clear,++ get size() { return entries.size; },++ /** The convergence bound, for docs and /ready: how long a cached "yes" can outlive VIP's "no". */++ bounds: Object.freeze({ grantMs: ttlMs, denyMs: denyTtlMs, unavailableMs: unavailableTtlMs }),++ };++}++++module.exports = { createVipClient, createVipCache };nnew file mode 100644@@@ -0,0 +1,264 @@++'use strict';++/**++ * Members-only discussion (OpenVibe.VIP): a forum space or a single thread gated to one creator's++ * VIP members. Reading and posting need VIP's yes (POST /api/v1/policies/evaluate with the owner++ * Community stored, through the product cache); the owner and moderators pass; signed-out, strangers,++ * VIP down and no client secret are refused (fail closed); nothing gated reaches Pulse, the sitemap,++ * feeds, JSON-LD or the SSR page of someone without access. Convergence: once VIP stops granting,++ * Community stops within the cache TTL (30 s), and at once on vip.membership.changed.++ */++const http = require('http');++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++/** A stand-in for VIP's evaluate: answers from `allow` (member subject → true), records calls. */++function startVip() {++ const state = { allow: new Set(), down: false, calls: [] };++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ if (state.down) { req.socket.destroy(); return; }++ let body = {};++ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* */ }++ state.calls.push({ path: req.url, auth: req.headers.authorization || '', body });++ if (req.method !== 'POST' || req.url !== '/api/v1/policies/evaluate') { res.writeHead(404); return res.end('{}'); }++ const yes = state.allow.has(`${body.subject}|${body.owner}`);++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify(yes++ ? { allow: true, reason: 'member', rule: null, fallback: true, entitlement: { status: 'active', active: true, expires_at: '2100-01-01T00:00:00.000Z', valid_until: '2100-01-01T00:00:00.000Z' } }++ : { allow: false, reason: 'not_a_member', rule: null, fallback: true, entitlement: { status: 'inactive', active: false } }));++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ ...state, state, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));++}++++(async () => {++ const vip = await startVip();++ process.env.OV_VIP_INTERNAL_URL = vip.url;++ process.env.OV_VIP_URL = 'https://openvibe.vip';++ const clock = { t: Date.now() };++ const t = await boot({++ authority: 'community',++ appOpts: {++ vipOptions: { now: () => clock.t },++ forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, votes: { perMinute: 1000 }, threadsPerDay: 1000 },++ },++ });++ const net = t.network;++ const mk = (id, username, role = 'user') => {++ const u = net.addUser({ network_user_id: id, username, display_name: username });++ return { ...u, jwt: net.sign({ id, subject_id: u.subject_id, username, display_name: username, role }) };++ };++ const cora = mk(21, 'cora'); // the creator++ const mia = mk(22, 'mia'); // her member++ const stan = mk(23, 'stan'); // not a member++ const newbie = mk(24, 'newbie'); // a member VIP cannot confirm while down++ const conv = mk(25, 'conv'); // convergence++ const boss = mk(26, 'boss', 'global_mod');++ vip.state.allow.add(`${mia.subject_id}|${cora.subject_id}`);++ vip.state.allow.add(`${newbie.subject_id}|${cora.subject_id}`);++++ const call = (path, { method = 'GET', who, json, headers = {} } = {}) => {++ const h = { ...headers };++ let body;++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }++ return t.get(path, { method, headers: h, body, cookies: who ? [`ov_token=${who.jwt}`] : [] });++ };++ const form = (path, who, fields) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString(), cookies: who ? [`ov_token=${who.jwt}`] : [] });++ const evaluateCalls = () => vip.state.calls.filter((c) => c.path === '/api/v1/policies/evaluate').length;++ let secret; // a gated thread in a public space++ let showcaseThread; // a thread in the gated space++++ await check('marking: moderators gate a space for a creator; others cannot; the owner must be a usr_ subject', async () => {++ assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: cora, json: { owner: cora.subject_id } })).status, 403);++ assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: 'cora' } })).status, 400);++ const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: cora.subject_id } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json().space.members_only, { owner: cora.subject_id, owner_username: 'cora', join_url: 'https://openvibe.vip/cora' });++ const list = (await call('/api/v1/spaces', { who: stan })).json().spaces;++ assert.strictEqual(list.find((s) => s.slug === 'showcase').members_only.join_url, 'https://openvibe.vip/cora', 'the space is listed with its join link');++ assert.strictEqual(list.find((s) => s.slug === 'general').members_only, null);++ });++++ await check('a gated space: the member, the owner and moderators read and post; VIP is asked with the owner and the default gate', async () => {++ const before = vip.state.calls.length;++ const r = await call('/api/v1/spaces/showcase/threads', { method: 'POST', who: mia, json: { title: 'Members lounge', body: 'members-only body text' } });++ assert.strictEqual(r.status, 201, r.text);++ showcaseThread = r.json().thread;++ const asked = vip.state.calls.slice(before).find((c) => c.path === '/api/v1/policies/evaluate');++ assert.deepStrictEqual(asked.body.resource, { service: 'community', type: 'space', id: 'showcase' });++ assert.strictEqual(asked.body.owner, cora.subject_id);++ assert.strictEqual(asked.body.subject, mia.subject_id);++ assert.deepStrictEqual(asked.body.fallback, { requirement: 'member', binding: 'community:members_only' });++ assert.ok(/^Bearer /.test(asked.auth), 'a service token for openvibe.vip');++ assert.ok(net.grants.some((g) => g.audience === 'openvibe.vip' && g.scope === 'vip.resource.policy.evaluate'));++ assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: mia })).status, 200);++ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who: mia })).json().posts[0].body_markdown, 'members-only body text');++ const n = evaluateCalls();++ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who: cora })).status, 200, 'the owner');++ assert.strictEqual(evaluateCalls(), n, 'the owner passes without asking VIP');++ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/posts`, { method: 'POST', who: boss, json: { body: 'mod note' } })).status, 201, 'moderators');++ });++++ await check('a gated space refuses strangers and signed-out visitors with 403 vip.members_only and the join link', async () => {++ for (const [who, reason] of [[stan, 'not_a_member'], [null, 'not_signed_in']]) {++ const r = await call('/api/v1/spaces/showcase/threads', { who });++ assert.strictEqual(r.status, 403, r.text);++ const p = r.json();++ assert.strictEqual(p.code, 'vip.members_only');++ assert.strictEqual(p.reason, reason);++ assert.strictEqual(p.members_only.join_url, 'https://openvibe.vip/cora');++ assert.ok(!r.text.includes('members-only body text'));++ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who })).status, 403);++ }++ assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { method: 'POST', who: stan, json: { title: 'Let me in', body: 'x' } })).status, 403);++ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/posts`, { method: 'POST', who: stan, json: { body: 'x' } })).status, 403);++ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/votes`, { method: 'POST', who: stan, json: { value: 1 } })).status, 403);++ });++++ await check('VIP down: a member VIP cannot confirm is refused (fail closed)', async () => {++ vip.state.down = true;++ try {++ const r = await call('/api/v1/spaces/showcase/threads', { who: newbie });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().reason, 'vip_unavailable');++ } finally { vip.state.down = false; }++ clock.t += 2_001; // failures are cached 2 s++ assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: newbie })).status, 200);++ });++++ await check('no client secret: nobody but the owner gets in, and VIP is never called', async () => {++ const { createVipGate } = require('../server/vip');++ const config = require('../server/config');++ const gate = createVipGate({ config: { ...config, oauth: { clientId: 'community', clientSecret: '' } } });++ const n = vip.state.calls.length;++ const d = await gate.check({ subject: mia.subject_id, type: 'space', id: 'showcase', owner: cora.subject_id });++ assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);++ assert.strictEqual((await gate.check({ subject: cora.subject_id, type: 'space', id: 'showcase', owner: cora.subject_id })).allow, true);++ assert.strictEqual(vip.state.calls.length, n);++ });++++ await check('a gated thread in a public space: the author gates it to their members; listed by title only', async () => {++ const r = await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Backstage with Cora', body: 'secret backstage body', members_only: true } });++ assert.strictEqual(r.status, 201, r.text);++ secret = r.json().thread;++ assert.strictEqual(secret.members_only.owner, cora.subject_id);++ const listed = (await call('/api/v1/spaces/general/threads?sort=new', { who: stan })).json();++ const row = listed.threads.find((x) => x.id === secret.id);++ assert.strictEqual(row.title, 'Backstage with Cora');++ assert.strictEqual(row.members_only.join_url, 'https://openvibe.vip/cora');++ assert.ok(!JSON.stringify(listed).includes('secret backstage body'));++ const refused = await call(`/api/v1/spaces/general/threads/${secret.slug}`, { who: stan });++ assert.strictEqual(refused.status, 403);++ assert.strictEqual(refused.json().gate, 'thread');++ assert.ok(!refused.text.includes('secret backstage body'));++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${secret.slug}`, { who: mia })).json().posts[0].body_markdown, 'secret backstage body');++ const reply = await call(`/api/v1/spaces/general/threads/${secret.slug}/posts`, { method: 'POST', who: mia, json: { body: 'member reply' } });++ assert.strictEqual(reply.status, 201);++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${secret.slug}/posts`, { method: 'POST', who: stan, json: { body: 'x' } })).status, 403);++ // Edits need access too: stan cannot edit (not his), and an author who lost access cannot either.++ assert.strictEqual((await call(`/api/v1/posts/${reply.json().post.id}`, { method: 'PUT', who: stan, json: { body: 'y' } })).status, 403);++ });++++ await check('thread gating permissions: only the author (own members) or moderators; others cannot claim someone else\'s members', async () => {++ const open = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: stan, json: { title: 'Stan open thread', body: 'open body' } })).json().thread;++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: mia, json: { owner: mia.subject_id } })).status, 403, 'not the author');++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: cora.subject_id } })).status, 403, 'the author gates to their own members only');++ const own = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: true } });++ assert.strictEqual(own.status, 200, own.text);++ assert.strictEqual(own.json().thread.members_only.owner, stan.subject_id);++ const cleared = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: null } });++ assert.strictEqual(cleared.json().thread.members_only, null);++ assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', who: stan, json: { title: 'Claim', body: 'x', members_only: { owner: cora.subject_id } } })).status, 403);++ const mod = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: boss, json: { owner: cora.subject_id } });++ assert.strictEqual(mod.json().thread.members_only.owner, cora.subject_id, 'moderators name any creator');++ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: null } })).status, 403, 'the author cannot open what a moderator gated to another creator');++ await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: boss, json: { owner: null } });++ });++++ await check('nothing gated leaks: Pulse, sitemap, feeds, space feed, JSON-LD', async () => {++ const pulse = (await call('/api/v1/pulse')).text;++ assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id)).c, 0);++ const sitemap = (await call('/sitemap.xml')).text;++ assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/s/showcase'), 'no gated thread or space in the sitemap');++ const feed = (await call('/s/feed.xml')).text;++ assert.ok(!feed.includes('Backstage') && !feed.includes('Members lounge'));++ assert.strictEqual((await call('/s/showcase/feed.xml')).status, 404);++ const general = (await call('/s/general/feed.xml')).text;++ assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));++ // A thread gated after the fact leaves Pulse as well.++ const later = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Soon private', body: 'soon' } })).json().thread;++ assert.ok((await call('/api/v1/pulse')).text.includes('Soon private'));++ await call(`/api/v1/spaces/general/threads/${later.slug}/members-only`, { method: 'PUT', who: cora, json: { owner: true } });++ assert.ok(!(await call('/api/v1/pulse')).text.includes('Soon private'));++ // Pulse's read-time check alone hides an item nothing removed.++ t.db.prepare("INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at) VALUES ('community', 'thread', ?, 'stale gated item', 'https://openvibe.community/x', 'user', 'public', datetime('now'))").run(String(secret.id));++ assert.ok(!(await call('/api/v1/pulse')).text.includes('stale gated item'));++ });++++ await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {++ const anon = await call(`/s/general/t/${secret.slug}`);++ assert.strictEqual(anon.status, 403);++ assert.ok(anon.text.includes('https://openvibe.vip/cora'));++ assert.ok(anon.text.includes('Backstage with Cora'));++ assert.ok(!anon.text.includes('secret backstage body') && !anon.text.includes('member reply'));++ assert.ok(/noindex/.test(anon.text));++ assert.ok(anon.text.includes('/auth/login?next='), 'signed out: a sign-in link');++ const space = await call('/s/showcase', { who: stan });++ assert.strictEqual(space.status, 403);++ assert.ok(space.text.includes('https://openvibe.vip/cora') && !space.text.includes('Members lounge'));++ assert.strictEqual((await call('/s/showcase/new', { who: stan })).status, 403, 'no new-thread form for outsiders');++ const reply = await form(`/s/general/t/${secret.slug}/reply`, stan, { body: 'sneaky' });++ assert.strictEqual(reply.status, 403);++ const member = await call(`/s/general/t/${secret.slug}`, { who: mia });++ assert.strictEqual(member.status, 200);++ assert.ok(member.text.includes('secret backstage body'));++ assert.ok(/noindex/.test(member.text));++ assert.ok(!member.text.includes('DiscussionForumPosting'), 'no structured data for a gated thread');++ const listing = await call('/s/general', { who: stan });++ assert.ok(listing.text.includes('Backstage with Cora') && listing.text.includes('VIP members only'));++ assert.ok(!listing.text.includes('secret backstage body'));++ // The author's no-JS toggle.++ const own = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Toggle me', body: 'toggle' } })).json().thread;++ const page = await call(`/s/general/t/${own.slug}`, { who: cora });++ assert.ok(page.text.includes(`/s/general/t/${own.slug}/members-only`));++ const on = await form(`/s/general/t/${own.slug}/members-only`, cora, { on: '1' });++ assert.strictEqual(on.status, 303);++ assert.strictEqual((await call(`/s/general/t/${own.slug}`, { who: stan })).status, 403);++ });++++ await check('convergence: VIP stops granting → Community refuses within the cache TTL (30 s); at once on vip.membership.changed', async () => {++ vip.state.allow.add(`${conv.subject_id}|${cora.subject_id}`);++ const path = `/api/v1/spaces/general/threads/${secret.slug}`;++ assert.strictEqual((await call(path, { who: conv })).status, 200);++ vip.state.allow.delete(`${conv.subject_id}|${cora.subject_id}`); // VIP applied the change++ clock.t += 29_999;++ assert.strictEqual((await call(path, { who: conv })).status, 200, 'inside the bound the cached yes may still answer');++ clock.t += 2;++ assert.strictEqual((await call(path, { who: conv })).status, 403, 'past 30 s VIP is asked again and says no');++ // With the event: at once.++ vip.state.allow.add(`${conv.subject_id}|${cora.subject_id}`);++ clock.t += 10_001; // the cached "no" expires++ assert.strictEqual((await call(path, { who: conv })).status, 200);++ vip.state.allow.delete(`${conv.subject_id}|${cora.subject_id}`);++ const understood = t.app.locals.vip.cache.handleEvent({ event_type: 'vip.membership.changed', payload: { member: { type: 'user', id: conv.subject_id }, creator: { type: 'user', id: cora.subject_id }, active: false } });++ assert.strictEqual(understood, true);++ assert.strictEqual((await call(path, { who: conv })).status, 403, 'no clock movement needed');++ assert.strictEqual(t.app.locals.vip.bounds.grantMs, 30_000);++ });++++ await check('a moderator opens a space again; guest subjects and ids are never owners', async () => {++ assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: ids.newId('guest') } })).status, 400);++ const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: null } });++ assert.strictEqual(r.json().space.members_only, null);++ assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: stan })).status, 200);++ assert.ok((await call('/sitemap.xml')).text.includes('/s/showcase'));++ });++++ await t.close();++ await vip.close();++ done();++})();
@@ -37,6 +37,16 @@ OV_MEDIA_URL=https://openvibe.media
@@ -37,6 +37,16 @@ OV_MEDIA_URL=https://openvibe.media
# with a service token (the OAuth client needs media.object.upload for audience openvibe.media).
# with a service token (the OAuth client needs media.object.upload for audience openvibe.media).
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
+# OpenVibe.VIP: members-only forum spaces and threads. Community asks POST /api/v1/policies/evaluate
+# with a service token (the OAuth client needs vip.resource.policy.evaluate for audience openvibe.vip).
+# Without it, or with VIP down, only the owner and moderators get in (fails closed).
+OV_VIP_INTERNAL_URL=http://127.0.0.1:4620
+OV_VIP_URL=https://openvibe.vip
+# How long a VIP yes / no / failure is cached (ms); the yes TTL bounds how long access outlives a membership.
+# VIP_CACHE_TTL_MS=30000
+# VIP_CACHE_DENY_TTL_MS=10000
+# VIP_CACHE_UNAVAILABLE_TTL_MS=2000
+
# Who owns pastes:
# Who owns pastes:
# live (default) /api/pastes/* proxies to OpenVibe.Live, pages read through Live
# live (default) /api/pastes/* proxies to OpenVibe.Live, pages read through Live
# community this site's own database is the authority (native API, pages, raw text)
# community this site's own database is the authority (native API, pages, raw text)
@@ -31,6 +31,7 @@ Pastes moved to Community in roadmap Wave 5. `PASTES_AUTHORITY` picks who owns t
@@ -31,6 +31,7 @@ Pastes moved to Community in roadmap Wave 5. `PASTES_AUTHORITY` picks who owns t
| Identity / SSO | **OpenVibe.Network** (OAuth2 + RS256 JWKS) | OAuth client `community` |
| Identity / SSO | **OpenVibe.Network** (OAuth2 + RS256 JWKS) | OAuth client `community` |
| Raw text, screenshots | OpenVibe.Media public host | 302 from `/p/:slug/raw`, `/p/:slug/screenshot` |
| Raw text, screenshots | OpenVibe.Media public host | 302 from `/p/:slug/raw`, `/p/:slug/screenshot` |
| Shared chrome, themes | `https://openvibe.network/shared/*.js` | loaded in every page |
| Shared chrome, themes | `https://openvibe.network/shared/*.js` | loaded in every page |
+| VIP memberships (members-only spaces/threads) | **OpenVibe.VIP** (`POST /api/v1/policies/evaluate`) | `OV_VIP_INTERNAL_URL`, service token (audience `openvibe.vip`, `vip.resource.policy.evaluate`) |
Why go through Live and not straight to Media: the visitor's JWT names the account by its
Why go through Live and not straight to Media: the visitor's JWT names the account by its
**Network** id, while the `user_id` Media stores for pastes is **Live's** own — different
**Network** id, while the `user_id` Media stores for pastes is **Live's** own — different
@@ -320,14 +321,51 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
@@ -320,14 +321,51 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| --- | --- |
| --- | --- |
| `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |
| `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |
| `GET /spaces/:space/threads?sort=&page=&limit=` | Threads (server pagination: `page`, `pages`, `total`) |
| `GET /spaces/:space/threads?sort=&page=&limit=` | Threads (server pagination: `page`, `pages`, `total`) |
-| `POST /spaces/:space/threads` `{ title, body }` | New thread (`community.post.create` for services) |
+| `POST /spaces/:space/threads` `{ title, body, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |
| `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |
| `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |
| `DELETE /spaces/:space/threads/:slug` | Author or moderator |
| `DELETE /spaces/:space/threads/:slug` | Author or moderator |
| `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply |
| `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply |
| `POST /spaces/:space/threads/:slug/votes` `{ value: 1\|-1\|0 }` | Vote |
| `POST /spaces/:space/threads/:slug/votes` `{ value: 1\|-1\|0 }` | Vote |
| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked? }` | Moderators |
| `PUT /spaces/:space/threads/:slug/state` `{ pinned?, locked? }` | Moderators |
+| `PUT /spaces/:space/members-only` `{ owner: 'usr_…' \| null }` | Moderators: gate a space to a creator's VIP members (or open it) |
+| `PUT /spaces/:space/threads/:slug/members-only` `{ owner: 'usr_…' \| true \| null }` | The author (to their own members) or moderators (any creator) |
| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |
| `PUT /posts/:id` `{ body }` · `DELETE /posts/:id` · `GET /posts/:id/versions` | Author or moderator |
+### Members-only spaces and threads (OpenVibe.VIP)
+
+A space or a single thread can be for one creator's [OpenVibe.VIP](https://github.com/OpenVibers/OpenVibe.VIP)
+members: `members_only_owner` holds the creator's Network subject (`usr_…`). Moderators gate spaces
+(for any creator) and threads; a thread's author gates it to their own members (and opens it again),
+by the API, the `members_only` box on the new-thread form, or the button on the thread page.
+
+- **Reading and posting need an active entitlement**: listing a gated space's threads, reading a
+ thread, starting a thread, replying, voting, editing and post history. Community asks VIP
+ `POST /api/v1/policies/evaluate` `{ subject, resource: { service: 'community', type: 'space'|'thread', id },
+ owner, fallback: { requirement: 'member', binding: 'community:members_only' } }` — the owner is the
+ one Community stored, and the creator's own VIP rule for that resource (plan or perk requirement) wins
+ over the default gate. A gated thread in a gated space needs both.
+- **The owner and discussion moderators always pass** (the owner without asking VIP).
+- **Fails closed**: signed out, not a member, VIP down, a refused token or no `OV_OAUTH_CLIENT_SECRET`
+ are all `403 vip.members_only` `{ reason, gate, members_only: { owner, owner_username, join_url } }`.
+ Pages show a `noindex` teaser (space name, or the thread title) with the join link to the creator's
+ page on openvibe.vip, never a post.
+- **Listings** keep a gated thread's title with `members_only` (never a body); gated spaces are listed
+ with their join link.
+- **Never public**: gated spaces and threads stay out of Pulse (not recorded, removed when gated later,
+ and filtered at read time), the Discord relay (not queued; pending deliveries dropped; re-checked at
+ send), the sitemap, the RSS feeds (a gated space's feed is 404) and JSON-LD; a member's view of a
+ gated thread is `noindex` as well.
+- **Convergence**: answers are cached per viewer and resource (`server/vip/`, VIP's `createVipCache`):
+ a "yes" at most `VIP_CACHE_TTL_MS` (30 s), a "no" 10 s, a failure 2 s. Community has no Events inbox,
+ so **once VIP stops granting** (it applied Billing's `billing.entitlement.changed` and emitted
+ `vip.membership.changed`) **Community stops within 30 s**; the end-to-end bound from Billing's change
+ is VIP's (seconds with events, at most `VIP_PROJECTION_MAX_AGE_MS` + 30 s without; see the table in
+ OpenVibe.VIP's README). `app.locals.vip.cache.handleEvent(envelope)` converges at once if an Events
+ subscription is added later. `test/members-only.test.js` proves both.
+- The client (`server/vip/vip-client.js`) is OpenVibe.VIP's `client/vip-client.js` vendored at 2accbeb
+ until VIP publishes a tag to pin.
+- **Grant needed** on the Network: `community` → `vip.resource.policy.evaluate` on `openvibe.vip`.
+
Votes (comments and threads) are one UPSERT per person plus a score recomputed from the vote
Votes (comments and threads) are one UPSERT per person plus a score recomputed from the vote
rows in the same IMMEDIATE transaction (`server/votes.js`), so concurrent votes cannot drift
rows in the same IMMEDIATE transaction (`server/votes.js`), so concurrent votes cannot drift
a score.
a score.
@@ -428,6 +466,10 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
@@ -428,6 +466,10 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
| *(any name)* e.g. `DISCORD_WEBHOOK_FEEDBACK` | — | A Discord webhook URL, named by a relay mapping's `webhook_url_ref` |
| *(any name)* e.g. `DISCORD_WEBHOOK_FEEDBACK` | — | A Discord webhook URL, named by a relay mapping's `webhook_url_ref` |
| `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts |
| `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts |
| `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |
| `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |
+| `OV_VIP_INTERNAL_URL` | `http://127.0.0.1:4620` | OpenVibe.VIP's API (members-only spaces and threads) |
+| `OV_VIP_URL` | `https://openvibe.vip` | Public VIP site, for join links |
+| `VIP_TIMEOUT_MS` | `2000` | One VIP call |
+| `VIP_CACHE_TTL_MS` / `VIP_CACHE_DENY_TTL_MS` / `VIP_CACHE_UNAVAILABLE_TTL_MS` | `30000` / `10000` / `2000` | How long a yes / no / failure is cached (the yes TTL is the convergence bound) |
## Run
## Run
@@ -487,6 +529,7 @@ server/
@@ -487,6 +529,7 @@ server/
comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
routes (the /c/:accessId page), live-import (Live's old VOD/clip comments)
routes (the /c/:accessId page), live-import (Live's old VOD/clip comments)
forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)
forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)
+ vip/ OpenVibe.VIP gate for members-only spaces/threads (index.js) + the vendored client
pulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)
pulse/ Pulse read model: store, service (hooks + ingest), api (/api/v1/pulse)
relay/ Discord relay: discord.js (queue, worker, backoff), api (/api/v1/relay)
relay/ Discord relay: discord.js (queue, worker, backoff), api (/api/v1/relay)
http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS
http/v1.js /api/v1 helpers: problem errors, capability guards, cursors, CORS
@@ -496,7 +539,7 @@ server/
@@ -496,7 +539,7 @@ server/
limits.js per-person write limits
limits.js per-person write limits
render/layout.js page shell: SEO head, shared chrome, hashed assets
render/layout.js page shell: SEO head, shared chrome, hashed assets
render/pages.js home / browse / paste / new / my / error templates
render/pages.js home / browse / paste / new / my / error templates
- render/forum.js spaces / threads / thread / new-thread templates
+ render/forum.js spaces / threads / thread / new-thread / members-only teaser templates
render/pulse.js the /pulse page
render/pulse.js the /pulse page
render/comments.js a comment thread's own page
render/comments.js a comment thread's own page
render/markdown.js the safe Markdown subset for posts
render/markdown.js the safe Markdown subset for posts
@@ -49,6 +49,7 @@ const { createCommentService } = require('./comments/service');
@@ -49,6 +49,7 @@ const { createCommentService } = require('./comments/service');
const { createCommentsApi } = require('./comments/api');
const { createCommentsApi } = require('./comments/api');
const { createCommentPages } = require('./comments/routes');
const { createCommentPages } = require('./comments/routes');
const { createForumService } = require('./forum/service');
const { createForumService } = require('./forum/service');
+const { createVipGate } = require('./vip');
const { createSpacesApi, createPostsApi } = require('./forum/api');
const { createSpacesApi, createPostsApi } = require('./forum/api');
const { createForumRoutes } = require('./forum/routes');
const { createForumRoutes } = require('./forum/routes');
const { createPulse } = require('./pulse/service');
const { createPulse } = require('./pulse/service');
@@ -118,11 +119,13 @@ function createApp(opts = {}) {
@@ -118,11 +119,13 @@ function createApp(opts = {}) {
webhookVars: config.discordRelay.webhookVars,
webhookVars: config.discordRelay.webhookVars,
...(opts.relayOptions || {}),
...(opts.relayOptions || {}),
});
});
- const forum = createForumService({ db, network, pulse, relay, limits: opts.forumLimits });
+ // OpenVibe.VIP: members-only spaces and threads (fails closed without a client secret or VIP).
+ const vip = opts.vip || createVipGate({ config, ...(opts.vipOptions || {}) });
+ const forum = createForumService({ db, network, pulse, relay, vip, limits: opts.forumLimits });
const community = config.pastesAuthority === 'community';
const community = config.pastesAuthority === 'community';
const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });
const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });
seo.useForum(forum);
seo.useForum(forum);
- Object.assign(app.locals, { db, network, pulse, relay, forum, comments });
+ Object.assign(app.locals, { db, network, pulse, relay, vip, forum, comments });
if (opts.startRelay !== false) relay.start();
if (opts.startRelay !== false) relay.start();
// ── Paste authority ──────────────────────────────────────
// ── Paste authority ──────────────────────────────────────
@@ -60,6 +60,20 @@ module.exports = {
@@ -60,6 +60,20 @@ module.exports = {
apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games')
apiCorsOrigins: (process.env.API_CORS_ORIGINS || 'https://openvibe.live,https://openvibe.media,https://openvibe.network,https://openvibe.tools,https://openvibe.games')
.split(',').map((s) => s.trim().replace(/\/$/, '')).filter(Boolean),
.split(',').map((s) => s.trim().replace(/\/$/, '')).filter(Boolean),
+ // OpenVibe.VIP — members-only spaces and threads (forum/service.js). Every answer comes from
+ // VIP's POST /api/v1/policies/evaluate (capability vip.resource.policy.evaluate, client
+ // credentials of client `community`), cached per viewer: a "yes" at most ttlMs (the convergence
+ // bound after VIP stops granting), a "no" denyTtlMs, a failure unavailableTtlMs. Without a
+ // client secret or with VIP down, nobody but the owner and discussion moderators gets in.
+ vip: {
+ internalUrl: (process.env.OV_VIP_INTERNAL_URL || 'http://127.0.0.1:4620').replace(/\/$/, ''),
+ publicUrl: (process.env.OV_VIP_URL || 'https://openvibe.vip').replace(/\/$/, ''),
+ timeoutMs: parseInt(process.env.VIP_TIMEOUT_MS, 10) || 2000,
+ ttlMs: parseInt(process.env.VIP_CACHE_TTL_MS, 10) || 30_000,
+ denyTtlMs: parseInt(process.env.VIP_CACHE_DENY_TTL_MS, 10) || 10_000,
+ unavailableTtlMs: parseInt(process.env.VIP_CACHE_UNAVAILABLE_TTL_MS, 10) || 2_000,
+ },
+
// Discord relay (outbound: new threads → a Discord webhook per mapped space). Off by default.
// Discord relay (outbound: new threads → a Discord webhook per mapped space). Off by default.
// Webhook URLs live in environment variables named by relay_mappings.webhook_url_ref.
// Webhook URLs live in environment variables named by relay_mappings.webhook_url_ref.
discordRelay: {
discordRelay: {
@@ -339,6 +339,8 @@ function openDb(file) {
@@ -339,6 +339,8 @@ function openDb(file) {
* bits); the sequential id is for services only, so threads cannot be enumerated. Threads made
* bits); the sequential id is for services only, so threads cannot be enumerated. Threads made
* before the column existed get one here.
* before the column existed get one here.
* comments.edited_at — when the author last edited the text (null: never edited).
* comments.edited_at — when the author last edited the text (null: never edited).
+ * spaces.members_only_owner, threads.members_only_owner — members-only for a creator's OpenVibe.VIP
+ * members (their Network subject); NULL: not gated. See forum/service.js.
*/
*/
function migrate(db) {
function migrate(db) {
const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));
const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));
@@ -351,6 +353,11 @@ function migrate(db) {
@@ -351,6 +353,11 @@ function migrate(db) {
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_comment_threads_access ON comment_threads(access_id)');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_comment_threads_access ON comment_threads(access_id)');
const commentCols = new Set(db.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));
const commentCols = new Set(db.prepare('PRAGMA table_info(comments)').all().map((c) => c.name));
if (!commentCols.has('edited_at')) db.exec('ALTER TABLE comments ADD COLUMN edited_at DATETIME');
if (!commentCols.has('edited_at')) db.exec('ALTER TABLE comments ADD COLUMN edited_at DATETIME');
+ // Members-only (OpenVibe.VIP): the creator (usr_…) whose members may read and post.
+ for (const table of ['spaces', 'threads']) {
+ const c = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((x) => x.name));
+ if (!c.has('members_only_owner')) db.exec(`ALTER TABLE ${table} ADD COLUMN members_only_owner TEXT`);
+ }
}
}
/** cth_ + 22 base64url characters (16 random bytes). */
/** cth_ + 22 base64url characters (16 random bytes). */
@@ -13,10 +13,15 @@
@@ -13,10 +13,15 @@
* POST /spaces/:space/threads/:slug/posts { body } reply
* POST /spaces/:space/threads/:slug/posts { body } reply
* POST /spaces/:space/threads/:slug/votes { value } 1 | -1 | 0
* POST /spaces/:space/threads/:slug/votes { value } 1 | -1 | 0
* PUT /spaces/:space/threads/:slug/state { pinned?, locked? } moderators
* PUT /spaces/:space/threads/:slug/state { pinned?, locked? } moderators
+ * PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)
+ * PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators
+ * POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread
* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
*
*
* Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and
* Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and
- * moderate with community.comment.moderate. Errors are problem+json.
+ * moderate with community.comment.moderate. Errors are problem+json. A members-only space or thread
+ * refuses readers and writers without the creator's VIP membership with 403 vip.members_only
+ * ({ reason, gate, members_only: { owner, owner_username, join_url } }).
*/
*/
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
@@ -42,6 +47,8 @@ function createSpacesApi({ forum, viewers }) {
@@ -42,6 +47,8 @@ function createSpacesApi({ forum, viewers }) {
router.post('/:space/threads/:slug/posts', write, jsonBody, run((req) => forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads/:slug/posts', write, jsonBody, run((req) => forum.reply(req.viewer, p(req).space, p(req).slug, req.body || {}), 201));
router.post('/:space/threads/:slug/votes', write, jsonBody, run((req) => forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.post('/:space/threads/:slug/votes', write, jsonBody, run((req) => forum.voteThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/state', serviceCap(MOD), jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.put('/:space/threads/:slug/state', serviceCap(MOD), jsonBody, run((req) => forum.moderateThread(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.put('/:space/threads/:slug/members-only', writeOrMod, jsonBody, run((req) => forum.setThreadMembersOnly(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.put('/:space/members-only', serviceCap(MOD), jsonBody, run((req) => forum.setSpaceMembersOnly(req.viewer, p(req).space, req.body || {})));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
return router;
@@ -9,7 +9,10 @@
@@ -9,7 +9,10 @@
* GET /s/:space/feed.xml RSS: latest threads of one public space
* GET /s/:space/feed.xml RSS: latest threads of one public space
* GET /s/:space/new POST /s/:space/new start a thread
* GET /s/:space/new POST /s/:space/new start a thread
* GET /s/:space/t/:slug a thread (?page=)
* GET /s/:space/t/:slug a thread (?page=)
- * POST /s/:space/t/:slug/reply | /vote | /state | /delete no-JS forms
+ * POST /s/:space/t/:slug/reply | /vote | /state | /delete | /members-only no-JS forms
+ *
+ * A members-only (OpenVibe.VIP) space or thread the viewer may not use renders a teaser with the
+ * creator's join link (403), never its posts.
*
*
* Form posts carry the ov_token cookie (SameSite=Lax, so other sites cannot post as the
* Form posts carry the ov_token cookie (SameSite=Lax, so other sites cannot post as the
* visitor); an Origin header from somewhere else is refused as well.
* visitor); an Origin header from somewhere else is refused as well.
@@ -31,7 +34,10 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -31,7 +34,10 @@ function createForumRoutes({ forum, viewers, config }) {
/** Render an ApiError as a page: sign-in for 401, the error page for the rest. */
/** Render an ApiError as a page: sign-in for 401, the error page for the rest. */
function failPage(req, res, err, next) {
function failPage(req, res, err, next) {
if (!(err instanceof ApiError)) return next(err);
if (!(err instanceof ApiError)) return next(err);
- if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete)$/, ''));
+ if (err.status === 401) return login(res, req.originalUrl.replace(/\/(reply|vote|state|delete|members-only)$/, ''));
+ if (err.code === 'vip.members_only' && err.extra) {
+ return html(res, forumPages.membersOnlyPage({ ...err.extra, user: req.user, next: req.originalUrl.replace(/\/(reply|vote|state|delete|members-only|new)$/, '') }), 403);
+ }
const titles = { 404: 'Not found', 403: 'Not allowed', 429: 'Slow down' };
const titles = { 404: 'Not found', 403: 'Not allowed', 429: 'Slow down' };
return html(res, pages.errorPage({ status: err.status, title: titles[err.status] || 'That did not work', message: err.message }), err.status);
return html(res, pages.errorPage({ status: err.status, title: titles[err.status] || 'That did not work', message: err.message }), err.status);
}
}
@@ -43,7 +49,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -43,7 +49,7 @@ function createForumRoutes({ forum, viewers, config }) {
}
}
router.get('/s', withViewer, wrap(async (req, res, next) => {
router.get('/s', withViewer, wrap(async (req, res, next) => {
- try { html(res, forumPages.spacesPage({ ...forum.listSpaces(req.viewer), user: req.user })); } catch (err) { failPage(req, res, err, next); }
+ try { html(res, forumPages.spacesPage({ ...(await forum.listSpaces(req.viewer)), user: req.user })); } catch (err) { failPage(req, res, err, next); }
}));
}));
router.get('/s/feed.xml', (_req, res) => {
router.get('/s/feed.xml', (_req, res) => {
@@ -64,23 +70,26 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -64,23 +70,26 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
- router.get('/s/:space/new', withViewer, (req, res, next) => {
+ router.get('/s/:space/new', withViewer, wrap(async (req, res, next) => {
try {
try {
- const { space } = forum.space(req.viewer, req.params.space);
+ // A members-only space: the teaser, not the form, for someone who may not post there.
+ await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
+ const { space } = await forum.space(req.viewer, req.params.space);
html(res, forumPages.newThreadPage({ space, user: req.user }));
html(res, forumPages.newThreadPage({ space, user: req.user }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
- });
+ }));
router.post('/s/:space/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
router.post('/s/:space/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
+ if ((req.body || {}).members_only === '1') values.members_only = true;
try {
try {
const out = await forum.createThread(req.viewer, req.params.space, values);
const out = await forum.createThread(req.viewer, req.params.space, values);
seo.resetCaches();
seo.resetCaches();
res.redirect(303, out.thread.url);
res.redirect(303, out.thread.url);
} catch (err) {
} catch (err) {
- if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);
+ if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
try {
try {
- const { space } = forum.space(req.viewer, req.params.space);
+ const { space } = await forum.space(req.viewer, req.params.space);
html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message }), err.status);
html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message }), err.status);
} catch (e) { failPage(req, res, e, next); }
} catch (e) { failPage(req, res, e, next); }
}
}
@@ -102,7 +111,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -102,7 +111,7 @@ function createForumRoutes({ forum, viewers, config }) {
const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft });
const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft });
res.redirect(303, out.url);
res.redirect(303, out.url);
} catch (err) {
} catch (err) {
- if (!(err instanceof ApiError) || err.status === 401 || err.status === 404) return failPage(req, res, err, next);
+ if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
try {
try {
// The reply form lives on the thread's last page; show it there with the error and the draft.
// The reply form lives on the thread's last page; show it there with the error and the draft.
const probe = await forum.getThread(req.viewer, req.params.space, req.params.slug, {});
const probe = await forum.getThread(req.viewer, req.params.space, req.params.slug, {});
@@ -114,12 +123,21 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -114,12 +123,21 @@ function createForumRoutes({ forum, viewers, config }) {
const back = (req) => `/s/${encodeURIComponent(req.params.space)}/t/${encodeURIComponent(req.params.slug)}`;
const back = (req) => `/s/${encodeURIComponent(req.params.space)}/t/${encodeURIComponent(req.params.slug)}`;
- router.post('/s/:space/t/:slug/vote', withViewer, sameOrigin, form, (req, res, next) => {
+ router.post('/s/:space/t/:slug/vote', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
- forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });
+ await forum.voteThread(req.viewer, req.params.space, req.params.slug, { value: (req.body || {}).value });
res.redirect(303, back(req));
res.redirect(303, back(req));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
- });
+ }));
+
+ // Members-only on/off (no JS): on gates the thread to its author's VIP members.
+ router.post('/s/:space/t/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ try {
+ await forum.setThreadMembersOnly(req.viewer, req.params.space, req.params.slug, { members_only: (req.body || {}).on === '1' ? true : null });
+ seo.resetCaches();
+ res.redirect(303, back(req));
+ } catch (err) { failPage(req, res, err, next); }
+ }));
router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
router.post('/s/:space/t/:slug/state', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const b = req.body || {};
const b = req.body || {};
@@ -16,6 +16,16 @@
@@ -16,6 +16,16 @@
*
*
* Side effects of a new thread/post: a Pulse item (public spaces) and, for threads, a Discord
* Side effects of a new thread/post: a Pulse item (public spaces) and, for threads, a Discord
* relay delivery per mapping (relay/discord.js; off unless DISCORD_RELAY_ENABLED).
* relay delivery per mapping (relay/discord.js; off unless DISCORD_RELAY_ENABLED).
+ *
+ * Members-only (OpenVibe.VIP): a space or a single thread can be for one creator's VIP members
+ * (members_only_owner = the creator's usr_ subject). Reading its threads and posts, starting a
+ * thread, replying, voting and editing need an active entitlement, asked of VIP
+ * (POST /policies/evaluate with that owner, through vip/index.js's cache); a gated thread in a gated
+ * space needs both. The owner and discussion moderators always pass; every doubt (signed out, VIP
+ * down, no grant) is a 403 vip.members_only with the reason and a join link. Listings keep a gated
+ * thread's title with its members_only flag, never a body; gated things never reach Pulse, the Discord
+ * relay, sitemaps or feeds. Moderators gate spaces (for any creator) and threads; a thread's author
+ * gates it to their own members.
*/
*/
const store = require('./store');
const store = require('./store');
const { applyVote, myVotes, parseVote } = require('../votes');
const { applyVote, myVotes, parseVote } = require('../votes');
@@ -24,6 +34,7 @@ const { createAuthors } = require('../identity/authors');
@@ -24,6 +34,7 @@ const { createAuthors } = require('../identity/authors');
const { createPersonLimiter } = require('../limits');
const { createPersonLimiter } = require('../limits');
const { discussionModerator } = require('../identity/capabilities');
const { discussionModerator } = require('../identity/capabilities');
const { renderMarkdown } = require('../render/markdown');
const { renderMarkdown } = require('../render/markdown');
+const { isUserSubject } = require('../vip');
const THREADS_PER_PAGE = 25;
const THREADS_PER_PAGE = 25;
const POSTS_PER_PAGE = 50;
const POSTS_PER_PAGE = 50;
@@ -31,7 +42,7 @@ const TITLE_MIN = 3, TITLE_MAX = 200;
@@ -31,7 +42,7 @@ const TITLE_MIN = 3, TITLE_MAX = 200;
const BODY_MAX = 40_000;
const BODY_MAX = 40_000;
const THREADS_PER_DAY = 20;
const THREADS_PER_DAY = 20;
-function createForumService({ db, network = null, pulse = null, relay = null, limits = {} } = {}) {
+function createForumService({ db, network = null, pulse = null, relay = null, vip = null, limits = {} } = {}) {
const authors = createAuthors({ db, network });
const authors = createAuthors({ db, network });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
@@ -49,6 +60,47 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -49,6 +60,47 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
return moderator(v);
return moderator(v);
}
}
+ // ── members-only (OpenVibe.VIP) ──────────────────────────
+ /** { owner, join_url } for a gated row, null for an open one. */
+ async function membersOnly(owner, projections = null) {
+ if (!owner) return null;
+ const p = (projections || await authors.projectionsFor([owner])).get(owner);
+ const username = p && p.username ? p.username : null;
+ return { owner, owner_username: username, join_url: vip ? vip.joinUrl(owner, username) : null };
+ }
+
+ /**
+ * Throw 403 vip.members_only unless the viewer may use every gated level named: the space and/or
+ * the thread. Moderators pass; the owner passes; the rest is VIP's answer (fail closed).
+ */
+ async function requireMembership(v, space, thread = null) {
+ if (moderator(v)) return;
+ const gates = [];
+ if (space && space.members_only_owner) gates.push({ type: 'space', id: space.slug, owner: space.members_only_owner });
+ if (thread && thread.members_only_owner) gates.push({ type: 'thread', id: String(thread.id), owner: thread.members_only_owner });
+ for (const g of gates) {
+ const subject = person(v) ? v.subject : null;
+ const d = vip ? await vip.check({ subject, type: g.type, id: g.id, owner: g.owner })
+ : (subject && subject === g.owner ? { allow: true } : { allow: false, reason: 'vip_unavailable' });
+ if (d.allow) continue;
+ const mo = await membersOnly(g.owner);
+ fail(403, 'vip.members_only', `Only members of this creator's OpenVibe.VIP can read and post in this ${g.type}`, {
+ reason: d.reason || 'denied', gate: g.type, members_only: mo,
+ space: { slug: space.slug, name: space.name, description: space.description || null },
+ thread: thread ? { slug: thread.slug, title: thread.title } : null,
+ });
+ }
+ }
+
+ /** A creator subject a viewer may gate something to: themselves, or anyone for moderators. */
+ function gateOwner(v, requested, fallbackOwner) {
+ if (requested === null || requested === false || requested === undefined) return null;
+ const owner = requested === true ? fallbackOwner : (typeof requested === 'object' ? requested.owner : requested);
+ if (!isUserSubject(owner)) fail(400, 'members_only.invalid_owner', 'members_only.owner must be the creator\'s Network subject (usr_…)');
+ if (!moderator(v) && !(person(v) && owner === v.subject)) fail(403, 'members_only.not_yours', 'You can only make things members-only for your own VIP members');
+ return owner;
+ }
+
/** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */
/** The space, or 404 (staff spaces look missing) / 401 (members-only, signed out). */
function spaceFor(v, slug) {
function spaceFor(v, slug) {
const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? store.getSpace(db, slug) : null;
const space = /^[a-z0-9-]{1,40}$/.test(String(slug)) ? store.getSpace(db, slug) : null;
@@ -96,8 +148,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -96,8 +148,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;
const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;
function shapeThread(t, space, v, projections, votes) {
function shapeThread(t, space, v, projections, votes) {
+ const mo = t.members_only_owner || null;
+ const p = mo && projections ? projections.get(mo) : null;
return {
return {
id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),
id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),
+ members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,
author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,
author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,
pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count,
pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count,
last_activity_at: isoTime(t.last_activity_at), created_at: isoTime(t.created_at),
last_activity_at: isoTime(t.last_activity_at), created_at: isoTime(t.created_at),
@@ -120,19 +175,30 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -120,19 +175,30 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
}
}
async function shapeThreads(rows, spaceOf, v) {
async function shapeThreads(rows, spaceOf, v) {
- const projections = await authors.projectionsFor(rows.map((r) => r.author_subject));
+ const projections = await authors.projectionsFor([...rows.map((r) => r.author_subject), ...rows.map((r) => r.members_only_owner)]);
const votes = myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);
const votes = myVotes(db, 'thread', rows.map((r) => r.id), v && v.subject);
return rows.map((r) => shapeThread(r, spaceOf(r), v, projections, votes));
return rows.map((r) => shapeThread(r, spaceOf(r), v, projections, votes));
}
}
- function shapeSpace(s) {
+ function shapeSpace(s, mo = null) {
return {
return {
slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
+ members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,
thread_count: s.thread_count != null ? s.thread_count : undefined,
thread_count: s.thread_count != null ? s.thread_count : undefined,
last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
};
};
}
}
+ /** Newly gated threads leave Pulse and the Discord relay queue at once (reads re-check as well). */
+ function hideGated(threadIds) {
+ if (!threadIds.length) return;
+ if (pulse) hook(() => { for (const id of threadIds) { pulse.threadGone(id); for (const p of db.prepare('SELECT id FROM posts WHERE thread_id = ?').all(id)) pulse.postGone(p.id); } });
+ hook(() => {
+ const del = db.prepare("DELETE FROM relay_deliveries WHERE thread_id = ? AND status = 'pending'");
+ for (const id of threadIds) del.run(id);
+ });
+ }
+
function removeThread(v, thread) {
function removeThread(v, thread) {
if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator deletes a thread');
store.softDeleteThread(db, thread.id);
store.softDeleteThread(db, thread.id);
@@ -148,25 +214,31 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -148,25 +214,31 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
isModerator: moderator,
isModerator: moderator,
- /** Spaces this viewer can open, with thread counts. */
- listSpaces(v) {
+ /** Spaces this viewer can open, with thread counts (members-only ones carry members_only). */
+ async listSpaces(v) {
const vis = ['public'];
const vis = ['public'];
if (person(v) || moderator(v)) vis.push('members');
if (person(v) || moderator(v)) vis.push('members');
if (moderator(v)) vis.push('staff');
if (moderator(v)) vis.push('staff');
- return { spaces: store.listSpaces(db, vis).map(shapeSpace) };
+ const rows = store.listSpaces(db, vis);
+ const projections = await authors.projectionsFor(rows.map((r) => r.members_only_owner));
+ return { spaces: await Promise.all(rows.map(async (r) => shapeSpace(r, await membersOnly(r.members_only_owner, projections)))) };
},
},
- space(v, slug) { return { space: shapeSpace(spaceFor(v, slug)) }; },
+ async space(v, slug) {
+ const s = spaceFor(v, slug);
+ return { space: shapeSpace(s, await membersOnly(s.members_only_owner)) };
+ },
/** A page of threads. ?sort=hot|new|top&page= */
/** A page of threads. ?sort=hot|new|top&page= */
async listThreads(v, spaceSlug, q = {}) {
async listThreads(v, spaceSlug, q = {}) {
const space = spaceFor(v, spaceSlug);
const space = spaceFor(v, spaceSlug);
+ await requireMembership(v, space);
const sort = store.SORTS.includes(q.sort) ? q.sort : 'hot';
const sort = store.SORTS.includes(q.sort) ? q.sort : 'hot';
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);
const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);
const { rows, total } = store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date() });
const { rows, total } = store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date() });
return {
return {
- space: shapeSpace(space), sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
+ space: shapeSpace(space, await membersOnly(space.members_only_owner)), sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
threads: await shapeThreads(rows, () => space, v),
threads: await shapeThreads(rows, () => space, v),
};
};
},
},
@@ -174,12 +246,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -174,12 +246,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
/** A thread with a page of its posts. ?page= */
/** A thread with a page of its posts. ?page= */
async getThread(v, spaceSlug, threadSlug, q = {}) {
async getThread(v, spaceSlug, threadSlug, q = {}) {
const { space, thread } = threadFor(v, spaceSlug, threadSlug);
const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ await requireMembership(v, space, thread);
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const { rows, total } = store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });
const { rows, total } = store.listPosts(db, thread.id, { limit: POSTS_PER_PAGE, offset: (page - 1) * POSTS_PER_PAGE });
- const projections = await authors.projectionsFor([thread.author_subject, ...rows.map((p) => p.author_subject)]);
+ const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner, space.members_only_owner, ...rows.map((p) => p.author_subject)]);
const votes = myVotes(db, 'thread', [thread.id], v && v.subject);
const votes = myVotes(db, 'thread', [thread.id], v && v.subject);
return {
return {
- space: shapeSpace(space),
+ space: shapeSpace(space, await membersOnly(space.members_only_owner, projections)),
thread: shapeThread(thread, space, v, projections, votes),
thread: shapeThread(thread, space, v, projections, votes),
posts: rows.map((p) => shapePost(p, v, projections)),
posts: rows.map((p) => shapePost(p, v, projections)),
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
@@ -189,15 +262,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -189,15 +262,21 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
can_vote: person(v) && !thread.locked,
can_vote: person(v) && !thread.locked,
can_moderate: moderator(v),
can_moderate: moderator(v),
can_delete: moderator(v) || (person(v) && thread.author_subject === v.subject),
can_delete: moderator(v) || (person(v) && thread.author_subject === v.subject),
+ can_gate: moderator(v) || (person(v) && thread.author_subject === v.subject),
},
},
};
};
},
},
- /** New thread { title, body } → { thread, post } */
+ /**
+ * New thread { title, body, members_only? } → { thread, post }. members_only: true gates it to
+ * the author's own VIP members; { owner } names the creator (moderators, or the author themselves).
+ */
async createThread(v, spaceSlug, body = {}) {
async createThread(v, spaceSlug, body = {}) {
const space = spaceFor(v, spaceSlug);
const space = spaceFor(v, spaceSlug);
const w = writer(v);
const w = writer(v);
mayPostIn(v, space);
mayPostIn(v, space);
+ await requireMembership(v, space);
+ const gate = gateOwner(v, body.members_only, w.author);
const title = cleanTitle(body.title);
const title = cleanTitle(body.title);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
@@ -205,11 +284,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -205,11 +284,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
threadLimiter.check(w.key, title);
threadLimiter.check(w.key, title);
if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
}
}
- const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text });
+ const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate });
threadLimiter.record(w.key, title);
threadLimiter.record(w.key, title);
if (pulse) hook(() => pulse.threadCreated(thread, space));
if (pulse) hook(() => pulse.threadCreated(thread, space));
if (relay) hook(() => relay.enqueueThread(thread, space));
if (relay) hook(() => relay.enqueueThread(thread, space));
- const projections = await authors.projectionsFor([thread.author_subject]);
+ const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections) };
return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections) };
},
},
@@ -218,6 +297,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -218,6 +297,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
const { space, thread } = threadFor(v, spaceSlug, threadSlug);
const { space, thread } = threadFor(v, spaceSlug, threadSlug);
const w = writer(v);
const w = writer(v);
mayPostIn(v, space);
mayPostIn(v, space);
+ await requireMembership(v, space, thread);
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
postLimiter.check(w.key, text);
postLimiter.check(w.key, text);
@@ -233,7 +313,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -233,7 +313,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
/** Edit { body } — the author (not on a locked thread) or a moderator. */
/** Edit { body } — the author (not on a locked thread) or a moderator. */
async editPost(v, postId, body = {}) {
async editPost(v, postId, body = {}) {
- const { post, thread } = postFor(v, postId);
+ const { post, thread, space } = postFor(v, postId);
+ await requireMembership(v, space, thread);
const mine = person(v) && post.author_subject === v.subject;
const mine = person(v) && post.author_subject === v.subject;
if (!mine && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
if (!mine && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
@@ -243,8 +324,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -243,8 +324,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
},
},
/** Edit history — the author or a moderator. */
/** Edit history — the author or a moderator. */
- postVersions(v, postId) {
- const { post } = postFor(v, postId);
+ async postVersions(v, postId) {
+ const { post, thread, space } = postFor(v, postId);
+ await requireMembership(v, space, thread);
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');
if (!(person(v) && post.author_subject === v.subject) && !moderator(v)) fail(403, 'post.not_yours', 'Only the author or a moderator sees the history');
const list = store.listPostVersions(db, post.id).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));
const list = store.listPostVersions(db, post.id).map((r) => ({ ...r, created_at: isoTime(r.created_at) }));
return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };
return { revision: post.revision, versions: list.length ? list : [{ revision: post.revision, body_markdown: post.body_markdown, edited_by: post.author_subject, created_at: isoTime(post.created_at) }] };
@@ -267,11 +349,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -267,11 +349,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
},
},
/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */
/** Vote { value: 1 | -1 | 0 } → { score, upvotes, downvotes, my_vote } */
- voteThread(v, spaceSlug, threadSlug, body = {}) {
+ async voteThread(v, spaceSlug, threadSlug, body = {}) {
const value = parseVote(body.value);
const value = parseVote(body.value);
if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
if (value === null) fail(400, 'vote.invalid', 'value must be 1, -1 or 0');
if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');
if (!person(v)) fail(401, 'auth.required', 'Sign in to vote');
- const { thread } = threadFor(v, spaceSlug, threadSlug);
+ const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ await requireMembership(v, space, thread);
if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked) fail(403, 'thread.locked', 'This thread is locked');
voteLimiter.check(`s:${v.subject}`);
voteLimiter.check(`s:${v.subject}`);
const out = applyVote(db, 'thread', thread.id, v.subject, value);
const out = applyVote(db, 'thread', thread.id, v.subject, value);
@@ -292,6 +375,36 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -292,6 +375,36 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
return { thread: shapeThread(next, space, v, projections, null) };
return { thread: shapeThread(next, space, v, projections, null) };
},
},
+ /**
+ * Members-only for a thread { owner: 'usr_…' | null } (or { members_only: … }). Its author gates
+ * it to themselves (and opens it again); moderators name any creator. Gating takes it out of
+ * Pulse and cancels pending Discord relay deliveries.
+ */
+ async setThreadMembersOnly(v, spaceSlug, threadSlug, body = {}) {
+ const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const mine = person(v) && thread.author_subject === v.subject;
+ if (!mine && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator makes a thread members-only');
+ await requireMembership(v, space, null);
+ const requested = body.members_only !== undefined ? body.members_only : body.owner;
+ const owner = gateOwner(v, requested === undefined ? null : requested, thread.author_subject);
+ if (!owner && thread.members_only_owner && !moderator(v) && thread.members_only_owner !== v.subject) fail(403, 'members_only.not_yours', 'Only the creator it is gated to, or a moderator, opens it again');
+ const next = store.setThreadMembersOnly(db, thread.id, owner);
+ if (owner) hideGated([thread.id]);
+ const projections = await authors.projectionsFor([next.author_subject, next.members_only_owner]);
+ return { thread: shapeThread(next, space, v, projections, null) };
+ },
+
+ /** Members-only for a whole space { owner: 'usr_…' | null } — moderators. */
+ async setSpaceMembersOnly(v, spaceSlug, body = {}) {
+ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators make a space members-only');
+ const space = spaceFor(v, spaceSlug);
+ const requested = body.members_only !== undefined ? body.members_only : body.owner;
+ const owner = gateOwner(v, requested === undefined ? null : requested, null);
+ const next = store.setSpaceMembersOnly(db, space.id, owner);
+ if (owner) hideGated(db.prepare('SELECT id FROM threads WHERE space_id = ?').all(space.id).map((r) => r.id));
+ return { space: shapeSpace(next, await membersOnly(next.members_only_owner)) };
+ },
+
/** Latest threads in public spaces, with their opening post (sitemap, feeds). */
/** Latest threads in public spaces, with their opening post (sitemap, feeds). */
recentPublic({ limit = 50, space = null } = {}) {
recentPublic({ limit = 50, space = null } = {}) {
const rows = store.recentThreads(db, { visibilities: ['public'], limit, spaceSlug: space });
const rows = store.recentThreads(db, { visibilities: ['public'], limit, spaceSlug: space });
@@ -299,7 +412,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
@@ -299,7 +412,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, li
return rows.map((t) => ({ ...t, opening: (opening.get(t.id) || {}).body_markdown || '' }));
return rows.map((t) => ({ ...t, opening: (opening.get(t.id) || {}).body_markdown || '' }));
},
},
- publicSpaces() { return store.listSpaces(db, ['public']).map(shapeSpace); },
+ /** Public, open spaces (sitemap, feeds): members-only spaces are left out. */
+ publicSpaces() { return store.listSpaces(db, ['public']).filter((s) => !s.members_only_owner).map((s) => shapeSpace(s)); },
};
};
}
}
@@ -57,11 +57,11 @@ function getThreadBySlug(db, spaceId, slug) {
@@ -57,11 +57,11 @@ function getThreadBySlug(db, spaceId, slug) {
}
}
/** New thread + its opening post, in one transaction. → { thread, post } */
/** New thread + its opening post, in one transaction. → { thread, post } */
-function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown }) {
+function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null }) {
return db.transaction(() => {
return db.transaction(() => {
const slug = uniqueSlug(db, space_id, slugify(title));
const slug = uniqueSlug(db, space_id, slugify(title));
- const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin) VALUES (?, ?, ?, ?, ?)')
- .run(space_id, slug, title, author_subject, origin);
+ const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(space_id, slug, title, author_subject, origin, members_only_owner);
const threadId = info.lastInsertRowid;
const threadId = info.lastInsertRowid;
const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
.run(threadId, author_subject, origin, body_markdown);
.run(threadId, author_subject, origin, body_markdown);
@@ -89,10 +89,11 @@ function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now =
@@ -89,10 +89,11 @@ function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now =
return { rows, total };
return { rows, total };
}
}
-/** Latest threads across spaces of the given visibilities (sitemap, feeds). */
+/** Latest threads across spaces of the given visibilities (sitemap, feeds); members-only ones never. */
function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {
function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {
return db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id
return db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id
WHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (? IS NULL OR s.slug = ?)
WHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (? IS NULL OR s.slug = ?)
+ AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL
ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);
ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);
}
}
@@ -104,6 +105,16 @@ function setThreadFlags(db, id, { pinned, locked }) {
@@ -104,6 +105,16 @@ function setThreadFlags(db, id, { pinned, locked }) {
return getThread(db, id);
return getThread(db, id);
}
}
+/** Members-only for a creator's VIP members (owner = their usr_ subject), or open (null). */
+function setThreadMembersOnly(db, id, owner) {
+ db.prepare('UPDATE threads SET members_only_owner = ? WHERE id = ?').run(owner || null, id);
+ return getThread(db, id);
+}
+function setSpaceMembersOnly(db, id, owner) {
+ db.prepare('UPDATE spaces SET members_only_owner = ? WHERE id = ?').run(owner || null, id);
+ return getSpaceById(db, id);
+}
+
function softDeleteThread(db, id) {
function softDeleteThread(db, id) {
return db.prepare('UPDATE threads SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(id).changes;
return db.prepare('UPDATE threads SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(id).changes;
}
}
@@ -177,6 +188,6 @@ function countPostsSince(db, subject, sinceSql) {
@@ -177,6 +188,6 @@ function countPostsSince(db, subject, sinceSql) {
module.exports = {
module.exports = {
SORTS, slugify,
SORTS, slugify,
listSpaces, getSpace, getSpaceById,
listSpaces, getSpace, getSpaceById,
- getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, softDeleteThread, countThreadsSince,
+ getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
};
};
@@ -76,14 +76,14 @@ function createPulse({ db, network = null, config = {} } = {}) {
@@ -76,14 +76,14 @@ function createPulse({ db, network = null, config = {} } = {}) {
},
},
pasteGone(slug) { forgetLocal('paste', slug); },
pasteGone(slug) { forgetLocal('paste', slug); },
threadCreated(thread, space) {
threadCreated(thread, space) {
- if (!thread || !space || space.visibility !== 'public') return;
+ if (!thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return;
recordLocal('thread', thread.id, {
recordLocal('thread', thread.id, {
title: thread.title, path: `/s/${space.slug}/t/${thread.slug}`,
title: thread.title, path: `/s/${space.slug}/t/${thread.slug}`,
actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,
actor: thread.author_subject, origin: thread.origin === 'ai' ? 'ai' : (thread.origin === 'system' ? 'system' : 'user'), at: thread.created_at,
});
});
},
},
postCreated(post, thread, space) {
postCreated(post, thread, space) {
- if (!post || !thread || !space || space.visibility !== 'public' || post.is_opening) return;
+ if (!post || !thread || !space || space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner || post.is_opening) return;
recordLocal('post', post.id, {
recordLocal('post', post.id, {
title: `Re: ${thread.title}`, path: `/s/${space.slug}/t/${thread.slug}#post-${post.id}`,
title: `Re: ${thread.title}`, path: `/s/${space.slug}/t/${thread.slug}#post-${post.id}`,
actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,
actor: post.author_subject, origin: post.origin === 'ai' ? 'ai' : 'user', at: post.created_at,
@@ -30,7 +30,7 @@ function removeItem(db, service, type, id) {
@@ -30,7 +30,7 @@ function removeItem(db, service, type, id) {
/**
/**
* Newest first, keyset-paginated by (occurred_at, id). Community's own items are re-checked
* Newest first, keyset-paginated by (occurred_at, id). Community's own items are re-checked
* against their source at read time — a paste made private, a deleted post or a thread in a
* against their source at read time — a paste made private, a deleted post or a thread in a
- * space that stopped being public never shows, even if nothing removed its item.
+ * space that stopped being public (or became members-only) never shows, even if nothing removed its item.
* opts.origin 'user' | 'ai' | 'system'
* opts.origin 'user' | 'ai' | 'system'
* opts.before [occurred_at, id] of the last item already seen
* opts.before [occurred_at, id] of the last item already seen
* → { rows, hasMore }
* → { rows, hasMore }
@@ -43,9 +43,11 @@ function listItems(db, { origin = null, before = null, limit = 30 } = {}) {
@@ -43,9 +43,11 @@ function listItems(db, { origin = null, before = null, limit = 30 } = {}) {
AND (i.source_service <> 'community'
AND (i.source_service <> 'community'
OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public'))
OR (i.source_type = 'paste' AND EXISTS (SELECT 1 FROM pastes p WHERE p.slug = i.source_id AND p.deleted_at IS NULL AND p.visibility = 'public'))
OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id
OR (i.source_type = 'thread' AND EXISTS (SELECT 1 FROM threads t JOIN spaces s ON s.id = t.space_id
- WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.visibility = 'public'))
+ WHERE t.id = CAST(i.source_id AS INTEGER) AND t.deleted_at IS NULL AND s.visibility = 'public'
+ AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL))
OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_id
OR (i.source_type = 'post' AND EXISTS (SELECT 1 FROM posts po JOIN threads t ON t.id = po.thread_id JOIN spaces s ON s.id = t.space_id
- WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public')))
+ WHERE po.id = CAST(i.source_id AS INTEGER) AND po.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'
+ AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL)))
ORDER BY i.occurred_at DESC, i.id DESC
ORDER BY i.occurred_at DESC, i.id DESC
LIMIT @limit`).all({ origin, at: before ? before[0] : null, id: before ? before[1] : null, limit: limit + 1 });
LIMIT @limit`).all({ origin, at: before ? before[0] : null, id: before ? before[1] : null, limit: limit + 1 });
const hasMore = rows.length > limit;
const hasMore = rows.length > limit;
@@ -51,6 +51,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -51,6 +51,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
if (!enabled || !thread || !space) return 0;
if (!enabled || !thread || !space) return 0;
if (thread.origin === 'discord') return 0; // loop prevention
if (thread.origin === 'discord') return 0; // loop prevention
if (space.visibility !== 'public') return 0; // members/staff spaces never leave the site
if (space.visibility !== 'public') return 0; // members/staff spaces never leave the site
+ if (space.members_only_owner || thread.members_only_owner) return 0; // nor do VIP members-only ones
const mappings = db.prepare("SELECT * FROM relay_mappings WHERE space_id = ? AND direction = 'out' AND enabled = 1").all(space.id);
const mappings = db.prepare("SELECT * FROM relay_mappings WHERE space_id = ? AND direction = 'out' AND enabled = 1").all(space.id);
let queued = 0;
let queued = 0;
const insert = db.prepare(`INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, ?, ?, ?)
const insert = db.prepare(`INSERT INTO relay_deliveries (thread_id, mapping_id, dedupe_key, next_attempt_at) VALUES (?, ?, ?, ?)
@@ -108,6 +109,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
@@ -108,6 +109,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;
const space = thread ? forumStore.getSpaceById(db, thread.space_id) : null;
if (!thread || thread.deleted_at || !space) return record(d, { ok: false, error: 'thread is gone' });
if (!thread || thread.deleted_at || !space) return record(d, { ok: false, error: 'thread is gone' });
if (thread.origin === 'discord') return record(d, { ok: false, error: 'loop prevention: thread came from Discord' });
if (thread.origin === 'discord') return record(d, { ok: false, error: 'loop prevention: thread came from Discord' });
+ if (space.visibility !== 'public' || space.members_only_owner || thread.members_only_owner) return record(d, { ok: false, error: 'the thread is no longer public (members-only or a restricted space)' });
if (!refAllowed(d.webhook_url_ref)) return record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });
if (!refAllowed(d.webhook_url_ref)) return record(d, { ok: false, error: 'webhook_url_ref is not an allowed webhook variable' });
const url = env[d.webhook_url_ref];
const url = env[d.webhook_url_ref];
if (!url) return record(d, { ok: false, retry: true, error: `webhook URL variable ${d.webhook_url_ref} is not set` });
if (!url) return record(d, { ok: false, retry: true, error: `webhook URL variable ${d.webhook_url_ref} is not set` });
@@ -38,7 +38,10 @@ function pager(href, page, pages, labels = ['Newer', 'Older']) {
@@ -38,7 +38,10 @@ function pager(href, page, pages, labels = ['Newer', 'Older']) {
</nav>`;
</nav>`;
}
}
-const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '');
+const visBadge = (s) => (VIS_LABELS[s.visibility] ? ` <span class="badge badge-vis"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${esc(VIS_LABELS[s.visibility])}</span>` : '') + vipBadge(s);
+/** A members-only (OpenVibe.VIP) space or thread. */
+const vipBadge = (x) => (x && x.members_only ? ` <span class="badge badge-vis badge-vip" title="For this creator's OpenVibe.VIP members"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span>` : '');
+const vipJoin = (mo, label = 'Join on OpenVibe.VIP') => (mo && mo.join_url ? `<a class="btn btn-primary" href="${esc(mo.join_url)}" rel="noopener">${esc(label)}</a>` : '');
// ── /s ───────────────────────────────────────────────────────
// ── /s ───────────────────────────────────────────────────────
function spacesPage({ spaces }) {
function spacesPage({ spaces }) {
@@ -73,7 +76,7 @@ function threadRow(t, space) {
@@ -73,7 +76,7 @@ function threadRow(t, space) {
return `<li class="thread-row">
return `<li class="thread-row">
<span class="thread-score" title="Score">${num(t.score)}</span>
<span class="thread-score" title="Score">${num(t.score)}</span>
<div class="thread-main">
<div class="thread-main">
- <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>
+ <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${vipBadge(t)}
<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
</div>
</div>
</li>`;
</li>`;
@@ -81,7 +84,7 @@ function threadRow(t, space) {
@@ -81,7 +84,7 @@ function threadRow(t, space) {
function spacePage({ space, threads, sort, page, pages, total, user }) {
function spacePage({ space, threads, sort, page, pages, total, user }) {
const tabs = Object.keys(SORT_LABELS).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
const tabs = Object.keys(SORT_LABELS).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
- const indexable = space.visibility === 'public';
+ const indexable = space.visibility === 'public' && !space.members_only;
const body = `
const body = `
<header class="page-head">
<header class="page-head">
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav>
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav>
@@ -127,7 +130,8 @@ function voteForm(base, thread, viewer) {
@@ -127,7 +130,8 @@ function voteForm(base, thread, viewer) {
function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '' }) {
function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '' }) {
const base = `/s/${space.slug}/t/${thread.slug}`;
const base = `/s/${space.slug}/t/${thread.slug}`;
const opening = posts.find((p) => p.is_opening) || null;
const opening = posts.find((p) => p.is_opening) || null;
- const indexable = space.visibility === 'public';
+ const gated = !!(space.members_only || thread.members_only);
+ const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
const replyBlock = viewer.can_reply
? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">
? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">
@@ -141,6 +145,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -141,6 +145,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>
${viewer.can_moderate ? `<form method="post" action="${esc(base)}/state"><input type="hidden" name="pinned" value="${thread.pinned ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> ${thread.pinned ? 'Unpin' : 'Pin'}</button></form>
<form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
<form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
+ ${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}
</div>` : '';
</div>` : '';
const body = `
const body = `
@@ -149,7 +154,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -149,7 +154,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
<header class="thread-head">
<header class="thread-head">
${voteForm(base, thread, viewer)}
${voteForm(base, thread, viewer)}
<div>
<div>
- <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}</h1>
+ <h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
</div>
</div>
</header>
</header>
@@ -158,7 +163,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -158,7 +163,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}
${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
</article>`;
</article>`;
- const description = seo.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title;
+ const description = gated ? `A thread for VIP members in ${space.name} on OpenVibe.Community.` : (seo.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);
return renderPage({
return renderPage({
title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
description,
description,
@@ -173,7 +178,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -173,7 +178,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
footerVariant: 'compact',
footerVariant: 'compact',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
jsonLd: [
jsonLd: [
- seo.threadLd({ space, thread, posts, opening, description }),
+ ...(gated ? [] : [seo.threadLd({ space, thread, posts, opening, description })]),
seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
],
],
body,
body,
@@ -186,6 +191,7 @@ function newThreadPage({ space, user, values = {}, error = null }) {
@@ -186,6 +191,7 @@ function newThreadPage({ space, user, values = {}, error = null }) {
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>
<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>
+ <label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>
<div class="form-actions">
<div class="form-actions">
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
<span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>
<span class="muted small">Posting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. By posting you agree to the <a href="/terms">rules</a>.</span>
@@ -208,4 +214,39 @@ ${form}`;
@@ -208,4 +214,39 @@ ${form}`;
});
});
}
}
-module.exports = { spacesPage, spacePage, threadPage, newThreadPage, spaceHref, who };
+// ── members-only teaser (403) ────────────────────────────────
+/**
+ * What someone without the creator's VIP membership sees: the space (and thread title), why, and
+ * the join link — never a post. `reason` is VIP's (not_signed_in, not_a_member, vip_unavailable, …).
+ */
+function membersOnlyPage({ space, thread = null, members_only: mo = null, reason, user, next = '/s' }) {
+ const creator = mo && mo.owner_username ? `@${mo.owner_username}` : 'this creator';
+ const why = reason === 'not_signed_in' || !user
+ ? `<p><a class="btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in</a> if you are already a member.</p>`
+ : reason === 'vip_unavailable' || reason === 'entitlement_unknown'
+ ? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>'
+ : '<p class="muted">Your account does not have an active membership for this.</p>';
+ const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(seo.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;
+ const body = `
+<header class="page-head">
+ ${crumbs}
+ <h1>${esc(thread ? thread.title : space.name)} <span class="badge badge-vis badge-vip"><i class="fa-solid fa-star" aria-hidden="true"></i> VIP members only</span></h1>
+ ${!thread && space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
+</header>
+<section class="members-only">
+ <p>This ${thread ? 'thread' : 'space'} is for members of ${esc(creator)} on OpenVibe.VIP. Members read and reply; everyone else sees this page.</p>
+ <p>${vipJoin(mo)}</p>
+ ${why}
+</section>`;
+ return renderPage({
+ title: `${thread ? thread.title : space.name} · members only`,
+ description: `For ${creator}'s OpenVibe.VIP members on OpenVibe.Community.`,
+ canonicalPath: thread ? `/s/${space.slug}/t/${thread.slug}` : `/s/${space.slug}`,
+ robots: 'noindex,nofollow',
+ active: 'spaces',
+ footerVariant: 'compact',
+ body,
+ });
+}
+
+module.exports = { spacesPage, spacePage, threadPage, newThreadPage, membersOnlyPage, spaceHref, who };
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+
+/**
+ * Community's seam to OpenVibe.VIP: members-only forum spaces and threads.
+ *
+ * gate.check({ subject, type: 'space'|'thread', id, owner }) → { allow, reason }
+ * POST /api/v1/policies/evaluate through the product cache (createVipCache), with the owner
+ * Community stored on the row and the product's default gate
+ * { requirement: 'member', binding: 'community:members_only' } — the creator's own VIP rule for
+ * the resource, if they set one, wins. The owner passes without asking VIP.
+ * gate.joinUrl(owner, username) → the creator's page on openvibe.vip
+ * gate.cache invalidate / handleEvent (vip.membership.changed, …) / clear
+ *
+ * Convergence: Community has no Events inbox, so a cached "yes" outlives VIP's "no" by at most
+ * config.vip.ttlMs (30 s by default); see OpenVibe.VIP's README for the end-to-end bound.
+ * Fails closed: no client secret, VIP down, a refused token or a malformed answer is a "no".
+ */
+const { serviceAuth, ids } = require('openvibe-contracts');
+const { createVipClient, createVipCache } = require('./vip-client');
+
+const FALLBACK = Object.freeze({ requirement: 'member', binding: 'community:members_only' });
+
+function createVipGate({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
+ const vipConfig = config.vip || {};
+ const secret = config.oauth && config.oauth.clientSecret;
+ const tokenClient = secret ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: secret,
+ audience: 'openvibe.vip',
+ scope: 'vip.resource.policy.evaluate',
+ fetchImpl,
+ }) : null;
+ const client = createVipClient({
+ baseUrl: vipConfig.internalUrl || 'http://127.0.0.1:4620',
+ tokenClient,
+ getToken: tokenClient ? null : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set'); },
+ fetch: fetchImpl,
+ timeoutMs: vipConfig.timeoutMs || 2000,
+ log,
+ });
+ const cache = createVipCache({
+ vip: client,
+ ttlMs: vipConfig.ttlMs || 30_000,
+ denyTtlMs: vipConfig.denyTtlMs || 10_000,
+ unavailableTtlMs: vipConfig.unavailableTtlMs || 2_000,
+ now,
+ });
+
+ async function check({ subject, type, id, owner }) {
+ if (!owner) return { allow: true, reason: 'open' };
+ if (subject && subject === owner) return { allow: true, reason: 'owner' };
+ if (!subject) return { allow: false, reason: 'not_signed_in' };
+ const d = await cache.evaluate({ subject, resource: { service: 'community', type, id: String(id) }, owner, fallback: FALLBACK });
+ return { allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
+ }
+
+ const base = (vipConfig.publicUrl || 'https://openvibe.vip').replace(/\/$/, '');
+ const joinUrl = (owner, username) => `${base}/${encodeURIComponent(username || owner)}`;
+
+ return { check, joinUrl, cache, client, FALLBACK, bounds: cache.bounds };
+}
+
+const isUserSubject = (s) => typeof s === 'string' && ids.isSubjectId('user', s);
+
+module.exports = { createVipGate, isUserSubject, FALLBACK };
new file mode 100644
new file mode 100644
@@ -0,0 +1,235 @@
@@ -0,0 +1,235 @@
+// Vendored verbatim from OpenVibe.VIP client/vip-client.js at 2accbeb (openvibe-vip/client). Replace
+// with the pinned package (require('openvibe-vip/client')) once OpenVibe.VIP publishes a tag; do not edit here.
+'use strict';
+
+/**
+ * openvibe-vip/client — the consumer seam for products that honour VIP memberships (Blog, Wiki,
+ * Chat, Live, Community, …). No dependencies; Node 18+ (global fetch) or any fetch you pass.
+ *
+ * const { createVipClient } = require('openvibe-vip/client');
+ * const tokens = contracts.serviceAuth.createTokenClient({ tokenUrl, clientId, clientSecret,
+ * audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });
+ * const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });
+ *
+ * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' }, owner: authorSubject,
+ * fallback: { requirement: 'member', binding: 'blog:gated_post' } }); // the product's default gate
+ * if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED
+ *
+ * const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…', product: 'chat' }); // e.active, e.product_perks
+ * await vip.isMember('usr_…', 'usr_…'); // boolean, false on any doubt
+ *
+ * Every failure — VIP unreachable, a timeout, a refused token, a malformed answer — is a denial
+ * ({ allow: false, reason: 'vip_unavailable' } / { status: 'unknown', active: false }), never an
+ * exception and never a "yes". Pass mode: 'authoritative' for sensitive content: VIP then asks
+ * OpenVibe.Billing directly instead of its projection.
+ *
+ * Put createVipCache (below) in front of it: it holds the convergence bound every product shares.
+ */
+
+function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null, getToken = null, fetch: fetchImpl = globalThis.fetch, timeoutMs = 2000, log = null } = {}) {
+ if (typeof fetchImpl !== 'function') throw new TypeError('createVipClient: a fetch implementation is required');
+ const base = String(baseUrl).replace(/\/+$/, '');
+
+ async function headers() {
+ if (tokenClient && typeof tokenClient.authHeaders === 'function') return tokenClient.authHeaders();
+ if (typeof getToken === 'function') return { Authorization: `Bearer ${await getToken()}` };
+ throw new Error('createVipClient: tokenClient or getToken is required');
+ }
+
+ async function post(path, body, retried = false) {
+ const res = await fetchImpl(`${base}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await headers()) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (res.status === 401 && !retried && tokenClient && typeof tokenClient.invalidate === 'function') { tokenClient.invalidate(); return post(path, body, true); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || typeof data !== 'object') throw new Error(`VIP ${res.status}: ${(data && (data.code || data.detail)) || 'bad response'}`);
+ return data;
+ }
+ const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };
+
+ /** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */
+ async function evaluate({ subject = null, resource, owner = null, ruleId, mode, fallback } = {}) {
+ try {
+ const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, owner, rule_id: ruleId, mode, fallback });
+ return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
+ } catch (err) {
+ note('evaluate', err);
+ return { allow: false, reason: 'vip_unavailable', error: err.message };
+ }
+ }
+
+ /** The entitlement of `subject` to `creator` (usr_ ids or SubjectRefs). */
+ async function checkEntitlement({ subject, creator, mode, product } = {}) {
+ try {
+ const e = await post('/api/v1/entitlements/check', { subject, creator, mode, product });
+ return { ...e, active: e.active === true && e.status === 'active' };
+ } catch (err) {
+ note('checkEntitlement', err);
+ return { status: 'unknown', active: false, reason: 'vip_unavailable', error: err.message };
+ }
+ }
+
+ const isMember = async (subject, creator, opts = {}) => (await checkEntitlement({ subject, creator, mode: opts.mode })).active;
+
+ return { evaluate, checkEntitlement, isMember };
+}
+
+/**
+ * createVipCache({ vip, ttlMs, denyTtlMs, unavailableTtlMs, maxEntries, now }) — the product-side
+ * cache every consumer puts in front of the client, with ONE convergence rule:
+ *
+ * A cached "yes" lives at most ttlMs (and never past the entitlement's expires_at or VIP's
+ * valid_until). So once VIP stops granting — it has applied Billing's billing.entitlement.changed
+ * and emitted vip.membership.changed — the product stops granting within ttlMs. handleEvent(envelope)
+ * with either event (or billing.subscription.canceled / billing.transaction.reversed) drops that
+ * member's entries for that creator at once, so a product that receives the events converges
+ * immediately. A "no" lives denyTtlMs (a new member waits at most that long); a failure
+ * (vip_unavailable / unknown) lives unavailableTtlMs, and a cached "yes" is never extended by a
+ * failure: when VIP is down, entries expire into denials (fail closed).
+ *
+ * await cache.entitlement({ subject, creator, product }) → the checkEntitlement answer (cached)
+ * cache.peekEntitlement({ subject, creator, product }) → the cached answer or undefined; never waits
+ * (a miss starts a fetch in the background)
+ * await cache.evaluate({ subject, resource, owner, fallback, mode }) → the evaluate answer (cached;
+ * mode 'authoritative' is never cached)
+ * cache.invalidate({ member, creator }), cache.handleEvent(envelope), cache.clear()
+ */
+function createVipCache({ vip, ttlMs = 30_000, denyTtlMs = 10_000, unavailableTtlMs = 2_000, maxEntries = 5000, now = () => Date.now() } = {}) {
+ if (!vip || typeof vip.evaluate !== 'function' || typeof vip.checkEntitlement !== 'function') throw new TypeError('createVipCache: a VIP client is required');
+ const entries = new Map(); // key → { value, expires, member, creator }
+ const inflight = new Map(); // key → Promise
+ let generation = 0; // bumped by clear(); an answer fetched before a clear is not stored
+ const pairGen = new Map(); // `${member}|${creator}` → generation of its last invalidation
+
+ const idOf = (v) => (v && typeof v === 'object' ? (v.id != null ? String(v.id) : null) : (v != null && v !== '' ? String(v) : null));
+ const pairKey = (member, creator) => `${member || ''}|${creator || ''}`;
+
+ function lifetime(value, allowed) {
+ const t = now();
+ if (value && (value.reason === 'vip_unavailable' || value.status === 'unknown' || value.reason === 'entitlement_unknown')) return unavailableTtlMs;
+ if (!allowed) return denyTtlMs;
+ let life = ttlMs;
+ const ent = value.entitlement !== undefined ? value.entitlement : value;
+ for (const at of [ent && ent.expires_at, ent && ent.valid_until]) {
+ const ms = at ? Date.parse(at) : NaN;
+ if (Number.isFinite(ms)) life = Math.min(life, ms - t);
+ }
+ if (ent && ent.stale) life = Math.min(life, unavailableTtlMs);
+ return Math.max(0, life);
+ }
+
+ function store(key, value, allowed, member, creator, startedGen, startedPair) {
+ if (startedGen !== generation || (pairGen.get(pairKey(member, creator)) || 0) !== startedPair) return; // invalidated meanwhile
+ const life = lifetime(value, allowed);
+ if (life <= 0) { entries.delete(key); return; }
+ if (entries.size >= maxEntries) {
+ const t = now();
+ for (const [k, e] of entries) if (e.expires <= t) entries.delete(k);
+ while (entries.size >= maxEntries) entries.delete(entries.keys().next().value);
+ }
+ entries.set(key, { value, expires: now() + life, member, creator });
+ }
+
+ function fresh(key) {
+ const e = entries.get(key);
+ if (!e) return undefined;
+ if (e.expires <= now()) { entries.delete(key); return undefined; }
+ return e.value;
+ }
+
+ function load(key, member, creator, fetcher, allowedOf) {
+ const hit = fresh(key);
+ if (hit !== undefined) return Promise.resolve(hit);
+ if (inflight.has(key)) return inflight.get(key);
+ const g = generation;
+ const pg = pairGen.get(pairKey(member, creator)) || 0;
+ const p = (async () => {
+ try {
+ const value = await fetcher();
+ store(key, value, allowedOf(value), member, creator, g, pg);
+ return value;
+ } finally { inflight.delete(key); }
+ })();
+ inflight.set(key, p);
+ return p;
+ }
+
+ function entitlement({ subject, creator, product = null, mode } = {}) {
+ const member = idOf(subject);
+ const owner = idOf(creator);
+ if (!member || !owner) return Promise.resolve({ status: 'inactive', active: false, reason: 'not_signed_in' });
+ if (mode === 'authoritative') return vip.checkEntitlement({ subject: member, creator: owner, mode, product: product || undefined });
+ const key = `e|${member}|${owner}|${product || ''}`;
+ return load(key, member, owner, () => vip.checkEntitlement({ subject: member, creator: owner, product: product || undefined }), (v) => v.active === true);
+ }
+
+ function peekEntitlement(args = {}) {
+ const member = idOf(args.subject);
+ const owner = idOf(args.creator);
+ if (!member || !owner) return undefined;
+ const key = `e|${member}|${owner}|${args.product || ''}`;
+ const hit = fresh(key);
+ if (hit === undefined) entitlement(args).catch(() => {});
+ return hit;
+ }
+
+ function evaluate({ subject = null, resource, owner, fallback, mode } = {}) {
+ const member = idOf(subject);
+ const creator = idOf(owner);
+ if (mode === 'authoritative' || !resource || typeof resource !== 'object') return vip.evaluate({ subject: member, resource, owner: creator, fallback, mode });
+ if (!member) return Promise.resolve({ allow: false, reason: 'not_signed_in' });
+ const fb = fallback == null ? '' : JSON.stringify(fallback);
+ const key = `p|${member}|${creator || ''}|${resource.service}|${resource.type}|${resource.id}|${fb}`;
+ return load(key, member, creator, () => vip.evaluate({ subject: member, resource, owner: creator, fallback }), (v) => v.allow === true);
+ }
+
+ /** Drop what the cache knows about (member, creator); either may be omitted to drop more. */
+ function invalidate({ member = null, creator = null } = {}) {
+ const m = idOf(member);
+ const c = idOf(creator);
+ if (m && c) pairGen.set(pairKey(m, c), (pairGen.get(pairKey(m, c)) || 0) + 1);
+ else generation++;
+ let dropped = 0;
+ for (const [k, e] of entries) {
+ if ((!m || e.member === m) && (!c || e.creator === c)) { entries.delete(k); dropped++; }
+ }
+ return dropped;
+ }
+
+ /**
+ * An OpenVibe.Events envelope about a membership → invalidate that pair. Returns true when the
+ * event was understood. vip.membership.changed { member, creator }; billing.entitlement.changed
+ * { subject, streamer }; billing.subscription.canceled { subscription: { subscriber, streamer } };
+ * billing.transaction.reversed { to_subject (member), from_subject (creator) }.
+ */
+ function handleEvent(envelope) {
+ const e = envelope && envelope.event && envelope.event.event_type ? envelope.event : envelope;
+ const p = (e && e.payload) || {};
+ let member = null;
+ let creator = null;
+ switch (e && e.event_type) {
+ case 'vip.membership.changed': member = idOf(p.member); creator = idOf(p.creator); break;
+ case 'billing.entitlement.changed': member = idOf(p.subject); creator = idOf(p.streamer); break;
+ case 'billing.subscription.canceled': member = idOf(p.subscription && p.subscription.subscriber); creator = idOf(p.subscription && p.subscription.streamer); break;
+ case 'billing.transaction.reversed': member = idOf(p.to_subject); creator = idOf(p.from_subject); break;
+ default: return false;
+ }
+ if (!member || !creator) return false;
+ invalidate({ member, creator });
+ return true;
+ }
+
+ function clear() { generation++; entries.clear(); }
+
+ return {
+ entitlement, peekEntitlement, evaluate, invalidate, handleEvent, clear,
+ get size() { return entries.size; },
+ /** The convergence bound, for docs and /ready: how long a cached "yes" can outlive VIP's "no". */
+ bounds: Object.freeze({ grantMs: ttlMs, denyMs: denyTtlMs, unavailableMs: unavailableTtlMs }),
+ };
+}
+
+module.exports = { createVipClient, createVipCache };
new file mode 100644
new file mode 100644
@@ -0,0 +1,264 @@
@@ -0,0 +1,264 @@
+'use strict';
+/**
+ * Members-only discussion (OpenVibe.VIP): a forum space or a single thread gated to one creator's
+ * VIP members. Reading and posting need VIP's yes (POST /api/v1/policies/evaluate with the owner
+ * Community stored, through the product cache); the owner and moderators pass; signed-out, strangers,
+ * VIP down and no client secret are refused (fail closed); nothing gated reaches Pulse, the sitemap,
+ * feeds, JSON-LD or the SSR page of someone without access. Convergence: once VIP stops granting,
+ * Community stops within the cache TTL (30 s), and at once on vip.membership.changed.
+ */
+const http = require('http');
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+/** A stand-in for VIP's evaluate: answers from `allow` (member subject → true), records calls. */
+function startVip() {
+ const state = { allow: new Set(), down: false, calls: [] };
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ if (state.down) { req.socket.destroy(); return; }
+ let body = {};
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* */ }
+ state.calls.push({ path: req.url, auth: req.headers.authorization || '', body });
+ if (req.method !== 'POST' || req.url !== '/api/v1/policies/evaluate') { res.writeHead(404); return res.end('{}'); }
+ const yes = state.allow.has(`${body.subject}|${body.owner}`);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(yes
+ ? { allow: true, reason: 'member', rule: null, fallback: true, entitlement: { status: 'active', active: true, expires_at: '2100-01-01T00:00:00.000Z', valid_until: '2100-01-01T00:00:00.000Z' } }
+ : { allow: false, reason: 'not_a_member', rule: null, fallback: true, entitlement: { status: 'inactive', active: false } }));
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ ...state, state, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
+}
+
+(async () => {
+ const vip = await startVip();
+ process.env.OV_VIP_INTERNAL_URL = vip.url;
+ process.env.OV_VIP_URL = 'https://openvibe.vip';
+ const clock = { t: Date.now() };
+ const t = await boot({
+ authority: 'community',
+ appOpts: {
+ vipOptions: { now: () => clock.t },
+ forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, votes: { perMinute: 1000 }, threadsPerDay: 1000 },
+ },
+ });
+ const net = t.network;
+ const mk = (id, username, role = 'user') => {
+ const u = net.addUser({ network_user_id: id, username, display_name: username });
+ return { ...u, jwt: net.sign({ id, subject_id: u.subject_id, username, display_name: username, role }) };
+ };
+ const cora = mk(21, 'cora'); // the creator
+ const mia = mk(22, 'mia'); // her member
+ const stan = mk(23, 'stan'); // not a member
+ const newbie = mk(24, 'newbie'); // a member VIP cannot confirm while down
+ const conv = mk(25, 'conv'); // convergence
+ const boss = mk(26, 'boss', 'global_mod');
+ vip.state.allow.add(`${mia.subject_id}|${cora.subject_id}`);
+ vip.state.allow.add(`${newbie.subject_id}|${cora.subject_id}`);
+
+ const call = (path, { method = 'GET', who, json, headers = {} } = {}) => {
+ const h = { ...headers };
+ let body;
+ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
+ return t.get(path, { method, headers: h, body, cookies: who ? [`ov_token=${who.jwt}`] : [] });
+ };
+ const form = (path, who, fields) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(fields).toString(), cookies: who ? [`ov_token=${who.jwt}`] : [] });
+ const evaluateCalls = () => vip.state.calls.filter((c) => c.path === '/api/v1/policies/evaluate').length;
+ let secret; // a gated thread in a public space
+ let showcaseThread; // a thread in the gated space
+
+ await check('marking: moderators gate a space for a creator; others cannot; the owner must be a usr_ subject', async () => {
+ assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: cora, json: { owner: cora.subject_id } })).status, 403);
+ assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: 'cora' } })).status, 400);
+ const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: cora.subject_id } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json().space.members_only, { owner: cora.subject_id, owner_username: 'cora', join_url: 'https://openvibe.vip/cora' });
+ const list = (await call('/api/v1/spaces', { who: stan })).json().spaces;
+ assert.strictEqual(list.find((s) => s.slug === 'showcase').members_only.join_url, 'https://openvibe.vip/cora', 'the space is listed with its join link');
+ assert.strictEqual(list.find((s) => s.slug === 'general').members_only, null);
+ });
+
+ await check('a gated space: the member, the owner and moderators read and post; VIP is asked with the owner and the default gate', async () => {
+ const before = vip.state.calls.length;
+ const r = await call('/api/v1/spaces/showcase/threads', { method: 'POST', who: mia, json: { title: 'Members lounge', body: 'members-only body text' } });
+ assert.strictEqual(r.status, 201, r.text);
+ showcaseThread = r.json().thread;
+ const asked = vip.state.calls.slice(before).find((c) => c.path === '/api/v1/policies/evaluate');
+ assert.deepStrictEqual(asked.body.resource, { service: 'community', type: 'space', id: 'showcase' });
+ assert.strictEqual(asked.body.owner, cora.subject_id);
+ assert.strictEqual(asked.body.subject, mia.subject_id);
+ assert.deepStrictEqual(asked.body.fallback, { requirement: 'member', binding: 'community:members_only' });
+ assert.ok(/^Bearer /.test(asked.auth), 'a service token for openvibe.vip');
+ assert.ok(net.grants.some((g) => g.audience === 'openvibe.vip' && g.scope === 'vip.resource.policy.evaluate'));
+ assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: mia })).status, 200);
+ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who: mia })).json().posts[0].body_markdown, 'members-only body text');
+ const n = evaluateCalls();
+ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who: cora })).status, 200, 'the owner');
+ assert.strictEqual(evaluateCalls(), n, 'the owner passes without asking VIP');
+ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/posts`, { method: 'POST', who: boss, json: { body: 'mod note' } })).status, 201, 'moderators');
+ });
+
+ await check('a gated space refuses strangers and signed-out visitors with 403 vip.members_only and the join link', async () => {
+ for (const [who, reason] of [[stan, 'not_a_member'], [null, 'not_signed_in']]) {
+ const r = await call('/api/v1/spaces/showcase/threads', { who });
+ assert.strictEqual(r.status, 403, r.text);
+ const p = r.json();
+ assert.strictEqual(p.code, 'vip.members_only');
+ assert.strictEqual(p.reason, reason);
+ assert.strictEqual(p.members_only.join_url, 'https://openvibe.vip/cora');
+ assert.ok(!r.text.includes('members-only body text'));
+ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}`, { who })).status, 403);
+ }
+ assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { method: 'POST', who: stan, json: { title: 'Let me in', body: 'x' } })).status, 403);
+ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/posts`, { method: 'POST', who: stan, json: { body: 'x' } })).status, 403);
+ assert.strictEqual((await call(`/api/v1/spaces/showcase/threads/${showcaseThread.slug}/votes`, { method: 'POST', who: stan, json: { value: 1 } })).status, 403);
+ });
+
+ await check('VIP down: a member VIP cannot confirm is refused (fail closed)', async () => {
+ vip.state.down = true;
+ try {
+ const r = await call('/api/v1/spaces/showcase/threads', { who: newbie });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().reason, 'vip_unavailable');
+ } finally { vip.state.down = false; }
+ clock.t += 2_001; // failures are cached 2 s
+ assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: newbie })).status, 200);
+ });
+
+ await check('no client secret: nobody but the owner gets in, and VIP is never called', async () => {
+ const { createVipGate } = require('../server/vip');
+ const config = require('../server/config');
+ const gate = createVipGate({ config: { ...config, oauth: { clientId: 'community', clientSecret: '' } } });
+ const n = vip.state.calls.length;
+ const d = await gate.check({ subject: mia.subject_id, type: 'space', id: 'showcase', owner: cora.subject_id });
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);
+ assert.strictEqual((await gate.check({ subject: cora.subject_id, type: 'space', id: 'showcase', owner: cora.subject_id })).allow, true);
+ assert.strictEqual(vip.state.calls.length, n);
+ });
+
+ await check('a gated thread in a public space: the author gates it to their members; listed by title only', async () => {
+ const r = await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Backstage with Cora', body: 'secret backstage body', members_only: true } });
+ assert.strictEqual(r.status, 201, r.text);
+ secret = r.json().thread;
+ assert.strictEqual(secret.members_only.owner, cora.subject_id);
+ const listed = (await call('/api/v1/spaces/general/threads?sort=new', { who: stan })).json();
+ const row = listed.threads.find((x) => x.id === secret.id);
+ assert.strictEqual(row.title, 'Backstage with Cora');
+ assert.strictEqual(row.members_only.join_url, 'https://openvibe.vip/cora');
+ assert.ok(!JSON.stringify(listed).includes('secret backstage body'));
+ const refused = await call(`/api/v1/spaces/general/threads/${secret.slug}`, { who: stan });
+ assert.strictEqual(refused.status, 403);
+ assert.strictEqual(refused.json().gate, 'thread');
+ assert.ok(!refused.text.includes('secret backstage body'));
+ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${secret.slug}`, { who: mia })).json().posts[0].body_markdown, 'secret backstage body');
+ const reply = await call(`/api/v1/spaces/general/threads/${secret.slug}/posts`, { method: 'POST', who: mia, json: { body: 'member reply' } });
+ assert.strictEqual(reply.status, 201);
+ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${secret.slug}/posts`, { method: 'POST', who: stan, json: { body: 'x' } })).status, 403);
+ // Edits need access too: stan cannot edit (not his), and an author who lost access cannot either.
+ assert.strictEqual((await call(`/api/v1/posts/${reply.json().post.id}`, { method: 'PUT', who: stan, json: { body: 'y' } })).status, 403);
+ });
+
+ await check('thread gating permissions: only the author (own members) or moderators; others cannot claim someone else\'s members', async () => {
+ const open = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: stan, json: { title: 'Stan open thread', body: 'open body' } })).json().thread;
+ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: mia, json: { owner: mia.subject_id } })).status, 403, 'not the author');
+ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: cora.subject_id } })).status, 403, 'the author gates to their own members only');
+ const own = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: true } });
+ assert.strictEqual(own.status, 200, own.text);
+ assert.strictEqual(own.json().thread.members_only.owner, stan.subject_id);
+ const cleared = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: null } });
+ assert.strictEqual(cleared.json().thread.members_only, null);
+ assert.strictEqual((await call('/api/v1/spaces/general/threads', { method: 'POST', who: stan, json: { title: 'Claim', body: 'x', members_only: { owner: cora.subject_id } } })).status, 403);
+ const mod = await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: boss, json: { owner: cora.subject_id } });
+ assert.strictEqual(mod.json().thread.members_only.owner, cora.subject_id, 'moderators name any creator');
+ assert.strictEqual((await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: stan, json: { owner: null } })).status, 403, 'the author cannot open what a moderator gated to another creator');
+ await call(`/api/v1/spaces/general/threads/${open.slug}/members-only`, { method: 'PUT', who: boss, json: { owner: null } });
+ });
+
+ await check('nothing gated leaks: Pulse, sitemap, feeds, space feed, JSON-LD', async () => {
+ const pulse = (await call('/api/v1/pulse')).text;
+ assert.ok(!pulse.includes('Backstage with Cora') && !pulse.includes('Members lounge') && !pulse.includes('member reply'));
+ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS c FROM pulse_items WHERE source_type = 'thread' AND source_id IN (?, ?)").get(String(secret.id), String(showcaseThread.id)).c, 0);
+ const sitemap = (await call('/sitemap.xml')).text;
+ assert.ok(!sitemap.includes(secret.slug) && !sitemap.includes('/s/showcase'), 'no gated thread or space in the sitemap');
+ const feed = (await call('/s/feed.xml')).text;
+ assert.ok(!feed.includes('Backstage') && !feed.includes('Members lounge'));
+ assert.strictEqual((await call('/s/showcase/feed.xml')).status, 404);
+ const general = (await call('/s/general/feed.xml')).text;
+ assert.ok(!general.includes('Backstage') && !general.includes('secret backstage body'));
+ // A thread gated after the fact leaves Pulse as well.
+ const later = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Soon private', body: 'soon' } })).json().thread;
+ assert.ok((await call('/api/v1/pulse')).text.includes('Soon private'));
+ await call(`/api/v1/spaces/general/threads/${later.slug}/members-only`, { method: 'PUT', who: cora, json: { owner: true } });
+ assert.ok(!(await call('/api/v1/pulse')).text.includes('Soon private'));
+ // Pulse's read-time check alone hides an item nothing removed.
+ t.db.prepare("INSERT INTO pulse_items (source_service, source_type, source_id, title, url, origin, visibility, occurred_at) VALUES ('community', 'thread', ?, 'stale gated item', 'https://openvibe.community/x', 'user', 'public', datetime('now'))").run(String(secret.id));
+ assert.ok(!(await call('/api/v1/pulse')).text.includes('stale gated item'));
+ });
+
+ await check('SSR: a teaser with the join link (403, noindex) for outsiders; the posts for members, still noindex and without JSON-LD', async () => {
+ const anon = await call(`/s/general/t/${secret.slug}`);
+ assert.strictEqual(anon.status, 403);
+ assert.ok(anon.text.includes('https://openvibe.vip/cora'));
+ assert.ok(anon.text.includes('Backstage with Cora'));
+ assert.ok(!anon.text.includes('secret backstage body') && !anon.text.includes('member reply'));
+ assert.ok(/noindex/.test(anon.text));
+ assert.ok(anon.text.includes('/auth/login?next='), 'signed out: a sign-in link');
+ const space = await call('/s/showcase', { who: stan });
+ assert.strictEqual(space.status, 403);
+ assert.ok(space.text.includes('https://openvibe.vip/cora') && !space.text.includes('Members lounge'));
+ assert.strictEqual((await call('/s/showcase/new', { who: stan })).status, 403, 'no new-thread form for outsiders');
+ const reply = await form(`/s/general/t/${secret.slug}/reply`, stan, { body: 'sneaky' });
+ assert.strictEqual(reply.status, 403);
+ const member = await call(`/s/general/t/${secret.slug}`, { who: mia });
+ assert.strictEqual(member.status, 200);
+ assert.ok(member.text.includes('secret backstage body'));
+ assert.ok(/noindex/.test(member.text));
+ assert.ok(!member.text.includes('DiscussionForumPosting'), 'no structured data for a gated thread');
+ const listing = await call('/s/general', { who: stan });
+ assert.ok(listing.text.includes('Backstage with Cora') && listing.text.includes('VIP members only'));
+ assert.ok(!listing.text.includes('secret backstage body'));
+ // The author's no-JS toggle.
+ const own = (await call('/api/v1/spaces/general/threads', { method: 'POST', who: cora, json: { title: 'Toggle me', body: 'toggle' } })).json().thread;
+ const page = await call(`/s/general/t/${own.slug}`, { who: cora });
+ assert.ok(page.text.includes(`/s/general/t/${own.slug}/members-only`));
+ const on = await form(`/s/general/t/${own.slug}/members-only`, cora, { on: '1' });
+ assert.strictEqual(on.status, 303);
+ assert.strictEqual((await call(`/s/general/t/${own.slug}`, { who: stan })).status, 403);
+ });
+
+ await check('convergence: VIP stops granting → Community refuses within the cache TTL (30 s); at once on vip.membership.changed', async () => {
+ vip.state.allow.add(`${conv.subject_id}|${cora.subject_id}`);
+ const path = `/api/v1/spaces/general/threads/${secret.slug}`;
+ assert.strictEqual((await call(path, { who: conv })).status, 200);
+ vip.state.allow.delete(`${conv.subject_id}|${cora.subject_id}`); // VIP applied the change
+ clock.t += 29_999;
+ assert.strictEqual((await call(path, { who: conv })).status, 200, 'inside the bound the cached yes may still answer');
+ clock.t += 2;
+ assert.strictEqual((await call(path, { who: conv })).status, 403, 'past 30 s VIP is asked again and says no');
+ // With the event: at once.
+ vip.state.allow.add(`${conv.subject_id}|${cora.subject_id}`);
+ clock.t += 10_001; // the cached "no" expires
+ assert.strictEqual((await call(path, { who: conv })).status, 200);
+ vip.state.allow.delete(`${conv.subject_id}|${cora.subject_id}`);
+ const understood = t.app.locals.vip.cache.handleEvent({ event_type: 'vip.membership.changed', payload: { member: { type: 'user', id: conv.subject_id }, creator: { type: 'user', id: cora.subject_id }, active: false } });
+ assert.strictEqual(understood, true);
+ assert.strictEqual((await call(path, { who: conv })).status, 403, 'no clock movement needed');
+ assert.strictEqual(t.app.locals.vip.bounds.grantMs, 30_000);
+ });
+
+ await check('a moderator opens a space again; guest subjects and ids are never owners', async () => {
+ assert.strictEqual((await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: ids.newId('guest') } })).status, 400);
+ const r = await call('/api/v1/spaces/showcase/members-only', { method: 'PUT', who: boss, json: { owner: null } });
+ assert.strictEqual(r.json().space.members_only, null);
+ assert.strictEqual((await call('/api/v1/spaces/showcase/threads', { who: stan })).status, 200);
+ assert.ok((await call('/sitemap.xml')).text.includes('/s/showcase'));
+ });
+
+ await t.close();
+ await vip.close();
+ done();
+})();