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

OP OpenVibers committed 23 Sept 2026 · 57d1dd4cbd · 16 files, +891 −54

- spaces.members_only_owner / threads.members_only_owner (idempotent ADD COLUMN): the creator's usr_ subject. Moderators gate spaces for any creator (PUT /api/v1/spaces/:space/members-only); a thread's author gates it to their own members and moderators to any creator (PUT /api/v1/spaces/:space/threads/:slug/members-only, members_only on POST …/threads, the new-thread checkbox and a no-JS button on the thread page).
- Listing a gated space's threads, reading a thread, starting a thread, replying, voting, editing and post history need an active entitlement: POST /api/v1/policies/evaluate on VIP with { resource: { service: 'community', type: 'space'|'thread', id }, owner (the stored creator), fallback { requirement: 'member', binding: 'community:members_only' } }; a gated thread in a gated space needs both. The owner (without asking VIP) and discussion moderators pass. Everything else fails closed — signed out, not a member, VIP down, no client secret — as 403 vip.members_only { reason, gate, members_only: { owner, owner_username, join_url } }; pages render a noindex teaser with the join link to openvibe.vip, never a post. Listings keep a gated thread's title with members_only, never a body.
- Gated spaces and threads never reach Pulse (not recorded, removed when gated later, filtered at read time), the Discord relay (not queued, pending deliveries dropped, re-checked at send), the sitemap, the RSS feeds or JSON-LD; a member's view is noindex too.
- server/vip/: OpenVibe.VIP's client/vip-client.js vendored at 2accbeb (replace with the pinned package once VIP tags a release) and the gate: client-credentials token for openvibe.vip (vip.resource.policy.evaluate), createVipCache with yes 30 s / no 10 s / failure 2 s (OV_VIP_INTERNAL_URL, OV_VIP_URL, VIP_CACHE_*). Community has no Events inbox, so once VIP stops granting Community stops within 30 s; cache.handleEvent converges at once.
- test/members-only.test.js (stub VIP): member, owner, moderator, stranger, signed out, VIP down, no secret, marking permissions, no leaks to Pulse/sitemap/feeds/JSON-LD/SSR, and convergence with an injected clock (allowed at 29.999 s, refused at 30.001 s; at once on vip.membership.changed).
- README (members-only section, configuration, grant needed: community → vip.resource.policy.evaluate on openvibe.vip) and .env.example.

Changes

ddiff --git a/.env.example b/.env.example
iindex 697334e..2dcdea6 100644
---- a/.env.example
++++ b/.env.example
@@@ -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)
ddiff --git a/README.md b/README.md
iindex 53c643a..c5c4cac 100644
---- a/README.md
++++ b/README.md
@@@ -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 vote
rows in the same IMMEDIATE transaction (`server/votes.js`), so concurrent votes cannot drift
a 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 client
pulse/ 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 limits
render/layout.js page shell: SEO head, shared chrome, hashed assets
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/comments.js a comment thread's own page
render/markdown.js the safe Markdown subset for posts
ddiff --git a/server/app.js b/server/app.js
iindex 81313fc..5a7ebe9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 ──────────────────────────────────────
ddiff --git a/server/config.js b/server/config.js
iindex c8f3555..5fb4ff7 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/db.js b/server/db.js
iindex 222bcfd..7482232 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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). */
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 6177874..6f12106 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -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;
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex 2f578d2..227da18 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -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 || {};
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 365ad10..b21ac1d 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -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, li
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). */
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, li
const 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, li
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'];
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, li
can_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, li
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)`);
}
-- 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, li
const { 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, li
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). */
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, li
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)); },
};
}
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex e06d714..9982dc4 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -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_id
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);
}
@@@ -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,
};
ddiff --git a/server/pulse/service.js b/server/pulse/service.js
iindex 2583756..18a73fc 100644
---- a/server/pulse/service.js
++++ b/server/pulse/service.js
@@@ -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,
ddiff --git a/server/pulse/store.js b/server/pulse/store.js
iindex b85cb82..a104ba6 100644
---- a/server/pulse/store.js
++++ b/server/pulse/store.js
@@@ -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 DESC
LIMIT @limit`).all({ origin, at: before ? before[0] : null, id: before ? before[1] : null, limit: limit + 1 });
const hasMore = rows.length > limit;
ddiff --git a/server/relay/discord.js b/server/relay/discord.js
iindex 060fefd..06907b7 100644
---- a/server/relay/discord.js
++++ b/server/relay/discord.js
@@@ -51,6 +51,7 @@ function createDiscordRelay({ db, config = {}, env = process.env, fetchImpl = gl
if (!enabled || !thread || !space) return 0;
if (thread.origin === 'discord') return 0; // loop prevention
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);
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 = gl
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.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` });
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex 3ba4631..94f930e 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -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, u
footerVariant: '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 };
ddiff --git a/server/vip/index.js b/server/vip/index.js
nnew file mode 100644
iindex 0000000..febc31a
---- /dev/null
++++ b/server/vip/index.js
@@@ -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 };
ddiff --git a/server/vip/vip-client.js b/server/vip/vip-client.js
nnew file mode 100644
iindex 0000000..536abc3
---- /dev/null
++++ b/server/vip/vip-client.js
@@@ -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 };
ddiff --git a/test/members-only.test.js b/test/members-only.test.js
nnew file mode 100644
iindex 0000000..8128078
---- /dev/null
++++ b/test/members-only.test.js
@@@ -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();
++})();