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
Changes
@@@ -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 aprivate 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 (chainscollapse; 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@@@ -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",@@@ -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"@@@ -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}`,@@@ -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 };@@@ -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);@@@ -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 seeconst 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' }));@@@ -21,7 +21,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.feconst 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 });@@@ -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),};}nnew file mode 100644@@@ -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 };@@@ -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,@@@ -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 };@@@ -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) };});},nnew file mode 100644@@@ -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); });
@@ -62,9 +62,24 @@ records who attached each Media object and what Media said about it at that mome
@@ -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
- **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`
(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
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.
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
- **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.
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
- **Revisions**: immutable, optimistic concurrency (`expected_revision` → 412 on conflict), word and
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"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-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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
@@ -999,9 +999,9 @@
@@ -999,9 +999,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"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-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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
@@ -57,6 +57,16 @@ function load(env = process.env) {
@@ -57,6 +57,16 @@ function load(env = process.env) {
mediaApp: env.WIKI_MEDIA_APP || 'wiki',
mediaApp: env.WIKI_MEDIA_APP || 'wiki',
mediaVerifyIntervalMs: int(env.WIKI_MEDIA_VERIFY_INTERVAL_MS, 6 * 60 * 60 * 1000),
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.
// Scheduled publication worker.
scheduleIntervalMs: int(env.WIKI_SCHEDULE_INTERVAL_MS, 15000),
scheduleIntervalMs: int(env.WIKI_SCHEDULE_INTERVAL_MS, 15000),
workerId: env.WIKI_WORKER_ID || `wiki-${process.pid}`,
workerId: env.WIKI_WORKER_ID || `wiki-${process.pid}`,
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS wiki_spaces (
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS wiki_spaces (
name TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
description TEXT,
kind TEXT NOT NULL CHECK (kind IN ('official','user')),
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,
owner TEXT NOT NULL,
created_by TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
@@ -54,7 +54,7 @@ CREATE TABLE IF NOT EXISTS wiki_pages (
@@ -54,7 +54,7 @@ CREATE TABLE IF NOT EXISTS wiki_pages (
parent_id TEXT REFERENCES wiki_pages(id),
parent_id TEXT REFERENCES wiki_pages(id),
position INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','scheduled','published','unpublished','deleted')),
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,
noindex INTEGER NOT NULL DEFAULT 0,
published_revision INTEGER,
published_revision INTEGER,
published_at INTEGER,
published_at INTEGER,
@@ -173,9 +173,41 @@ function openDb(dbPath) {
@@ -173,9 +173,41 @@ function openDb(dbPath) {
* Creates every table (package tables first: the infobox purge trigger references
* Creates every table (package tables first: the infobox purge trigger references
* wiki_page_revision_purges) and returns the package stores bound to this database.
* 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 } = {}) {
function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } = {}) {
const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
db.exec(SCHEMA);
db.exec(SCHEMA);
+ widenVisibilityChecks(db);
return {
return {
revisions,
revisions,
citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),
citations: createCitationStore(db, { prefix: 'wiki', now, revisions }),
@@ -188,4 +220,4 @@ function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } =
@@ -188,4 +220,4 @@ function createStores(db, { now = () => Date.now(), scheduleLeaseMs = 60000 } =
};
};
}
}
-module.exports = { openDb, createStores, SCHEMA };
+module.exports = { openDb, createStores, widenVisibilityChecks, SCHEMA };
@@ -99,6 +99,17 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -99,6 +99,17 @@ function createApi({ svc, viewers, platform, config, log = console }) {
});
});
router.use(actorMiddleware(viewers));
router.use(actorMiddleware(viewers));
const R = (fn, status) => run(fn, status, log);
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 pageAndSpace = (id) => {
const page = svc.pageById(id);
const page = svc.pageById(id);
@@ -42,6 +42,16 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -42,6 +42,16 @@ function createPages({ svc, viewers, platform, config, log = console }) {
const form = express.urlencoded({ extended: false, limit: '600kb' });
const form = express.urlencoded({ extended: false, limit: '600kb' });
router.use(actorMiddleware(viewers, { services: false }));
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;
const origin = new URL(config.baseUrl).origin;
// Cross-site form posts are refused (the session cookie is SameSite=Lax as well).
// Cross-site form posts are refused (the session cookie is SameSite=Lax as well).
router.use((req, res, next) => {
router.use((req, res, next) => {
@@ -59,6 +69,13 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -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 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 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.');
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 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) => {
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);
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 }) {
@@ -77,7 +94,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
notFound(req, res);
notFound(req, res);
return null;
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 };
return { space, page };
}
}
@@ -90,19 +107,20 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -90,19 +107,20 @@ function createPages({ svc, viewers, platform, config, log = console }) {
notFound(req, res);
notFound(req, res);
return null;
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;
return space;
}
}
// ── Home, recent, search ─────────────────────────────────
// ── 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 spaces = svc.listSpaces(req.actor);
const recent = svc.recentChanges(10);
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' }), {
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: '/',
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.' }),
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.
// 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('/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' }));
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' }));
@@ -21,7 +21,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
@@ -21,7 +21,7 @@ async function start({ config, now = () => Date.now(), fetchImpl = globalThis.fe
const db = openDb(config.dbPath);
const db = openDb(config.dbPath);
const stores = createStores(db, { now });
const stores = createStores(db, { now });
const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });
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();
const reconciled = svc.reconcileIndex();
if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);
if (reconciled.sent) log.log(`[Wiki] re-sent ${reconciled.sent} Search document(s) whose indexability changed`);
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
@@ -171,8 +171,11 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
@@ -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 {
return {
- tokenClient, sdk, events, outbox, community, sources, media,
+ tokenClient, sdk, events, outbox, community, sources, media, vip,
eventsConfigured: !!(tokenClient && config.eventsUrl),
eventsConfigured: !!(tokenClient && config.eventsUrl),
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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 };
@@ -10,7 +10,7 @@ const { infoboxToText } = require('../wiki/content');
@@ -10,7 +10,7 @@ const { infoboxToText } = require('../wiki/content');
const { html, raw } = ssr;
const { html, raw } = ssr;
const t = (v) => raw(ssr.timeTag(v));
const t = (v) => raw(ssr.timeTag(v));
const e = encodeURIComponent;
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)}`; }
function wpath(space, page) { return `/w/${e(space.slug)}/${e(page.slug)}`; }
@@ -25,6 +25,12 @@ function errorBody({ status, title, message }) {
@@ -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>`;
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 }) {
function home({ spaces, recent, actor }) {
const official = spaces.filter((s) => s.kind === 'official');
const official = spaces.filter((s) => s.kind === 'official');
const user = spaces.filter((s) => s.kind === 'user');
const user = spaces.filter((s) => s.kind === 'user');
@@ -220,7 +226,7 @@ ${preview ? html`<section class="wk-preview" aria-label="Preview"><h2>Preview (n
@@ -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)}">
<input type="hidden" name="base_revision" value="${String(baseRevision)}">
<label>Title <input type="text" name="title" required maxlength="200" value="${values.title || ''}"></label>
<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>
${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>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>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>
<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 }) {
@@ -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>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>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>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>` : ''}
${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>`;
<p><button type="submit">Create space</button></p></form></section>`;
}
}
@@ -277,7 +283,7 @@ function spaceSettingsPage({ space, roles, error = null, flash = null }) {
@@ -277,7 +283,7 @@ function spaceSettingsPage({ space, roles, error = null, flash = null }) {
<form method="post" action="/s/${e(space.slug)}/settings" class="wk-form">
<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>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>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 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>
<p><button type="submit" name="op" value="settings">Save settings</button></p></form>
<h2>Roles</h2>
<h2>Roles</h2>
@@ -323,7 +329,7 @@ ${v.attachments.length ? html`<ul>${v.attachments.map((a) => html`<li><code>${a.
@@ -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>
<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>
<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">
${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>
<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>
<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>` : ''}
<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 }) {
@@ -377,6 +383,7 @@ function signInPage({ next, message }) {
}
}
module.exports = {
module.exports = {
+ vipGateBody,
home, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
home, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
spaceSettingsPage, importPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,
spaceSettingsPage, importPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,
signInPage, errorBody, wpath, VIS_LABEL,
signInPage, errorBody, wpath, VIS_LABEL,
@@ -8,16 +8,30 @@
@@ -8,16 +8,30 @@
* { kind: 'service', service: 'svc:x', claims, subject } service token; acts for X-OV-Subject if given
* { 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
* { 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,
* 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
* 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.
* 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 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.
* 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; }
function strictest(a, b) { return RANK[a] >= RANK[b] ? a : b; }
@@ -25,7 +39,7 @@ function effectiveVisibility(space, page) {
@@ -25,7 +39,7 @@ function effectiveVisibility(space, page) {
return page ? strictest(space.visibility, page.visibility) : space.visibility;
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 = ?');
const roleOf = db.prepare('SELECT role FROM wiki_permissions WHERE space_id = ? AND subject = ?');
/** 'owner' | 'editor' | 'viewer' | null */
/** 'owner' | 'editor' | 'viewer' | null */
@@ -42,11 +56,12 @@ function createAccess(db) {
@@ -42,11 +56,12 @@ function createAccess(db) {
function isPerson(actor) { return !!(actor && actor.subject && /^usr_/.test(actor.subject)); }
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) return visibility === 'public';
if (actor.kind === 'system') return true;
if (actor.kind === 'system') return true;
if (visibility === 'public') return true;
if (visibility === 'public') return true;
if (visibility === 'members') return !!actor.subject || role(space, actor) != null;
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;
return role(space, actor) != null;
}
}
@@ -62,7 +77,7 @@ function createAccess(db) {
@@ -62,7 +77,7 @@ function createAccess(db) {
/** Published content of a page (drafts and history of unpublished pages need canEdit). */
/** Published content of a page (drafts and history of unpublished pages need canEdit). */
canReadPage(space, page, actor) {
canReadPage(space, page, actor) {
if (!space || space.deleted_at || !page || page.state === 'deleted') return false;
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;
if (page.state === 'published') return true;
return api.canEdit(space, actor);
return api.canEdit(space, actor);
},
},
@@ -75,6 +90,37 @@ function createAccess(db) {
@@ -75,6 +90,37 @@ function createAccess(db) {
if (!space || space.deleted_at) return false;
if (!space || space.deleted_at) return false;
return role(space, actor) === 'owner';
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. */
/** Would this subject (a watcher) be allowed to read the page? Staff status is not known here. */
subjectCanRead(space, page, subject) {
subjectCanRead(space, page, subject) {
return api.canReadPage(space, page, { kind: 'user', subject, staff: false });
return api.canReadPage(space, page, { kind: 'user', subject, staff: false });
@@ -83,4 +129,4 @@ function createAccess(db) {
@@ -83,4 +129,4 @@ function createAccess(db) {
return api;
return api;
}
}
-module.exports = { createAccess, effectiveVisibility, strictest, VISIBILITIES, RANK };
+module.exports = { createAccess, effectiveVisibility, strictest, vipResource, VISIBILITIES, RANK };
@@ -30,7 +30,7 @@ const SPACE_KINDS = ['official', 'user'];
@@ -30,7 +30,7 @@ const SPACE_KINDS = ['official', 'user'];
const MAX_BODY = 200000;
const MAX_BODY = 200000;
const MAX_TITLE = 200;
const MAX_TITLE = 200;
const MAX_SUMMARY = 300;
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}$/;
const PAGE_ID_RE = /^pg_[0-9A-HJKMNP-TV-Z]{26}$/;
function pageIdNew(t) { return `pg_${ulid(t)}`; }
function pageIdNew(t) { return `pg_${ulid(t)}`; }
@@ -68,9 +68,9 @@ function disclosureFor(rec, review) {
@@ -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 { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;
- const access = createAccess(db);
+ const access = createAccess(db, { vip });
const origin = config.baseUrl;
const origin = config.baseUrl;
const policy = config.gate;
const policy = config.gate;
@@ -227,9 +227,11 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -227,9 +227,11 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
return body.replace(/\r\n?/g, '\n');
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);
const out = v == null || v === '' ? fallback : String(v);
if (!VISIBILITIES.includes(out)) fail(422, 'wiki.invalid_visibility', `visibility is one of ${VISIBILITIES.join(', ')}`);
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;
return out;
}
}
@@ -421,7 +423,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -421,7 +423,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
const cleanSummary = checkSummary(summary);
const cleanSummary = checkSummary(summary);
const text = checkBody(body == null ? '' : body);
const text = checkBody(body == null ? '' : body);
const box = wrapContentError(() => content.normalizeInfobox(infobox));
const box = wrapContentError(() => content.normalizeInfobox(infobox));
- const vis = checkVisibility(visibility, 'public');
+ const vis = checkVisibility(visibility, 'public', space);
return tx(() => {
return tx(() => {
const existing = q.pageBySlug.get(space.id, slug);
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 });
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 =
@@ -477,6 +479,10 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
// Spaces ---------------------------------------------------------------------------
// Spaces ---------------------------------------------------------------------------
listSpaces(actor) { return q.spaces.all().filter((s) => access.canReadSpace(s, actor)); },
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) {
getSpace(idOrSlug, actor) {
const s = spaceOrFail(idOrSlug);
const s = spaceOrFail(idOrSlug);
@@ -497,7 +503,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -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); }
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 (!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');
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:…)
// 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.
// 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));
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 =
@@ -530,7 +536,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
const next = {
const next = {
name: name == null ? space.name : String(name).replace(/\s+/g, ' ').trim().slice(0, 120) || space.name,
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)),
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(),
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');
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 =
@@ -849,7 +855,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
return tx(() => {
return tx(() => {
const b = before(page);
const b = before(page);
db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')
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) };
return { page: q.pageById.get(page.id), ...sync(b, page.id, actor) };
});
});
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -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); });