openvibers/openvibe.wiki Commits

VIP spaces and pages (roadmap WS-K task 8; Contracts 0.64.0, pinned from 0.49.0). A new visibility, vip: read by the space's roles and by the viewers OpenVibe.VIP admits as members of the space owner (vip.resource.policy.evaluate; resource wiki/page when the page itself is VIP-only, else wiki/space; fallback wiki:gated_page, so an owner's VIP rule wins). The decision is made before render: route params trigger access.prepareVip, which records the answers on the actor for the synchronous checks (cached through openvibe-sdk/vip; the JSON export asks authoritatively). Others get a join prompt (403, never cached) linking to the owner's VIP plans, and every doubt refuses. VIP content never reaches search, sitemaps, feeds or another viewer's lists. Official spaces refuse vip. Existing databases get their visibility CHECK widened in place at startup (writable_schema in one transaction, integrity-checked; tried on a copy of production). test/vip-gate.test.js

OP OpenVibers committed 26 Sept 2026 · c76d051481 · 14 files, +375 −36


Changes

ddiff --git a/README.md b/README.md
iindex cda3f71..563b733 100644
---- a/README.md
++++ b/README.md
@@@ -62,9 +62,24 @@ records who attached each Media object and what Media said about it at that mome
- **Spaces**: official (created by Network staff, or a service acting as itself) and user spaces
(created by any signed-in person, who becomes owner). Visibility `public` (anyone), `members`
-- (any signed-in OpenVibe account), `private` (people with a role). Roles `owner` (settings, roles,
++ (any signed-in OpenVibe account), `vip` (the space owner's OpenVibe.VIP members, see below), `private`
++ (people with a role). Roles `owner` (settings, roles,
delete, page visibility), `editor` (write, publish, revert, review proposals), `viewer` (read a
private space). Staff act as owners of official spaces only; they get no silent access to user spaces.
++- **VIP spaces and pages** (roadmap WS-K task 8, Contracts 0.64.0): a `vip` space or page is read by the
++ space's roles and by the viewers OpenVibe.VIP admits as members of the space owner.
++ - *Who decides:* Wiki asks `vip.resource.policy.evaluate` (`server/integrations/vip.js`). The resource
++ is `wiki/page/<id>` when the page itself is VIP-only, else `wiki/space/<id>`, with the fallback
++ `wiki:gated_page`, so a rule the owner sets in VIP wins.
++ - *When it asks:* before rendering. Route params trigger `access.prepareVip`, answers are cached
++ (a yes 30 s, a no 10 s), and the JSON export asks authoritatively.
++ - *Who is refused:* others get a join prompt (403, never cached) linking to the owner's VIP plans.
++ Every doubt refuses.
++ - *Where it never shows:* search, sitemaps, feeds, and another viewer's lists.
++ - *Official spaces* have no VIP owner and refuse `vip`.
++ - *Migration:* databases made before it get their visibility CHECK widened in place at startup
++ (`widenVisibilityChecks`, integrity-checked).
++ - Config: `OV_VIP_INTERNAL_URL`, `OV_VIP_URL`, and `WIKI_VIP_*` for the timeout and cache.
- **Pages**: a tree per space, canonical slugs, rename/move with history-aware 301s (chains
collapse; renaming a space redirects all its pages), deletion answers 410 at every old address.
- **Revisions**: immutable, optimistic concurrency (`expected_revision` → 412 on conflict), word and
ddiff --git a/package-lock.json b/package-lock.json
iindex 805410f..65f31d4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,7 +14,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
@@@ -999,9 +999,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.49.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
++ "version": "0.64.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
++ "integrity": "sha512-WoTx6IpmcWtKM+DMQDYv1qnZOO9wZJbG6wKd5VNjF7H5gOXsFDPIV9UWqf4FHK3dxrg2cVvQxOwTiPxsztqObA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 693d52b..d956c40 100644
---- a/package.json
++++ b/package.json
@@@ -20,7 +20,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.64.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
ddiff --git a/server/config.js b/server/config.js
iindex a411590..04b005d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -57,6 +57,16 @@ function load(env = process.env) {
mediaApp: env.WIKI_MEDIA_APP || 'wiki',
mediaVerifyIntervalMs: int(env.WIKI_MEDIA_VERIFY_INTERVAL_MS, 6 * 60 * 60 * 1000),
++ // OpenVibe.VIP: who may read a VIP space or page (server/integrations/vip.js, WS-K task 8).
++ vip: {
++ internalUrl: trim(env.OV_VIP_INTERNAL_URL || 'http://127.0.0.1:4620'),
++ publicUrl: trim(env.OV_VIP_URL || 'https://openvibe.vip'),
++ timeoutMs: int(env.WIKI_VIP_TIMEOUT_MS, 2000),
++ ttlMs: int(env.WIKI_VIP_CACHE_TTL_MS, 30_000),
++ denyTtlMs: int(env.WIKI_VIP_CACHE_DENY_TTL_MS, 10_000),
++ unavailableTtlMs: int(env.WIKI_VIP_CACHE_UNAVAILABLE_TTL_MS, 2_000),
++ },
++
// Scheduled publication worker.
scheduleIntervalMs: int(env.WIKI_SCHEDULE_INTERVAL_MS, 15000),
workerId: env.WIKI_WORKER_ID || `wiki-${process.pid}`,
ddiff --git a/server/db.js b/server/db.js
iindex 4aa7efb..9f68ec5 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS wiki_spaces (
name TEXT NOT NULL,
description TEXT,
kind TEXT NOT NULL CHECK (kind IN ('official','user')),
-- visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','private')),
++ visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),
owner TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
@@@ -54,7 +54,7 @@ CREATE TABLE IF NOT EXISTS wiki_pages (
parent_id TEXT REFERENCES wiki_pages(id),
position INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),
-- visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','private')),
++ visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','members','vip','private')),
noindex INTEGER NOT NULL DEFAULT 0,
published_revision INTEGER,
published_at INTEGER,
@@@ -173,9 +173,41 @@ function openDb(dbPath) {
* Creates every table (package tables first: the infobox purge trigger references
* wiki_page_revision_purges) and returns the package stores bound to this database.
*/
++/**
++ * Contracts 0.64.0 (WS-K task 8) adds the `vip` visibility. Tables created before it carry
++ * CHECK (visibility IN ('public','members','private')); SQLite cannot ALTER a CHECK, and widening one
++ * changes no stored row, so the table's CREATE statement is rewritten in place (SQLite's documented
++ * writable_schema procedure, in one transaction with a schema_version bump), then integrity-checked.
++ * Idempotent: a database already on the new text is left alone. Returns the tables changed.
++ */
++const OLD_VIS_CHECK = "CHECK (visibility IN ('public','members','private'))";
++const NEW_VIS_CHECK = "CHECK (visibility IN ('public','members','vip','private'))";
++function widenVisibilityChecks(db) {
++ const rows = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name IN ('wiki_spaces', 'wiki_pages')").all()
++ .filter((r) => r.sql && r.sql.includes(OLD_VIS_CHECK));
++ if (!rows.length) return [];
++ db.unsafeMode(true);
++ try {
++ db.transaction(() => {
++ const version = db.pragma('schema_version', { simple: true });
++ db.pragma('writable_schema = ON');
++ const set = db.prepare("UPDATE sqlite_master SET sql = ? WHERE type = 'table' AND name = ?");
++ for (const r of rows) set.run(r.sql.split(OLD_VIS_CHECK).join(NEW_VIS_CHECK), r.name);
++ db.pragma(`schema_version = ${Number(version) + 1}`);
++ db.pragma('writable_schema = OFF');
++ })();
++ } finally {
++ db.unsafeMode(false);
++ }
++ const check = db.pragma('integrity_check', { simple: true });
++ if (check !== 'ok') throw new Error(`wiki: integrity_check after widening the visibility CHECK: ${check}`);
++ return rows.map((r) => r.name);
++}
++
function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } = {}) {
const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
db.exec(SCHEMA);
++ widenVisibilityChecks(db);
return {
revisions,
citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),
@@@ -188,4 +220,4 @@ function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } =
};
}
--module.exports = { openDb, createStores, SCHEMA };
++module.exports = { openDb, createStores, widenVisibilityChecks, SCHEMA };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex f0e49e8..330ee56 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -99,6 +99,17 @@ function createApi({ svc, viewers, platform, config, log = console }) {
});
router.use(actorMiddleware(viewers));
const R = (fn, status) => run(fn, status, log);
++ // VIP (WS-K task 8): ask VIP about the space or page a route names before its synchronous access check.
++ const prepare = (req, pairs, next) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs).then(() => next(), next) : next());
++ router.param('space', (req, res, next, ref) => {
++ const space = svc.findSpace(ref);
++ return space && !space.deleted_at ? prepare(req, [{ space }, ...svc.vipPagesOf(space.id).map((page) => ({ space, page }))], next) : next();
++ });
++ router.param('id', (req, res, next, id) => {
++ const page = svc.pageById(id);
++ const space = page && svc.spaceById(page.space_id);
++ return space ? prepare(req, [{ space }, { space, page }], next) : next();
++ });
const pageAndSpace = (id) => {
const page = svc.pageById(id);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex d32b55c..bb9f8bf 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -42,6 +42,16 @@ function createPages({ svc, viewers, platform, config, log = console }) {
const form = express.urlencoded({ extended: false, limit: '600kb' });
router.use(actorMiddleware(viewers, { services: false }));
++ // VIP (WS-K task 8): before any handler's synchronous access check, ask VIP about the space and page this
++ // URL names (and a space's VIP-only pages, for its tree). The JSON export is sensitive: VIP asks Billing.
++ router.param('space', (req, res, next, ref) => {
++ const space = svc.findSpace(ref);
++ if (!space || space.deleted_at || !req.actor || !req.actor.subject) return next();
++ const page = req.params.slug ? svc.findPage(space.id, req.params.slug) : null;
++ const pairs = [{ space }, ...(page ? [{ space, page }] : svc.vipPagesOf(space.id).map((p) => ({ space, page: p })))];
++ svc.access.prepareVip(req.actor, pairs, { sensitive: /\.json$/.test(req.path) }).then(() => next(), next);
++ });
++
const origin = new URL(config.baseUrl).origin;
// Cross-site form posts are refused (the session cookie is SameSite=Lax as well).
router.use((req, res, next) => {
@@@ -59,6 +69,13 @@ function createPages({ svc, viewers, platform, config, log = console }) {
const errorPage = (req, res, status, title, message) => send(req, res, status, views.errorBody({ status, title, message }), { title, robots: 'noindex, nofollow' });
const notFound = (req, res) => errorPage(req, res, 404, 'Page not found', 'Nothing lives at that address, or it is not visible to you.');
const gone = (req, res) => errorPage(req, res, 410, 'Gone', 'This page was deleted. Its history is kept, but it is no longer published.');
++ // VIP (WS-K task 8): the join prompt for a VIP space or page the viewer is not admitted to (403, never cached).
++ const vipGate = (req, res, space, page) => {
++ const r = svc.access.vipRefusal(space, page, req.actor);
++ if (!r) return false;
++ send(req, res, 403, views.vipGateBody({ title: page ? page.title : space.name, spaceName: space.name, joinUrl: r.joinUrl, signedIn: !!req.actor.subject, signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }), { title: 'For VIP members', robots: 'noindex, nofollow' });
++ return true;
++ };
const needSignIn = (req, res, message) => send(req, res, 401, views.signInPage({ next: req.originalUrl, message }), { title: 'Sign in', robots: 'noindex, nofollow' });
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch((err) => {
if (err && err.status && err.status < 500) return errorPage(req, res, err.status, 'That did not work', err.message);
@@@ -77,7 +94,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
notFound(req, res);
return null;
}
-- if (!svc.access.canReadPage(space, page, req.actor)) { notFound(req, res); return null; }
++ if (!svc.access.canReadPage(space, page, req.actor)) { if (page.state !== 'published' || !vipGate(req, res, space, page)) notFound(req, res); return null; }
return { space, page };
}
@@@ -90,19 +107,20 @@ function createPages({ svc, viewers, platform, config, log = console }) {
notFound(req, res);
return null;
}
-- if (!svc.access.canReadSpace(space, req.actor)) { notFound(req, res); return null; }
++ if (!svc.access.canReadSpace(space, req.actor)) { if (!vipGate(req, res, space, null)) notFound(req, res); return null; }
return space;
}
// ── Home, recent, search ─────────────────────────────────
-- router.get('/', (req, res) => {
++ router.get('/', wrap(async (req, res) => {
++ if (req.actor.subject) await svc.access.prepareVip(req.actor, svc.vipSpaces().map((space) => ({ space }))); // VIP spaces the viewer may see
const spaces = svc.listSpaces(req.actor);
const recent = svc.recentChanges(10);
send(req, res, 200, views.home({ spaces, recent, actor: req.actor }) + frame.shipped({ service: 'wiki', title: 'Recently shipped on OpenVibe.Wiki' }), {
robots: 'index, follow', cache: 'public', active: 'home', path: '/',
jsonLd: seo.structuredData.webPage({ url: `${config.baseUrl}/`, name: 'OpenVibe.Wiki', description: 'Wiki spaces of the OpenVibe network.' }),
});
-- });
++ }));
// What shipped on OpenVibe.Wiki: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(req, res, 200, frame.updatesBody({ service: 'wiki', siteName: 'OpenVibe.Wiki' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, { title: 'What shipped on OpenVibe.Wiki', robots: 'index, follow', cache: 'public', path: '/updates' }));
router.get('/recent', (req, res) => send(req, res, 200, views.recentPage({ items: svc.recentChanges(100) }), { title: 'Recent changes', robots: 'noindex, follow', cache: 'public', active: 'recent' }));
ddiff --git a/server/index.js b/server/index.js
iindex 01279e7..eddced4 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -21,7 +21,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
const db = openDb(config.dbPath);
const stores = createStores(db, { now });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
-- const svc = createWikiService({ db, stores, outbox: platform.outbox, config, community: platform.community, now, log });
++ const svc = createWikiService({ db, stores, outbox: platform.outbox, config, community: platform.community, vip: platform.vip, now, log });
const reconciled = svc.reconcileIndex();
if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
ddiff --git a/server/integrations/platform.js b/server/integrations/platform.js
iindex 89a16a1..3c3ba1d 100644
---- a/server/integrations/platform.js
++++ b/server/integrations/platform.js
@@@ -171,8 +171,11 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
},
};
++ // ── VIP: VIP spaces and pages (WS-K task 8) ──
++ const vip = require('./vip').createVip({ config, tokenClient, fetchImpl, now, log });
++
return {
-- tokenClient, sdk, events, outbox, community, sources, media,
++ tokenClient, sdk, events, outbox, community, sources, media, vip,
eventsConfigured: !!(tokenClient && config.eventsUrl),
};
}
ddiff --git a/server/integrations/vip.js b/server/integrations/vip.js
nnew file mode 100644
iindex 0000000..73fefe2
---- /dev/null
++++ b/server/integrations/vip.js
@@@ -0,0 +1,53 @@
++'use strict';
++/**
++ * OpenVibe.VIP — who may read a VIP space or page (roadmap WS-K task 8). Wiki asks VIP's gated-resource
++ * policy (POST /api/v1/policies/evaluate, capability vip.resource.policy.evaluate, a client-credentials
++ * token for audience openvibe.vip) with:
++ *
++ * resource { service: 'wiki', type: 'page' | 'space', id } the page when the page itself is VIP-only,
++ * else its space (access.js vipResource)
++ * owner the space's owner subject: the creator whose members may read. An official space has no
++ * owner in VIP, so its VIP content admits only the space's roles and staff.
++ * fallback { requirement: 'member', binding: 'wiki:gated_page' }: any active member of the owner, or,
++ * when the owner defines a perk bound to `wiki gated_page`, a member whose plan version includes
++ * it. A rule the owner sets in VIP for the page or space wins.
++ *
++ * Answers go through openvibe-sdk/vip's createVipCache: a "yes" lives at most vip.ttlMs (never past the
++ * entitlement's expiry), a "no" denyTtlMs, a failure unavailableTtlMs. `sensitive` asks VIP in
++ * authoritative mode (VIP asks Billing directly; never cached): exports and attachment downloads. Every
++ * failure is a refusal.
++ */
++const { createVipClient, createVipCache } = require('openvibe-sdk/vip');
++
++const FALLBACK = Object.freeze({ requirement: 'member', binding: 'wiki:gated_page' });
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createVip({ config, tokenClient = null, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
++ const v = config.vip;
++ const tokens = tokenClient ? {
++ authHeaders: () => tokenClient.authHeaders({ audience: 'openvibe.vip' }),
++ invalidate: () => tokenClient.invalidate && tokenClient.invalidate({ audience: 'openvibe.vip' }),
++ } : null;
++ const client = tokens ? createVipClient({ baseUrl: v.internalUrl, tokenClient: tokens, fetch: fetchImpl, timeoutMs: v.timeoutMs, log }) : null;
++ const cache = client ? createVipCache({ vip: client, ttlMs: v.ttlMs, denyTtlMs: v.denyTtlMs, unavailableTtlMs: v.unavailableTtlMs, now }) : null;
++
++ /** May `subject` read `resource` of `space`? → { allow, reason } (never throws). */
++ async function decide({ subject, space, resource, sensitive = false }) {
++ if (!cache) return { allow: false, reason: 'vip_not_configured' };
++ if (!space || !SUBJECT_RE.test(String(space.owner || ''))) return { allow: false, reason: 'no_owner' };
++ if (!subject) return { allow: false, reason: 'not_signed_in' };
++ try {
++ const d = await cache.evaluate({ subject, resource: { service: 'wiki', type: resource.type, id: String(resource.id) }, owner: space.owner, fallback: FALLBACK, ...(sensitive ? { mode: 'authoritative' } : {}) });
++ return { allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
++ } catch (err) {
++ return { allow: false, reason: 'vip_unavailable', error: err.message };
++ }
++ }
++
++ /** Where a reader joins the owner's plans. */
++ const joinUrl = (ownerSubject) => (ownerSubject ? `${v.publicUrl}/${encodeURIComponent(ownerSubject)}` : v.publicUrl);
++
++ return { configured: !!cache, decide, joinUrl, FALLBACK, cache };
++}
++
++module.exports = { createVip, FALLBACK };
ddiff --git a/server/render/views.js b/server/render/views.js
iindex bd9cfe2..f99435f 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -10,7 +10,7 @@ const { infoboxToText } = require('../wiki/content');
const { html, raw } = ssr;
const t = (v) => raw(ssr.timeTag(v));
const e = encodeURIComponent;
--const VIS_LABEL = { public: 'Public', members: 'Members (any signed-in OpenVibe account)', private: 'Private (people with a role in this space)' };
++const VIS_LABEL = { public: 'Public', members: 'Members (any signed-in OpenVibe account)', vip: 'VIP (the space owner\'s OpenVibe.VIP members)', private: 'Private (people with a role in this space)' };
function wpath(space, page) { return `/w/${e(space.slug)}/${e(page.slug)}`; }
@@@ -25,6 +25,12 @@ function errorBody({ status, title, message }) {
return html`<section class="wk-error"><h1>${title}</h1><p>${message}</p><p class="wk-muted">HTTP ${String(status)}</p><p><a href="/">Back to the wiki</a></p></section>`;
}
++/** A VIP space or page the viewer is not (yet) admitted to: the join prompt (WS-K task 8). */
++function vipGateBody({ title, spaceName, joinUrl, signedIn, signInUrl }) {
++ return html`<section class="wk-error"><h1>For VIP members</h1><p>${title ? html`<strong>${title}</strong> is` : 'This is'} for the VIP members of ${spaceName ? html`<strong>${spaceName}</strong>'s owner` : 'its owner'}.</p>
++<p>${signedIn ? '' : html`<a class="wk-btn" href="${signInUrl}">Sign in</a> `}${joinUrl ? html`<a class="wk-btn" href="${joinUrl}" rel="noopener">See their VIP plans</a>` : ''}</p><p><a href="/">Back to the wiki</a></p></section>`;
++}
++
function home({ spaces, recent, actor }) {
const official = spaces.filter((s) => s.kind === 'official');
const user = spaces.filter((s) => s.kind === 'user');
@@@ -220,7 +226,7 @@ ${preview ? html`<section class="wk-preview" aria-label="Preview"><h2>Preview (n
<input type="hidden" name="base_revision" value="${String(baseRevision)}">
<label>Title <input type="text" name="title" required maxlength="200" value="${values.title || ''}"></label>
${isNew ? html`<label>Parent page <select name="parent_id"><option value="">(top level)</option>${parents.map((p) => html`<option value="${p.id}"${values.parent_id === p.id ? raw(' selected') : ''}>${p.title}</option>`)}</select></label>
--<label>Visibility <select name="visibility">${['public', 'members', 'private'].map((v) => html`<option value="${v}"${(values.visibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>` : ''}
++<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${(values.visibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>` : ''}
<label>Summary (one sentence, optional) <input type="text" name="summary" maxlength="300" value="${values.summary || ''}"></label>
<label>Text (Markdown; link pages with [[Page title]] or [[space:Page title|label]]) <textarea name="body" rows="22">${values.body || ''}</textarea></label>
<label>Infobox (one row per line: <code>Label | type | value</code>; types: text, number, date, url, boolean, page, media) <textarea name="infobox" rows="5">${values.infobox || ''}</textarea></label>
@@@ -242,7 +248,7 @@ function newSpacePage({ values = {}, error = null, staff = false }) {
<label>Name <input type="text" name="name" required maxlength="120" value="${values.name || ''}"></label>
<label>Address (optional; lowercase letters, digits and dashes) <input type="text" name="slug" maxlength="63" pattern="[a-z0-9][a-z0-9-]{1,62}" value="${values.slug || ''}"></label>
<label>Description <input type="text" name="description" maxlength="1000" value="${values.description || ''}"></label>
--<label>Visibility <select name="visibility">${['public', 'members', 'private'].map((v) => html`<option value="${v}"${(values.visibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>
++<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${(values.visibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>
${staff ? html`<label class="wk-check"><input type="checkbox" name="official" value="1"> Official (editorial) space</label>` : ''}
<p><button type="submit">Create space</button></p></form></section>`;
}
@@@ -277,7 +283,7 @@ function spaceSettingsPage({ space, roles, error = null, flash = null }) {
<form method="post" action="/s/${e(space.slug)}/settings" class="wk-form">
<label>Name <input type="text" name="name" maxlength="120" value="${space.name}"></label>
<label>Description <input type="text" name="description" maxlength="1000" value="${space.description || ''}"></label>
--<label>Visibility <select name="visibility">${['public', 'members', 'private'].map((v) => html`<option value="${v}"${space.visibility === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>
++<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${space.visibility === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>
<p class="wk-muted">Making a space members-only or private removes its pages from sitemaps, feeds and OpenVibe.Search.</p>
<p><button type="submit" name="op" value="settings">Save settings</button></p></form>
<h2>Roles</h2>
@@@ -323,7 +329,7 @@ ${v.attachments.length ? html`<ul>${v.attachments.map((a) => html`<li><code>${a.
<label>Caption <input type="text" name="caption" maxlength="2000"></label>
<p><button type="submit">Attach</button> <button type="submit" name="op" value="verify" formnovalidate>Check attachments against Media</button></p></form>
${canManage ? html`<h2>Visibility</h2><form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="visibility">
--<label>Visibility <select name="visibility">${['public', 'members', 'private'].map((x) => html`<option value="${x}"${page.visibility === x ? raw(' selected') : ''}>${VIS_LABEL[x]}</option>`)}</select></label>
++<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((x) => html`<option value="${x}"${page.visibility === x ? raw(' selected') : ''}>${VIS_LABEL[x]}</option>`)}</select></label>
<label class="wk-check"><input type="checkbox" name="noindex" value="1"${page.noindex ? raw(' checked') : ''}> Ask search engines not to index this page</label>
<p><button type="submit">Save</button></p></form>
<h2>Delete</h2><form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="delete"><label class="wk-check"><input type="checkbox" name="confirm" value="yes" required> Delete this page (its address answers 410 Gone; history is kept)</label><p><button type="submit">Delete page</button></p></form>` : ''}
@@@ -377,6 +383,7 @@ function signInPage({ next, message }) {
}
module.exports = {
++ vipGateBody,
home, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
spaceSettingsPage, importPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,
signInPage, errorBody, wpath, VIS_LABEL,
ddiff --git a/server/wiki/access.js b/server/wiki/access.js
iindex 177b54a..5663c35 100644
---- a/server/wiki/access.js
++++ b/server/wiki/access.js
@@@ -8,16 +8,30 @@
* { kind: 'service', service: 'svc:x', claims, subject } service token; acts for X-OV-Subject if given
* { kind: 'system', service: 'svc:wiki' } this service's own workers and seed
*
-- * Space visibility: public (anyone), members (any signed-in OpenVibe account), private (only
-- * subjects holding a role in the space). A page's own visibility can only narrow its space's.
++ * Space visibility: public (anyone), members (any signed-in OpenVibe account), vip (the space owner's
++ * OpenVibe.VIP members, plus anyone with a role in the space), private (only subjects holding a role in
++ * the space). A page's own visibility can only narrow its space's.
++ *
++ * VIP (roadmap WS-K task 8): the decision is VIP's (../integrations/vip.js), made per resource: the page
++ * when the page itself is VIP-only, else its space. It is asynchronous, so routes resolve it BEFORE they
++ * render: prepareVip(actor, [{ space, page? }]) asks VIP (cached; `sensitive` asks authoritatively) and
++ * records the answers on the actor (actor.vipAllowed, actor.vipDenied). The synchronous checks below then
++ * read them. Anything not prepared is not readable (fail closed), so VIP content never leaks into a list,
++ * a link preview or a feed that did not ask.
* Roles: owner (everything, incl. roles and settings), editor (write revisions, publish, revert,
* review AI proposals), viewer (read a private space). Network staff (admin, global_mod) act as
* owner of official spaces; they get no silent access to user spaces.
* A service acting for a person gets that person's rights (and must hold the route's capability);
* a service acting as itself reads public content only and writes nothing that needs a person.
*/
--const RANK = { public: 0, members: 1, private: 2 };
--const VISIBILITIES = ['public', 'members', 'private'];
++const RANK = { public: 0, members: 1, vip: 2, private: 3 };
++const VISIBILITIES = ['public', 'members', 'vip', 'private'];
++
++/** The VIP resource a 'vip' read is decided on: the page when it is VIP-only itself, else its space. */
++function vipResource(space, page) {
++ return page && page.visibility === 'vip' ? { type: 'page', id: page.id } : { type: 'space', id: space.id };
++}
++const vipKey = (space, page) => { const r = vipResource(space, page); return `${r.type}:${r.id}`; };
function strictest(a, b) { return RANK[a] >= RANK[b] ? a : b; }
@@@ -25,7 +39,7 @@ function effectiveVisibility(space, page) {
return page ? strictest(space.visibility, page.visibility) : space.visibility;
}
--function createAccess(db) {
++function createAccess(db, { vip = null } = {}) {
const roleOf = db.prepare('SELECT role FROM wiki_permissions WHERE space_id = ? AND subject = ?');
/** 'owner' | 'editor' | 'viewer' | null */
@@@ -42,11 +56,12 @@ function createAccess(db) {
function isPerson(actor) { return !!(actor && actor.subject && /^usr_/.test(actor.subject)); }
-- function canReadVisibility(space, visibility, actor) {
++ function canReadVisibility(space, visibility, actor, page = null) {
if (!actor) return visibility === 'public';
if (actor.kind === 'system') return true;
if (visibility === 'public') return true;
if (visibility === 'members') return !!actor.subject || role(space, actor) != null;
++ if (visibility === 'vip') return role(space, actor) != null || !!(actor.vipAllowed && actor.vipAllowed.has(vipKey(space, page)));
return role(space, actor) != null;
}
@@@ -62,7 +77,7 @@ function createAccess(db) {
/** Published content of a page (drafts and history of unpublished pages need canEdit). */
canReadPage(space, page, actor) {
if (!space || space.deleted_at || !page || page.state === 'deleted') return false;
-- if (!canReadVisibility(space, effectiveVisibility(space, page), actor)) return false;
++ if (!canReadVisibility(space, effectiveVisibility(space, page), actor, page)) return false;
if (page.state === 'published') return true;
return api.canEdit(space, actor);
},
@@@ -75,6 +90,37 @@ function createAccess(db) {
if (!space || space.deleted_at) return false;
return role(space, actor) === 'owner';
},
++ /**
++ * Ask VIP, before rendering, about every VIP space or page in `pairs` ([{ space, page? }]) the actor
++ * cannot already read through a role. Answers land on the actor: vipAllowed (Set of 'space:<id>' /
++ * 'page:<id>') and vipDenied (Map key → reason, for the join prompt). At most 50 questions per call;
++ * the rest stay unreadable. sensitive: VIP asks Billing directly (never cached).
++ */
++ async prepareVip(actor, pairs, { sensitive = false } = {}) {
++ if (!actor || actor.kind === 'system') return;
++ const todo = new Map();
++ for (const { space, page = null } of pairs || []) {
++ if (!space || space.deleted_at || effectiveVisibility(space, page) !== 'vip') continue;
++ if (role(space, actor) != null) continue;
++ const key = vipKey(space, page);
++ if (!sensitive && actor.vipAllowed && actor.vipAllowed.has(key)) continue;
++ if (!todo.has(key) && todo.size < 50) todo.set(key, { space, resource: vipResource(space, page) });
++ }
++ if (!todo.size) return;
++ actor.vipAllowed = actor.vipAllowed || new Set();
++ actor.vipDenied = actor.vipDenied || new Map();
++ await Promise.all([...todo].map(async ([key, { space, resource }]) => {
++ const d = vip ? await vip.decide({ subject: actor.subject || null, space, resource, sensitive }) : { allow: false, reason: 'vip_not_configured' };
++ if (d.allow) { actor.vipAllowed.add(key); actor.vipDenied.delete(key); } else { actor.vipAllowed.delete(key); actor.vipDenied.set(key, d.reason || 'denied'); }
++ }));
++ },
++ /** Why a VIP read was refused (the join prompt), or null when it was not a VIP refusal. */
++ vipRefusal(space, page, actor) {
++ if (!space || effectiveVisibility(space, page) !== 'vip' || !actor || role(space, actor) != null) return null;
++ if (actor.vipAllowed && actor.vipAllowed.has(vipKey(space, page))) return null;
++ return { reason: (actor.vipDenied && actor.vipDenied.get(vipKey(space, page))) || (actor.subject ? 'denied' : 'not_signed_in'),
++ owner: space.owner || null, joinUrl: vip ? vip.joinUrl(space.owner || null) : null };
++ },
/** Would this subject (a watcher) be allowed to read the page? Staff status is not known here. */
subjectCanRead(space, page, subject) {
return api.canReadPage(space, page, { kind: 'user', subject, staff: false });
@@@ -83,4 +129,4 @@ function createAccess(db) {
return api;
}
--module.exports = { createAccess, effectiveVisibility, strictest, VISIBILITIES, RANK };
++module.exports = { createAccess, effectiveVisibility, strictest, vipResource, VISIBILITIES, RANK };
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex eda312d..8f581a5 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -30,7 +30,7 @@ const SPACE_KINDS = ['official', 'user'];
const MAX_BODY = 200000;
const MAX_TITLE = 200;
const MAX_SUMMARY = 300;
--const GATE_VIS = { public: 'public', members: 'gated', private: 'private' };
++const GATE_VIS = { public: 'public', members: 'gated', vip: 'gated', private: 'private' };
const PAGE_ID_RE = /^pg_[0-9A-HJKMNP-TV-Z]{26}$/;
function pageIdNew(t) { return `pg_${ulid(t)}`; }
@@@ -68,9 +68,9 @@ function disclosureFor(rec, review) {
};
}
--function createWikiService({ db, stores, outbox, config, community = null, now = () => Date.now(), log = console }) {
++function createWikiService({ db, stores, outbox, config, community = null, vip = null, now = () => Date.now(), log = console }) {
const { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;
-- const access = createAccess(db);
++ const access = createAccess(db, { vip });
const origin = config.baseUrl;
const policy = config.gate;
@@@ -227,9 +227,11 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
return body.replace(/\r\n?/g, '\n');
}
-- function checkVisibility(v, fallback) {
++ function checkVisibility(v, fallback, space = null) {
const out = v == null || v === '' ? fallback : String(v);
if (!VISIBILITIES.includes(out)) fail(422, 'wiki.invalid_visibility', `visibility is one of ${VISIBILITIES.join(', ')}`);
++ // An official space has no owner in OpenVibe.VIP, so VIP content there would admit no one (WS-K task 8).
++ if (out === 'vip' && space && space.kind === 'official') fail(422, 'wiki.vip_needs_owner', 'An official space has no VIP owner: use members or private');
return out;
}
@@@ -421,7 +423,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
const cleanSummary = checkSummary(summary);
const text = checkBody(body == null ? '' : body);
const box = wrapContentError(() => content.normalizeInfobox(infobox));
-- const vis = checkVisibility(visibility, 'public');
++ const vis = checkVisibility(visibility, 'public', space);
return tx(() => {
const existing = q.pageBySlug.get(space.id, slug);
if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });
@@@ -477,6 +479,10 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
// Spaces ---------------------------------------------------------------------------
listSpaces(actor) { return q.spaces.all().filter((s) => access.canReadSpace(s, actor)); },
++ /** VIP spaces (for preparing a viewer's VIP answers before a list renders; WS-K task 8). */
++ vipSpaces() { return q.spaces.all().filter((s) => s.visibility === 'vip').slice(0, 50); },
++ /** A space's VIP-only pages (its tree), at most 50. */
++ vipPagesOf(spaceId) { return db.prepare("SELECT * FROM wiki_pages WHERE space_id = ? AND visibility = 'vip' AND state <> 'deleted' LIMIT 50").all(spaceId); },
getSpace(idOrSlug, actor) {
const s = spaceOrFail(idOrSlug);
@@@ -497,7 +503,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
try { cleanSlug = slug ? String(slug).trim().toLowerCase() : content.pageSlug(cleanName); } catch (err) { fail(422, 'space.invalid_slug', err.message); }
if (!content.SPACE_SLUG_RE.test(cleanSlug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');
if (!SPACE_KINDS.includes(kind)) fail(422, 'space.invalid_kind', 'kind is official or user');
-- const vis = checkVisibility(visibility, 'public');
++ const vis = checkVisibility(visibility, 'public', { kind });
// Official (editorial) spaces: Network staff, this service, or a first-party service (svc:…)
// acting as itself. A developer app or module (app:…, mod:…) never does.
const firstPartyService = actor && actor.kind === 'service' && !actor.subject && /^svc:/.test(String(actor.service));
@@@ -530,7 +536,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
const next = {
name: name == null ? space.name : String(name).replace(/\s+/g, ' ').trim().slice(0, 120) || space.name,
description: description === undefined ? space.description : (description == null ? null : String(description).slice(0, 1000)),
-- visibility: checkVisibility(visibility, space.visibility),
++ visibility: checkVisibility(visibility, space.visibility, space),
slug: slug == null || slug === '' ? space.slug : String(slug).trim().toLowerCase(),
};
if (!content.SPACE_SLUG_RE.test(next.slug)) fail(422, 'space.invalid_slug', 'A space slug is 2–63 lowercase letters, digits and dashes');
@@@ -849,7 +855,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
return tx(() => {
const b = before(page);
db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')
-- .run(checkVisibility(visibility, page.visibility), noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);
++ .run(checkVisibility(visibility, page.visibility, space), noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);
return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
});
},
ddiff --git a/test/vip-gate.test.js b/test/vip-gate.test.js
nnew file mode 100644
iindex 0000000..4f254ec
---- /dev/null
++++ b/test/vip-gate.test.js
@@@ -0,0 +1,138 @@
++'use strict';
++/**
++ * VIP spaces and pages (roadmap WS-K task 8): OpenVibe.VIP decides who may read, before render (cached; the
++ * JSON export asks authoritatively); every doubt refuses with a join prompt; roles in the space always
++ * read; VIP content never reaches sitemaps, feeds, Search or another viewer's list; official spaces refuse vip.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++const OWNER = H.subject();
++const MEMBER = H.subject();
++const STRANGER = H.subject();
++const VIP_URL = 'http://127.0.0.1:4620/api/v1/policies/evaluate';
++
++(async () => {
++ // Migration: a database made before `vip` (the old CHECK) is widened in place; rows stay; idempotent.
++ {
++ const fs = require('fs');
++ const os = require('os');
++ const path = require('path');
++ const { openDb, createStores, widenVisibilityChecks, SCHEMA } = require('../server/db');
++ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-vipmig-')), 'wiki.db');
++ const old = openDb(file);
++ old.exec(SCHEMA.split("CHECK (visibility IN ('public','members','vip','private'))").join("CHECK (visibility IN ('public','members','private'))"));
++ old.prepare("INSERT INTO wiki_spaces (id, slug, name, kind, visibility, owner, created_by, created_at, updated_at) VALUES ('spc_1', 'old', 'Old', 'user', 'members', ?, ?, 1, 1)").run(OWNER, OWNER);
++ assert.throws(() => old.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run(), /CHECK constraint/);
++ old.close();
++ const db = openDb(file);
++ createStores(db);
++ db.prepare("UPDATE wiki_spaces SET visibility = 'vip' WHERE id = 'spc_1'").run();
++ assert.strictEqual(db.prepare("SELECT visibility FROM wiki_spaces WHERE id = 'spc_1'").get().visibility, 'vip', 'widened; the row kept');
++ assert.throws(() => db.prepare("UPDATE wiki_spaces SET visibility = 'secret' WHERE id = 'spc_1'").run(), /CHECK constraint/, 'still checked');
++ assert.deepStrictEqual(widenVisibilityChecks(db), [], 'idempotent');
++ assert.strictEqual(db.pragma('integrity_check', { simple: true }), 'ok');
++ db.close();
++ fs.rmSync(path.dirname(file), { recursive: true, force: true });
++ }
++
++ const asks = [];
++ let vipDown = false;
++ const fetch = async (url, opts = {}) => {
++ if (url !== VIP_URL) throw new Error(`unexpected outbound fetch ${url}`);
++ const b = JSON.parse(opts.body);
++ asks.push(b);
++ if (vipDown) throw new Error('ECONNREFUSED');
++ const allow = b.subject === MEMBER && b.owner === OWNER && b.resource.service === 'wiki' && b.fallback && b.fallback.binding === 'wiki:gated_page';
++ return new Response(JSON.stringify({ allow, reason: allow ? 'member' : 'not_member', rule: null, fallback: true }), { status: 200, headers: { 'content-type': 'application/json' } });
++ };
++ const h = await H.boot({ fetch, env: { OV_OAUTH_CLIENT_SECRET: 'x'.repeat(40) } });
++ const owner = { kind: 'user', subject: OWNER, staff: false };
++ const cookie = (sub) => H.cookieFor(H.userToken({ subject: sub }));
++ try {
++ const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
++ const club = h.svc.createSpace({ name: 'Members club', slug: 'club', visibility: 'vip' }, owner);
++ const secret = h.svc.createPage(club.id, { title: 'Club secret', body: H.LONG, citations: cite }, owner).page;
++ h.svc.publish(secret.id, {}, owner);
++ const open = h.svc.createSpace({ name: 'Open notes', slug: 'open' }, owner);
++ const perk = h.svc.createPage(open.id, { title: 'Perk page', body: H.LONG, citations: cite, visibility: 'vip' }, owner).page;
++ h.svc.publish(perk.id, {}, owner);
++ const pub = h.svc.createPage(open.id, { title: 'Public page', body: H.LONG, citations: cite }, owner).page;
++ h.svc.publish(pub.id, {}, owner);
++
++ // Anonymous: the join prompt (403, never publicly cached), no body.
++ let r = await H.req(h, 'GET', '/w/club/club-secret');
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /For VIP members/);
++ assert.match(r.text, /Sign in/);
++ assert.ok(!r.text.includes('thin content rule'), 'no body for a non-member');
++ assert.match(r.headers.get('cache-control'), /private/);
++ assert.strictEqual(asks.length, 0, 'nobody signed in: VIP is not asked');
++
++ // A member: VIP says yes (space resource, the owner, the wiki fallback).
++ r = await H.req(h, 'GET', '/w/club/club-secret', { cookie: cookie(MEMBER) });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.ok(r.text.includes('thin content rule'));
++ assert.deepStrictEqual([asks[0].resource, asks[0].owner, asks[0].subject, asks[0].mode], [{ service: 'wiki', type: 'space', id: club.id }, OWNER, MEMBER, undefined]);
++ const n = asks.length;
++ await H.req(h, 'GET', '/w/club/club-secret', { cookie: cookie(MEMBER) });
++ assert.strictEqual(asks.length, n, 'cached');
++
++ // A signed-in stranger: the join prompt with the VIP link.
++ r = await H.req(h, 'GET', '/w/club/club-secret', { cookie: cookie(STRANGER) });
++ assert.strictEqual(r.status, 403);
++ assert.ok(r.text.includes(`https://openvibe.vip/${OWNER}`), 'links to the owner\'s plans');
++ assert.strictEqual((await H.req(h, 'GET', '/s/club', { cookie: cookie(STRANGER) })).status, 403);
++ assert.strictEqual((await H.req(h, 'GET', '/s/club', { cookie: cookie(MEMBER) })).status, 200);
++
++ // A VIP-only page in a public space is decided on the page.
++ r = await H.req(h, 'GET', '/w/open/perk-page', { cookie: cookie(MEMBER) });
++ assert.strictEqual(r.status, 200);
++ assert.ok(asks.some((a) => a.resource.type === 'page' && a.resource.id === perk.id));
++ assert.strictEqual((await H.req(h, 'GET', '/w/open/perk-page')).status, 403);
++ r = await H.req(h, 'GET', '/s/open');
++ assert.ok(r.text.includes('Public page') && !r.text.includes('Perk page'), 'the tree hides a VIP page from a non-member');
++ r = await H.req(h, 'GET', '/s/open', { cookie: cookie(MEMBER) });
++ assert.ok(r.text.includes('Perk page'), 'and shows it to a member');
++
++ // The JSON export is sensitive: VIP asks authoritatively, never cached.
++ const before = asks.length;
++ r = await H.req(h, 'GET', '/w/club/club-secret.json', { cookie: cookie(MEMBER) });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(asks.length, before + 1);
++ assert.strictEqual(asks[asks.length - 1].mode, 'authoritative');
++
++ // The owner reads without asking VIP; the API honours the same gate.
++ const ownerAsks = asks.length;
++ assert.strictEqual((await H.req(h, 'GET', '/w/club/club-secret', { cookie: cookie(OWNER) })).status, 200);
++ assert.strictEqual(asks.length, ownerAsks, 'a role in the space never asks VIP');
++ assert.strictEqual((await H.req(h, 'GET', `/api/v1/pages/${secret.id}`, { token: H.userToken({ subject: MEMBER }) })).status, 200);
++ assert.strictEqual((await H.req(h, 'GET', `/api/v1/pages/${secret.id}`, { token: H.userToken({ subject: STRANGER }) })).status, 404);
++
++ // Lists: the member sees the VIP space on the home page; others do not.
++ assert.ok((await H.req(h, 'GET', '/', { cookie: cookie(MEMBER) })).text.includes('/s/club'));
++ assert.ok(!(await H.req(h, 'GET', '/', { cookie: cookie(STRANGER) })).text.includes('/s/club'));
++ assert.ok(!(await H.req(h, 'GET', '/')).text.includes('/s/club'));
++
++ // Never in sitemaps, feeds or Search; its events say vip.
++ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('club-secret'));
++ assert.ok(!(await H.req(h, 'GET', '/feed.atom')).text.includes('club-secret'));
++ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes('perk-page'));
++ const evs = H.outbox(h);
++ assert.ok(!evs.some((e) => e.event_type === 'wiki.index_document.upserted' && (e.subject.id === secret.id || e.subject.id === perk.id)), 'no Search document');
++ assert.ok(evs.some((e) => e.event_type === 'wiki.page.published' && e.subject.id === secret.id && e.payload.visibility === 'vip'));
++
++ // VIP down: fail closed.
++ vipDown = true;
++ const outage = H.subject();
++ r = await H.req(h, 'GET', '/w/club/club-secret', { cookie: cookie(outage) });
++ assert.strictEqual(r.status, 403);
++ vipDown = false;
++
++ // Official spaces have no VIP owner.
++ assert.throws(() => h.svc.createSpace({ name: 'Staff club', slug: 'staff-club', kind: 'official', visibility: 'vip' }, { kind: 'system', service: 'svc:wiki' }), /no VIP owner/);
++ } finally {
++ await h.stop();
++ }
++ console.log('vip gate: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });