openvibers/openvibe.wiki Commits
Wiki: the four audiences on pages and drafts (sharing step 4) (#223)
Changes
@@@ -316,7 +316,7 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).- `openvibe-publishing` v1.3.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`-- v0.107.0, `openvibe-shared` v4.122.0++ v0.107.0, `openvibe-shared` v4.123.0(Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.91.0 (db, auth, PostgreSQLevents outbox, per-actor limits, account export and deletion, testing) — pinned release tarballs.- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,@@@ -446,6 +446,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.289.1- openvibe-sdk: v0.91.0--- openvibe-shared: v4.122.0++- openvibe-shared: v4.123.0- openvibe-publishing: v1.3.0<!-- versions:end -->@@@ -9,10 +9,10 @@"domain": "openvibe.wiki","contracts": "openvibe-contracts v0.289.1","sdk": "openvibe-sdk v0.91.0",-- "shared": "openvibe-shared v4.122.0",++ "shared": "openvibe-shared v4.123.0","packages": ["openvibe-publishing v1.3.0",-- "openvibe-shared v4.122.0",++ "openvibe-shared v4.123.0","openvibe-sdk v0.91.0"],"capabilities": [nnew file mode 100644@@@ -0,0 +1,72 @@++-- phase: expand++-- Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 4): the tables openvibe-sdk/share v0.91.0's++-- sharesSchema() creates, kept verbatim so Blog's database has the same shape as every other++-- service that adopts it. Expand only: nothing is dropped or rewritten.++++++CREATE TABLE IF NOT EXISTS shares (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),++ revision integer NOT NULL DEFAULT 1,++ lock text NOT NULL DEFAULT 'none',++ reseal_nonce_base text,++ PRIMARY KEY (service, kind, resource_id)++);++CREATE TABLE IF NOT EXISTS share_members (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,++ key_slot jsonb,++ PRIMARY KEY (service, kind, resource_id, subject),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE TABLE IF NOT EXISTS share_transfers (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),++ key_slot jsonb,++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_access_requests (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_log (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;++DO $$ BEGIN++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();++ END IF;++END $$;++CREATE TABLE IF NOT EXISTS share_deliveries (++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE TABLE IF NOT EXISTS share_name_limits (++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL++);++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the++-- slot a transfer was offered with. Repeating this migration is a no-op.++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';++DO $$ BEGIN++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;++nnew file mode 100644@@@ -0,0 +1,23 @@++-- phase: migrate++-- Every page that existed before sharing is registered once, so canView has a row for all of them.++--++-- a published page of a public space, public itself → public (Everyone)++-- everything else (drafts, members, vip, private) → private (Only me; members/vip keep the gate)++--++-- The owner is the person who created the page; a page made by the service or an import with no++-- person gets Wiki itself, which nobody can sign in as. Deleted pages are not registered.++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)++SELECT++ 'wiki', 'page', p.id,++ CASE++ WHEN p.created_by LIKE 'usr\_%' THEN jsonb_build_object('type', 'user', 'id', p.created_by)++ WHEN p.created_by LIKE 'gst\_%' THEN jsonb_build_object('type', 'guest', 'id', p.created_by)++ ELSE jsonb_build_object('type', 'service', 'id', 'wiki')++ END,++ CASE++ WHEN p.state = 'published' AND p.visibility = 'public' AND s.visibility = 'public' THEN 'public'++ ELSE 'private'++ END++FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id++WHERE p.state <> 'deleted'++ON CONFLICT (service, kind, resource_id) DO NOTHING;@@@ -17,7 +17,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0","pg": "^8.23.0"},"devDependencies": {@@@ -1196,9 +1196,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.122.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",-- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",++ "version": "4.123.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",++ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -28,7 +28,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0","pg": "^8.23.0"},"engines": {@@@ -19,11 +19,13 @@ const { createApi } = require('./http/api');const { createPages } = require('./http/pages');const { createMachine } = require('./http/machine');const { createActorLimits } = require('./http/actor-limits');++const { actorMiddleware } = require('./http/common');++const { describeRef } = require('./sharing');const PUBLIC_DIR = path.join(__dirname, '..', 'public');const VERSION = require('../package.json').version;--function createApp({ config, telemetry = null, svc, viewers: baseViewers, platform, keys, db, valkey = null, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null, indexnow = null, accountData = null, accountSend = null, vibes = null }) {++function createApp({ config, telemetry = null, svc, viewers: baseViewers, platform, keys, db, valkey = null, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null, indexnow = null, accountData = null, accountSend = null, vibes = null, sharing = null }) {const app = express();// Every resolved actor carries its wiki roles (read once per request), so the synchronous access checks work.const viewers = { ...baseViewers, resolve: async (req, opts) => svc.loadRoles(await baseViewers.resolve(req, opts)) };@@@ -113,6 +115,21 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo// Per-actor limits (http/actor-limits.js) on /api/v1 and the editing forms, counted once each router// resolved req.actor; the per-address limits here stay. limitsNow: the limiter's clock (tests).const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits, valkey });++ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────++ // GET/PUT /api/v1/page/:id/share, .../share/requests, .../share/transfer[/accept|decline|cancel],++ // GET /api/v1/share/people?name=, GET /api/v1/shared-with-me. Authentication is Wiki's own actor;++ // the router answers only its own paths.++ if (sharing) {++ const shareApi = express.Router();++ shareApi.use(actorMiddleware(viewers, { services: false }));++ shareApi.use(sharing.shares.router({++ service: 'wiki',++ authenticate: (req) => sharing.shareViewer(req.actor),++ describe: describeRef(db, config),++ }));++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));++ }app.use('/api/v1', createApi({ svc, viewers, platform, config, log, limits, vibes }));app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));@@@ -135,7 +152,7 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfoapp.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));// Search scans the text of every published page (LIKE): a person's pace, not a crawler's.app.get('/search', limiter(60000, 60));-- const pages = createPages({ svc, viewers, platform, config, log, limits, vibes });++ const pages = createPages({ svc, viewers, platform, config, log, limits, vibes, sharing });// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the// public pages (the home, a wiki's page tree and its articles). Only the manifest and a twin request// reach it — the layer renders every page signed out, and a wiki page is not one page (a signed-in@@@ -46,7 +46,7 @@ const { createFrameGroups } = require('./frame-groups');// What the home says in search results and share cards (≤ 160 characters).const HOME_DESCRIPTION = 'Read the OpenVibe wiki or start your own: every page keeps its full revision history, lists the sources behind it and has a discussion thread.';--function createPages({ svc, viewers, platform, config, log = console, limits, vibes = null, frameGroups = createFrameGroups({ url: config.frameUrl }) }) {++function createPages({ svc, viewers, platform, config, log = console, limits, vibes = null, sharing = null, frameGroups = createFrameGroups({ url: config.frameUrl }) }) {const router = express.Router();const form = express.urlencoded({ extended: false, limit: '600kb' });router.use(actorMiddleware(viewers, { services: false }));@@@ -114,7 +114,36 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vinotFound(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; }++ if (!svc.access.canReadPage(space, page, req.actor)) {++ // A share row can open this one page for a signed-in person: a member of its people list, or the++ // link when the space itself is public. A public row never widens a private space.++ const row = sharing ? await sharing.shareOf(page.id).catch(() => null) : null;++ // An Everyone draft opens by its address in a public space; a link or people draft needs a signed-in++ // person; a public row never widens a private space.++ const publicDraft = Boolean(row && (row.audience === 'public' || row.audience === 'link') && page.state !== 'published' && svc.access.canReadSpace(space, req.actor));++ const narrowed = Boolean(row && req.actor && req.actor.subject && (row.audience === 'people'++ || (row.audience === 'link' && svc.access.canReadSpace(space, req.actor))));++ const allowed = Boolean((publicDraft || narrowed) && await sharing.canView(req.actor, page.id).catch(() => false));++ if (allowed) {++ try {++ if (!req.actor.wikiShares) Object.defineProperty(req.actor, 'wikiShares', { value: new Set(), writable: true, configurable: true });++ req.actor.wikiShares.add(page.id);++ } catch {++ // The anonymous actor is frozen: a copy carries the grant for the rest of this request.++ req.actor = { ...req.actor, wikiShares: new Set([page.id]) };++ }++ return { space, page };++ }++ // A page shared with specific people answers with the gate, never its title, text or history.++ const s = sharing ? await sharing.shareOf(page.id).catch(() => null) : null;++ if (s && s.audience === 'people' && !vipGate(req, res, space, page)) {++ send(req, res, 404, views.sharedGateBody({ id: page.id, signedInAs: req.actor && req.actor.user && req.actor.user.username ? req.actor.user.username : null,++ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }), { title: 'A page on OpenVibe.Wiki', robots: 'noindex, nofollow' });++ return null;++ }++ if (page.state !== 'published' || !vipGate(req, res, space, page)) notFound(req, res);++ return null;++ }return { space, page };}@@@ -558,7 +587,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, viasync function renderPageSettings(req, res, space, page, status, extra = {}) {const [view, list, parents] = await Promise.all([svc.view(space, page, req.actor, {}), svc.history(page, { limit: 1 }), parentsOf(space, req.actor)]);const headNumber = list[0].number;-- send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents, canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });++ send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents, canManage: svc.access.canManage(space, req.actor), shareUrl: svc.pageUrl(space, page), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });}router.post('/w/:space/:slug/settings', B('wiki.page.update'), form, wrap(async (req, res) => {@@@ -15,6 +15,7 @@ const { load } = require('./config');const { openDb, migrate, createStores } = require('./db');const { createPlatform } = require('./integrations/platform');const { createWikiService } = require('./wiki/service');++const { createSharing, describeRef } = require('./sharing');const { createVibes } = require('./wiki/vibe');const { createKeyStore } = require('./auth/keys');const { createViewerResolver } = require('./auth/viewer');@@@ -70,7 +71,9 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch// Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.const valkey = config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null;const platform = createPlatform({ config, db, fetchImpl, tokens, now, log });-- const svc = createWikiService({ db, stores, outbox: platform.outbox, config, community: platform.community, vip: platform.vip, now, log, indexnow });++ // Sharing (openvibe-sdk/share): the shares row next to each page, members, transfers and the one access check.++ const sharing = createSharing({ db, config, outbox: platform.outbox, now, fetchImpl, log });++ const svc = createWikiService({ db, stores, outbox: platform.outbox, config, community: platform.community, vip: platform.vip, now, log, indexnow, sharing });// "Vibe a wiki" (server/wiki/vibe.js): generated wikis, researched and written through OpenVibe.AI.const vibes = createVibes({ db, svc, platform, config, now, log, ...(imageFetch ? { fetchImg: imageFetch } : {}) });const reconciled = await svc.reconcileIndex();@@@ -89,7 +92,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch// on, a one-minute tick writes each interval (the collector has no timer of its own) and the table keeps 14 days.const telemetry = createHttpTelemetry({ service: 'wiki', sink: telemetrySink || createPgTelemetrySink({ db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });let retention = null;-- const app = createApp({ config, telemetry, svc, viewers, platform, keys, db, valkey, log, rateLimits, fetchImpl, limitsNow, indexnow, accountData, accountSend, vibes });++ const app = createApp({ config, telemetry, svc, viewers, platform, keys, db, valkey, log, rateLimits, fetchImpl, limitsNow, indexnow, accountData, accountSend, vibes, sharing });const timers = [];if (workers) {@@@ -138,7 +141,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetchasync function stop() {return lifecycle.stop('stop');}-- return { app, db, telemetry, svc, stores, platform, keys, viewers, server, config, indexnow, timers, lifecycle, stop, vibes };++ return { app, db, telemetry, svc, stores, platform, keys, viewers, server, config, indexnow, timers, lifecycle, stop, vibes, sharing };}module.exports = { start, createLifecycle };@@@ -5,6 +5,7 @@*/const ssr = require('openvibe-publishing/ssr');const kit = require('openvibe-shared/kit');++const ovServe = require('openvibe-shared/serve');const showcase = require('openvibe-shared/showcase');const { figureHtml } = require('openvibe-publishing/media');const { infoboxToText, mediaRefs } = require('../wiki/content');@@@ -426,7 +427,7 @@ function articlePage(v, { html: contentHtml, discussion, actor, mediaUrl, resolvif (v.canEdit && v.decision && !v.decision.indexable && page.state === 'published') banners.push(html`<p class="wk-notice wk-info">Search engines are told not to index this page: ${v.decision.reasons.map((r) => r.code + (r.detail ? ` (${r.detail})` : '')).join(', ')}.</p>`);const actions = [html`<a href="${base}/history">History</a>`];if (v.canEdit) actions.unshift(html`<a href="${base}/edit">Edit</a>`), actions.push(html`<a href="${base}/settings">Page settings</a>`);-- if (shareUrl) actions.unshift(html`${raw(kit.share({ title, url: shareUrl, text: shareText || '', image: shareImage || null }))}`);++ if (shareUrl) actions.unshift(html`${raw(kit.share({ title, url: shareUrl, text: shareText || '', image: shareImage || null, kind: 'page', id: page.id }))}`);return html`${crumbs(trail)}<article class="wk-article" data-page-id="${page.id}" data-revision="${String(rev.number)}"><header><h1>${title}</h1><nav class="wk-actions" aria-label="Page actions">${actions.map((a, i) => html`${i ? ' · ' : ''}${a}`)}</nav>${pageFacts(v, base)}</header>@@@ -578,11 +579,12 @@ ${roles.map((r) => html`<tr><td><code>${r.subject}</code></td><td>${r.role}</td></section>`;}--function pageSettingsPage({ space, page, v, parents, error = null, flash = null, canManage }) {++function pageSettingsPage({ space, page, v, parents, error = null, flash = null, canManage, shareUrl = '' }) {const base = wpath(space, page);return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: page.title, url: base }, { name: 'Settings' }])}<section><h1>Settings of ${page.title}</h1>${error ? notice('error', error) : ''}${flash ? notice('ok', flash) : ''}<p>State: <strong>${page.state}</strong>${page.published_revision ? html` · published revision ${String(page.published_revision)}` : ''} · visibility ${page.visibility} (wiki: ${space.visibility})</p>++<p class="wk-share">${raw(kit.share({ title: page.title, url: shareUrl, kind: 'page', id: page.id }))}</p><h2>Publish</h2><form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="publish"><label>Revision <input type="number" name="revision" min="1" value="${String(v.headNumber)}"></label>@@@ -608,8 +610,9 @@ ${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', 'vip', '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') : ''}>${x === 'public' ? 'Everyone' : x === 'private' ? 'Only me' : 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 class="wk-muted">The Share button above offers the same four choices for this one page, and can name specific people (“look at this before I publish it”).</p><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>` : ''}</section>`;@@@ -667,13 +670,30 @@ ${items.length ? html`<ol class="wk-recent">${items.map((r) => html`<li><a href=</section>`;}++/**++ * The page a person who is not on the list sees (design 2026-11-06): the audience, never the title,++ * the text, the pictures or the history. OVShare.gate offers "Ask for access" when signed in.++ */++function sharedGateBody({ id, signedInAs = null, signInUrl = '/auth/login' }) {++ return html`<section class="wk-message">++<h1>A page on OpenVibe.Wiki</h1>++<p>This is shared with specific people.</p>++<div id="share-gate" data-ov-share-gate data-kind="page" data-id="${String(id)}" data-signed-in-as="${signedInAs || ''}" data-sign-in-url="${signInUrl}">++<p>This page is shared with specific people on OpenVibe.Wiki.</p>++<p><a class="wk-button" href="${signInUrl}">Sign in</a></p>++</div>++<link rel="stylesheet" href="${ovServe.url('ov-share.css')}">++<script src="${ovServe.url('ov-share.js')}" defer></script>++</section>`;++}++function signInPage({ next, message }) {return html`<section><h1>Sign in</h1><p>${message}</p><p><a class="wk-button" href="/auth/login?next=${e(next || '/')}">Sign in with OpenVibe</a></p></section>`;}module.exports = {vibePage, vibeProgress, vibeForm,-- vipGateBody,++ vipGateBody, sharedGateBody,home, homeJsonLd, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,spaceSettingsPage, importPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,signInPage, errorBody, wpath, VIS_LABEL,nnew file mode 100644@@@ -0,0 +1,202 @@++'use strict';++++/**++ * Sharing for Wiki pages and drafts (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; openvibe-sdk/share).++ *++ * The SDK owns the model and the rules: the `shares` row next to each page, the one access check++ * (canView), members, access requests, transfers, the append-only log and the delivery queue. This++ * file is the wiring a service supplies:++ *++ * - network.json Network's identity lookups (@name → subject, and a person's card for the sheet)++ * - events.publish Wiki's own Events outbox, so share.* envelopes go out with every other wiki.* event++ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue++ * - onAudienceChange keeps wiki_pages.visibility in step (public→public, link→unlisted, people→private,++ * private→private), so an old reader that only knows the column fails closed, and++ * re-syncs Search so a page that leaves Everyone is tombstoned at once++ * - onOwnerChange moves wiki_pages.created_by to the new owner++ *++ * A draft is registered private (Only me). The sheet on the draft can name people ("look at this++ * before I publish it"); the page at /w/:space/:slug is the link, and it answers with the gate,++ * never the text, to anyone not on the list. `members` (OpenVibe.VIP) is the fifth audience that++ * already existed on Blog; the sheet never offers it, and canReadPost keeps deciding it the old way.++ */++const { createShares } = require('openvibe-sdk/share');++const { serviceAuth } = require('openvibe-contracts');++++const SERVICE = 'wiki';++const KIND = 'page';++const AUDIENCES = ['public', 'link', 'people', 'private'];++/** Wiki's visibility words to the SDK's, and back. `members` and `vip` are not share audiences. */++const TO_SDK = { public: 'public', members: 'private', vip: 'private', private: 'private' };++const FROM_SDK = { public: 'public', link: 'private', people: 'private', private: 'private' };++++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });++++/** Blog's viewer as the SDK wants it: a subject object, and the staff bit. */++function shareViewer(v) {++ if (!v || !v.subject) return null;++ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };++ if (String(v.subject).startsWith('gst_')) return { subject: { type: 'guest', id: v.subject }, staff: !!v.staff };++ return null;++}++++function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {++ let reindex = null;++ const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');++ const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({++ tokenUrl: `${base}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network',++ scope: 'identity.subject.resolve network.notifications.push',++ fetchImpl,++ }) : null;++++ async function networkJson({ path, method = 'GET', json, query } = {}) {++ if (!tokens) throw new Error('Network is not configured for this service');++ const url = new URL(`${base}${path}`);++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };++ if (json !== undefined) headers['Content-Type'] = 'application/json';++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });++ const body = await res.json().catch(() => null);++ if (!res.ok) {++ const err = new Error(`Network ${method} ${path} ${res.status}: ${(body && (body.detail || body.error)) || 'failed'}`);++ err.status = res.status;++ throw err;++ }++ return body;++ }++++ /** One notification, addressed by canonical subject. It never carries a page's title or text. */++ async function notify(n) {++ const to = n && n.to;++ if (!to || to.type !== 'user') return;++ const words = {++ 'share.added': ['A wiki page was shared with you', 'Open it on OpenVibe.Wiki.'],++ 'share.access_requested': ['Someone asked for access to your page', 'Review the request on OpenVibe.Wiki.'],++ 'share.transfer.requested': ['Someone wants to give you a page', 'Accept or decline it on OpenVibe.Wiki.'],++ 'share.transfer.answered': ['A page transfer was answered', 'See what happened on OpenVibe.Wiki.'],++ }[n.kind] || ['Sharing on OpenVibe.Wiki', 'There is something new for you.'];++ try {++ await networkJson({++ path: '/internal/notifications/push', method: 'POST',++ json: {++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],++ service: 'wiki', url: config.baseUrl,++ category: 'social',++ },++ });++ } catch (err) {++ log.warn('[Sharing] notification not sent:', err.message);++ }++ }++++ /** The row's audience change keeps the old column in step and re-indexes (a no-op when already there). */++ async function onAudienceChange(ref, audience, tx) {++ if (ref.service !== SERVICE || ref.kind !== KIND) return;++ const row = await tx.maybe('SELECT state, visibility FROM wiki_pages WHERE id = $1', [ref.id]);++ if (!row) return;++ const visibility = FROM_SDK[audience] || 'private';++ // `members` and `vip` have no share audience: leave those columns alone (canReadPage keeps deciding them).++ if (!(row.visibility === 'members' || row.visibility === 'vip')) {++ await tx.exec('UPDATE wiki_pages SET visibility = $1, updated_at = $3 WHERE id = $2', [visibility, ref.id, now()]);++ }++ tx.afterCommit(async () => {++ try { if (reindex) await reindex(ref.id); }++ catch (err) { log.warn('[Sharing] search sync:', err.message); }++ });++ }++++ async function onOwnerChange(ref, from, to, tx) {++ if (ref.service !== SERVICE || ref.kind !== KIND) return;++ const owner = to && to.type === 'user' ? to.id : null;++ await tx.exec('UPDATE wiki_pages SET created_by = $1, updated_at = $3 WHERE id = $2', [owner, ref.id, now()]);++ }++++ const shares = createShares({++ db,++ network: { json: networkJson },++ events: { publish: async (envelope) => await outbox.emit(envelope) },++ notify,++ onAudienceChange,++ onOwnerChange,++ });++++ const sv = (viewer) => shareViewer(viewer);++ const at = (id) => refOf(id);++++ return {++ shares,++ SERVICE,++ KIND,++ AUDIENCES,++ refOf,++ shareViewer,++ /** Wiki's own indexer, attached by the wiki service once it exists (onAudienceChange calls it after the commit). */++ attach: (fn) => { reindex = fn; },++ /** Register a page with the sharing system (inside the page's own transaction). */++ register: async (viewer, id, audience = 'private') => await shares.register(at(id), {++ owner_subject: viewer && viewer.subject ? { type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject } : { type: 'service', id: SERVICE },++ audience: TO_SDK[audience] || 'private',++ }),++ /**++ * The service's own audience move (a create form, the editor's select, a publish). The caller++ * has already passed access.canWrite and owns the column and the Search sync; this only keeps++ * the share row in step. The SDK's owner check is for people using the sheet.++ */++ syncAudience: async (id, audience) => {++ const next = TO_SDK[audience] || 'private';++ await db.tx(async (t) => {++ const row = await t.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3 FOR UPDATE', [SERVICE, KIND, String(id)]);++ if (!row || row.audience === next) return;++ await t.exec('UPDATE shares SET audience = $1, revision = revision + 1 WHERE service = $2 AND kind = $3 AND resource_id = $4', [next, SERVICE, KIND, String(id)]);++ });++ },++ shareOf: async (id) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]),++ canView: async (viewer, id) => await shares.canView(sv(viewer), at(id)),++ canEdit: async (viewer, id) => await shares.canEdit(sv(viewer), at(id)),++ get: async (viewer, id) => await shares.get(sv(viewer), at(id)),++ getWithRevision: async (viewer, id) => await shares.getWithRevision(sv(viewer), at(id)),++ setAudience: async (viewer, id, audience) => await shares.setAudience(sv(viewer), at(id), TO_SDK[audience] || 'private'),++ addMembers: async (viewer, id, list) => await shares.addMembers(sv(viewer), at(id), list),++ removeMember: async (viewer, id, subject) => await shares.removeMember(sv(viewer), at(id), subject),++ update: async (viewer, id, body, opts) => await shares.update(sv(viewer), at(id), body, opts),++ requestAccess: async (viewer, id) => await shares.requestAccess(sv(viewer), at(id)),++ dismiss: async (viewer, id, requestId) => await shares.dismiss(sv(viewer), at(id), requestId),++ transfer: async (viewer, id, input) => await shares.transfer(sv(viewer), at(id), input),++ accept: async (viewer, id, transferId) => await shares.accept(sv(viewer), at(id), transferId),++ decline: async (viewer, id, transferId) => await shares.decline(sv(viewer), at(id), transferId),++ cancel: async (viewer, id, transferId) => await shares.cancel(sv(viewer), at(id), transferId),++ lookup: async (viewer, name) => await shares.lookup(sv(viewer), name),++ sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(sv(viewer), { service: SERVICE, ...(opts || {}) }),++ /** Expire members and transfers, then send queued events and notifications. */++ work: async ({ flush = true } = {}) => {++ let expired = 0;++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }++ let sent = 0;++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }++ return { expired, sent };++ },++ };++}++++/** The uniform API's describe(ref): the sheet shows the page's title and link to its owner. */++function describeRef(db, config) {++ return async (ref) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};++ const page = await db.maybe(`SELECT p.id, p.slug, p.title, p.state, s.slug AS space_slug++ FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id WHERE p.id = $1 AND p.state <> 'deleted'`, [ref.id]);++ if (!page) return {};++ const link = `/w/${page.space_slug}/${page.slug}`;++ return {++ title: page.title || 'Untitled',++ link: `${String(config.baseUrl || '').replace(/\/+$/, '')}${link}`,++ roles: ['viewer'],++ audiences: AUDIENCES,++ transferable: true,++ };++ };++}++++module.exports = { createSharing, describeRef, shareViewer, refOf, AUDIENCES, TO_SDK, FROM_SDK, SERVICE, KIND };@@@ -47,6 +47,27 @@ function createAccess(db, { vip = null } = {}) {const rolesOfSubject = db.prepare('SELECT space_id, role FROM wiki_permissions WHERE subject = ?');const rolesOfSpace = db.prepare('SELECT subject, role FROM wiki_permissions WHERE space_id = ?');++ /**++ * The pages this actor may read because somebody shared that one page with them (openvibe-sdk/share):++ * its owner, a member of its people list, or anybody when the row says public/link. Loaded with the++ * roles, so canReadPage stays synchronous and a tree filter never races an ACL write.++ */++ async function loadShares(actor) {++ if (!actor || !actor.subject || actor.wikiShares) return actor;++ const subject = JSON.stringify({ type: /^gst_/.test(String(actor.subject)) ? 'guest' : 'user', id: actor.subject });++ // A public/link row only counts inside a public space: a page can narrow its space's audience, never widen it.++ const rows = await db.many(`SELECT resource_id FROM shares++ WHERE service = 'wiki' AND kind = 'page' AND owner_subject = $1::jsonb++ UNION SELECT resource_id FROM share_members++ WHERE service = 'wiki' AND kind = 'page' AND subject = $1::jsonb AND (expires_at IS NULL OR expires_at > now())++ UNION SELECT sh.resource_id FROM shares sh++ JOIN wiki_pages p ON p.id = sh.resource_id++ JOIN wiki_spaces s ON s.id = p.space_id++ WHERE sh.service = 'wiki' AND sh.kind = 'page' AND sh.audience IN ('public','link') AND s.visibility = 'public'`, [subject]);++ Object.defineProperty(actor, 'wikiShares', { value: new Set(rows.map((r) => r.resource_id)), writable: true, configurable: true, enumerable: false });++ return actor;++ }++/** Read (or re-read with fresh: true) every role the actor holds; idempotent and cheap to call again. */async function loadRoles(actor, { fresh = false } = {}) {if (!actor || actor.kind === 'system' || !actor.subject) return actor;@@@ -93,6 +114,7 @@ function createAccess(db, { vip = null } = {}) {VISIBILITIES,role,loadRoles,++ loadShares,subjectActors,isPerson,effectiveVisibility,@@@ -103,6 +125,9 @@ function createAccess(db, { vip = null } = {}) {/** 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;++ // A share row for this one page opens it before the space's own visibility: that is what++ // "share this page with @x" means, and for a draft it is the only way in.++ if (actor && actor.wikiShares && actor.wikiShares.has(page.id)) return true;if (!canReadVisibility(space, effectiveVisibility(space, page), actor, page)) return false;if (page.state === 'published') return true;return api.canEdit(space, actor);@@@ -33,6 +33,23 @@ const TABLES = [{ table: 'wiki_page_reviews', subject: 'reviewer', file: 'reviews.json', columns: ['entity_id', 'revision', 'decision', 'note', 'reviewed_at'], order: 'reviewed_at', erase: { keep: 'a person\'s approval is what lets reviewed text stay published' } },];++++/** The sharing system's rows (openvibe-sdk/share): jsonb subjects, so the SDK's table map cannot name them. */++async function shareExport(db, subject) {++ const owned = await db.many("SELECT * FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['wiki', subject]);++ const member = await db.many("SELECT * FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['wiki', subject]);++ const asked = await db.many("SELECT * FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['wiki', subject]);++ return owned.length || member.length || asked.length ? [{ name: 'shares.json', content: { owned, member_of: member, requests: asked } }] : [];++}++async function shareErase(t, subjects) {++ for (const subject of subjects) {++ await t.exec("DELETE FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['wiki', subject]);++ await t.exec("DELETE FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['wiki', subject]);++ await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['wiki', subject]);++ await t.exec("DELETE FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['wiki', subject]);++ }++}++async function extraExport(db, subject) {const files = [];const spaces = await db.many(`SELECT id, slug, name, description, kind, visibility, created_at, updated_at, deleted_at FROM wiki_spaces@@@ -47,12 +64,14 @@ async function extraExport(db, subject) {const proposals = await db.many(`SELECT id, page_id, space_id, revision, status, note, created_at FROM wiki_ai_proposalsWHERE proposed_by = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);if (proposals.length) files.push({ name: 'ai-proposals.json', content: proposals });++ files.push(...await shareExport(db, subject));return files;}/** The account-data handle over Wiki's database and service (service.deleteSpace for the person's own spaces). */function create({ db, svc, log = console } = {}) {async function extraErase(t, subjects, counts) {++ await shareErase(t, subjects);// Their own user spaces go the way their owner would delete them (a savepoint inside this transaction).const spaces = await t.many("SELECT id FROM wiki_spaces WHERE kind = 'user' AND owner = ANY($1::text[]) AND deleted_at IS NULL", [subjects]);for (const s of spaces) await svc.deleteSpace(s.id, SYSTEM);@@@ -84,13 +84,13 @@ function disclosureFor(rec, review) {};}--function createWikiService({ db, stores, outbox, config, community = null, vip = null, now = () => Date.now(), log = console, indexnow = null }) {++function createWikiService({ db, stores, outbox, config, community = null, vip = null, now = () => Date.now(), log = console, indexnow = null, sharing = null }) {const { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;const access = createAccess(db, { vip });const origin = config.baseUrl;const policy = config.gate;// Every entry point reads the actor's roles again (one indexed query): a role granted a moment ago counts.-- const roles = (actor, opts = { fresh: true }) => access.loadRoles(actor, opts);++ const roles = async (actor, opts = { fresh: true }) => { await access.loadRoles(actor, opts); await access.loadShares(actor); return actor; };// ── Queries (PostgreSQL; async prepared statements via openvibe-sdk/db prepare) ──const q = {@@@ -510,6 +510,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =await writeRevisionExtras(space, page, revision, box);await attachCitationList(page, revision.number, cites, actor);await revisionEvent(space, page, revision, actor);++ // A new page is a draft: Only me until its author shares it or publishes it.++ if (sharing) await sharing.register(actor, id, 'private');if (/^usr_/.test(actorId(actor))) await q.watch.run(id, actorId(actor), t);return { page, revision };});@@@ -526,6 +528,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =}const b = await before(page);await q.setPublished.run({ n: revisionNumber, title: rev.fields.title || page.title, t: now(), id: page.id });++ // Publishing widens a draft to the visibility its author chose (the Share sheet keeps the column in step).++ if (sharing) await sharing.syncAudience(page.id, page.visibility);return await sync(b, page.id, actor);}@@@ -536,6 +540,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =*/async function revisionVisible(space, page, n, actor, proposal) {if (access.canEdit(space, actor)) return true;++ // A shared *draft* opens at its head revision. A published page still shows only its published revision++ // to a reader who is not an editor, however it was shared.++ if (page.state !== 'published' && actor && actor.wikiShares && actor.wikiShares.has(page.id)) return true;++ if (sharing && page.state !== 'published') {++ const row = await sharing.shareOf(page.id).catch(() => null);++ if (row && (row.audience === 'public' || row.audience === 'link')) return true;++ }if (page.state !== 'published' || !page.published_revision || n > page.published_revision) return false;const p = proposal === undefined ? await q.proposalByRev.get(page.id, n) : proposal;return !p || p.status === 'approved';@@@ -1000,8 +1011,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space changes page visibility');return await tx(async () => {const b = await before(page);++ const nextVisibility = checkVisibility(visibility, page.visibility, space);await db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')-- .run(checkVisibility(visibility, page.visibility, space), noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);++ .run(nextVisibility, noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);++ if (sharing) await sharing.syncAudience(page.id, nextVisibility);const out = await sync(b, page.id, actor);return { page: await q.pageById.get(page.id), ...out };});@@@ -1517,6 +1530,16 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =},};++ /** The Share sheet moved a page's audience: send Search what it should hold (a tombstone when it leaves Everyone). */++ async function reindex(pageId) {++ await tx(async () => {++ const page = await q.pageById.get(pageId);++ if (!page) return;++ await sync(await before(page), pageId, null);++ });++ }++ if (sharing) sharing.attach(reindex);++return svc;}@@@ -50,6 +50,8 @@ async function check(name, fn) {exempt: {account_data_events: 'the ledger of requests itself: which export and deletion Wiki applied',wiki_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",++ share_members: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',++ share_access_requests: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',},});if (missing.length) { failures++; console.log(` ✗ every table that names a person is covered or exempted with a reason\n ${missing.join(', ')}`); }@@@ -87,7 +89,7 @@ async function check(name, fn) {const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');assert.strictEqual(part.auth, 'Bearer tok_wiki');const names = part.body.files.map((f) => f.name).sort();-- assert.deepStrictEqual(names, ['pages.json', 'revisions.json', 'roles.json', 'spaces.json', 'watching.json']);++ assert.deepStrictEqual(names, ['pages.json', 'revisions.json', 'roles.json', 'shares.json', 'spaces.json', 'watching.json']);assert.ok(!JSON.stringify(part.body).includes(bob.subject));});nnew file mode 100644@@@ -0,0 +1,129 @@++'use strict';++/**++ * Sharing on Wiki pages and drafts (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; the pilot is++ * Community's pastes, the same openvibe-sdk/share). A new page is Only me; the four audiences are++ * enforced by canView on the page; a page that leaves Everyone leaves the sitemap and the feeds at++ * once; publishing a draft widens it to the visibility its author chose.++ */++const assert = require('assert');++const H = require('./helpers');++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }++}++++(async () => {++ const people = new Map();++ /** Network's two identity routes plus the notification push, without a socket. */++ const fetchImpl = async (url, opts = {}) => {++ const u = new URL(String(url));++ const json = (status, body) => ({ ok: status < 400, status, json: async () => body });++ if (u.pathname === '/oauth/token') return json(200, { access_token: 'stub-token', token_type: 'Bearer', expires_in: 300 });++ if (u.pathname === '/internal/identity/resolve-batch') {++ const b = JSON.parse(opts.body || '{}');++ const results = {};++ for (const id of b.subject_ids || []) results[id] = people.get(id) || null;++ return json(200, { results });++ }++ if (u.pathname === '/internal/identity/resolve') {++ const name = (u.searchParams.get('username') || '').toLowerCase();++ for (const p of people.values()) if (p.username.toLowerCase() === name) return json(200, p);++ return json(404, { error: 'no such person' });++ }++ if (u.pathname === '/internal/notifications/push') return json(202, { queued: true });++ throw new Error(`unexpected outbound fetch ${url}`);++ };++ const h = await H.boot({ env: { OV_NETWORK_INTERNAL_URL: 'https://network.test', OV_OAUTH_CLIENT_SECRET: 'shh' }, fetch: fetchImpl });++ const alice = { subject: H.subject(), username: 'alice' };++ const bob = { subject: H.subject(), username: 'bob' };++ const sam = { subject: H.subject(), username: 'sam' };++ for (const p of [alice, bob, sam]) people.set(p.subject, { subject: { type: 'user', id: p.subject }, username: p.username, display_name: p.username, avatar_url: null, banned: false });++ const tok = (u) => H.userToken({ subject: u.subject, username: u.username, aud: ['openvibe.network', 'openvibe.wiki'] });++ const cookies = (u) => ({ cookie: H.cookieFor(tok(u)) });++++ await H.req(h, 'POST', '/api/v1/spaces', { token: tok(alice), body: { slug: 'handbook', name: 'Handbook', kind: 'user', visibility: 'public' } });++ const make = async (title = 'Draft notes') => {++ const r = await H.req(h, 'POST', '/api/v1/spaces/handbook/pages', { token: tok(alice), body: { title, body: `${H.LONG} [1]`, visibility: 'public', citations: [{ url: 'https://example.com/source', title: 'A source', retrievedAt: '2026-11-06T00:00:00Z' }] } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json.page;++ };++ const share = (id, opts = {}) => H.req(h, opts.method || 'GET', `/api/v1/page/${id}/share`, { headers: { 'x-requested-with': 'ov-share' }, ...opts });++ const putShare = (id, body, u = alice) => share(id, { method: 'PUT', token: tok(u), body });++ const row = async (id) => await h.db.prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('wiki', 'page', id);++++ await check('a new page is registered Only me, owned by its author; only its author reads the draft', async () => {++ const p = await make();++ const s = await row(p.id);++ assert.strictEqual(s.audience, 'private');++ assert.deepStrictEqual(s.owner_subject, { type: 'user', id: alice.subject });++ const url = `/w/handbook/${p.slug}`;++ assert.strictEqual((await H.req(h, 'GET', url, { token: tok(alice) })).status, 200);++ assert.strictEqual((await H.req(h, 'GET', url, { token: tok(bob) })).status, 404);++ assert.strictEqual((await H.req(h, 'GET', url)).status, 404);++ });++++ await check('Specific people: the named person reads the draft; a stranger gets the gate and no title', async () => {++ const p = await make('Board pack');++ const put = await putShare(p.id, { audience: 'people', members: [{ subject: { type: 'user', id: bob.subject }, role: 'viewer' }] });++ assert.strictEqual(put.status, 200, put.text);++ assert.strictEqual(put.json.audience, 'people');++ assert.strictEqual((await h.db.prepare('SELECT visibility FROM wiki_pages WHERE id = ?').get(p.id)).visibility, 'private');++ const url = `/w/handbook/${p.slug}`;++ const member = await H.req(h, 'GET', url, { token: tok(bob) });++ assert.strictEqual(member.status, 200);++ assert.ok(member.text.includes(H.LONG.slice(0, 40)), 'the body');++ const stranger = await H.req(h, 'GET', url, { token: tok(sam) });++ assert.strictEqual(stranger.status, 404);++ assert.ok(stranger.text.includes('shared with specific people'), 'OVShare.gate');++ assert.ok(stranger.text.includes('ov-share.js') && stranger.text.includes('data-ov-share-gate'));++ assert.ok(!stranger.text.includes('Board pack'), 'no title');++ const anon = await H.req(h, 'GET', url);++ assert.strictEqual(anon.status, 404);++ assert.ok(anon.text.includes('/auth/login?next='), 'sign in');++ assert.strictEqual((await H.req(h, 'GET', `/api/v1/page/${p.id}/share`, { token: tok(sam) })).status, 404, 'the sheet is the owner’s');++ });++++ await check('Everyone and the link: a shared draft opens by its address, still not listed', async () => {++ const p = await make('Everyone draft');++ assert.strictEqual((await putShare(p.id, { audience: 'public' })).status, 200);++ assert.strictEqual((await H.req(h, 'GET', `/w/handbook/${p.slug}`)).status, 200);++ assert.strictEqual((await putShare(p.id, { audience: 'link' })).status, 200);++ assert.strictEqual((await H.req(h, 'GET', `/w/handbook/${p.slug}`)).status, 200, 'the link is the key');++ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes(`/w/handbook/${p.slug}`), 'a draft is never in the sitemap');++ });++++ await check('publishing widens a draft to its author’s chosen visibility; leaving Everyone removes it at once', async () => {++ const p = await make('Ready to ship');++ assert.strictEqual((await row(p.id)).audience, 'private');++ const pub = await H.req(h, 'POST', `/api/v1/pages/${p.id}/publish`, { token: tok(alice), body: {} });++ assert.strictEqual(pub.status, 200, pub.text);++ assert.strictEqual((await row(p.id)).audience, 'public');++ const url = `/w/handbook/${p.slug}`;++ assert.strictEqual((await H.req(h, 'GET', url)).status, 200);++ assert.ok((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes(url), 'a public page is in the sitemap');++ const put = await putShare(p.id, { audience: 'private' });++ assert.strictEqual(put.status, 200, put.text);++ assert.strictEqual((await H.req(h, 'GET', url)).status, 404);++ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes(url), 'and out of the sitemap');++ assert.strictEqual((await H.req(h, 'GET', url, { token: tok(alice) })).status, 200, 'the owner still reads it');++ const events = await H.outbox(h);++ const tombstones = events.filter((e) => /^wiki\.index_document\.deleted$/.test(e.event_type));++ assert.ok(tombstones.some((e) => JSON.stringify(e.payload || {}).includes(p.id)), 'a search tombstone went out');++ });++++ await check('the members list is shown to the owner and the sheet refuses a non-owner', async () => {++ const p = await make('Two readers');++ await putShare(p.id, { audience: 'people', members: [{ subject: { type: 'user', id: sam.subject }, role: 'viewer' }] });++ const mine = await share(p.id, { token: tok(alice) });++ assert.strictEqual(mine.status, 200);++ assert.strictEqual(mine.json.members[0].subject.id, sam.subject);++ assert.strictEqual(mine.json.you.role, 'owner');++ const theirs = await share(p.id, { token: tok(bob) });++ assert.strictEqual(theirs.status, 404);++ });++++ console.log(failures ? `\n${failures} failed` : '\nall passed');++ await h.stop();++ process.exit(failures ? 1 : 0);++})();@@@ -65,6 +65,7 @@ const INTERNAL = ['server/integrations/platform.js': 'Community, Sources, Media, VIP, AI (configured)','server/integrations/projects-module.js': 'Network user modules (configured)','server/integrations/image-fetch.js': 'pictures a generated wiki uses (research results): https only, openvibe-shared/egress safeLookup on every hop, a byte cap, magic bytes checked',++ 'server/sharing.js': 'OpenVibe.Network (configured) for identity lookups and the notification push; never a URL a user chose',};const root = path.join(__dirname, '..');const found = [];nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]):++ * the page carries markup only, ov-share.js draws the gate, signed out it offers Sign in, and a++ * signed-in stranger is offered Ask for access — no console error and no CSP refusal.++ * Skipped (exit 0) without Chrome, or with OV_SKIP_BROWSER=1.++ */++const assert = require('assert');++const fs = require('fs');++const H = require('./helpers');++++let playwright = null;++try { playwright = require('playwright'); } catch {++ try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }++}++const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';++if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {++ console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;++let failures = 0;++async function check(fn, name) {++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }++}++++(async () => {++ const people = new Map();++ const fetchImpl = async (url, opts = {}) => {++ const u = new URL(String(url));++ const json = (status, body) => ({ ok: status < 400, status, json: async () => body });++ if (u.pathname === '/oauth/token') return json(200, { access_token: 'stub-token', token_type: 'Bearer', expires_in: 300 });++ if (u.pathname === '/internal/identity/resolve-batch') {++ const b = JSON.parse(opts.body || '{}');++ const results = {};++ for (const id of b.subject_ids || []) results[id] = people.get(id) || null;++ return json(200, { results });++ }++ if (u.pathname === '/internal/identity/resolve') {++ const name = (u.searchParams.get('username') || '').toLowerCase();++ for (const p of people.values()) if (p.username.toLowerCase() === name) return json(200, p);++ return json(404, { error: 'no such person' });++ }++ if (u.pathname === '/internal/notifications/push') return json(202, { queued: true });++ throw new Error(`unexpected outbound fetch ${url}`);++ };++ const h = await H.boot({ env: { OV_NETWORK_INTERNAL_URL: 'https://network.test', OV_OAUTH_CLIENT_SECRET: 'shh' }, fetch: fetchImpl });++ const alice = { subject: H.subject(), username: 'alice' };++ const bob = { subject: H.subject(), username: 'bob' };++ const sam = { subject: H.subject(), username: 'sam' };++ for (const p of [alice, bob, sam]) people.set(p.subject, { subject: { type: 'user', id: p.subject }, username: p.username, display_name: p.username, avatar_url: null, banned: false });++ const tok = (u) => H.userToken({ subject: u.subject, username: u.username, aud: ['openvibe.network', 'openvibe.wiki'] });++++ await H.req(h, 'POST', '/api/v1/spaces', { token: tok(alice), body: { slug: 'handbook', name: 'Handbook', kind: 'user', visibility: 'public' } });++ const made = await H.req(h, 'POST', '/api/v1/spaces/handbook/pages', { token: tok(alice), body: { title: 'Board pack', body: `${H.LONG} [1]`, visibility: 'public', citations: [{ url: 'https://example.com/source', title: 'A source', retrievedAt: '2026-11-06T00:00:00Z' }] } });++ assert.strictEqual(made.status, 201, made.text);++ const page = made.json.page;++ const put = await H.req(h, 'PUT', `/api/v1/page/${page.id}/share`, { headers: { 'x-requested-with': 'ov-share' }, token: tok(alice), body: { audience: 'people', members: [{ subject: { type: 'user', id: bob.subject }, role: 'viewer' }] } });++ assert.strictEqual(put.status, 200, put.text);++ const url = `${h.base}/w/handbook/${page.slug}`;++++ let browser;++ const errors = [];++ const watch = (p) => {++ p.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });++ p.on('pageerror', (e) => errors.push(String(e)));++ };++ const settled = async (p) => {++ await p.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });++ return await p.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());++ };++++ try {++ await check(async () => {++ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });++ const ctx = await browser.newContext();++ const p = await ctx.newPage();++ watch(p);++ const res = await p.goto(url, { waitUntil: 'load', timeout: 15000 });++ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the page');++ const text = await settled(p);++ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);++ const href = await p.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));++ assert.match(href, /^\/auth\/login\?next=/);++ await ctx.close();++ }, 'headless Chrome: the gate draws itself, signed out, with Sign in');++++ await check(async () => {++ const ctx = await browser.newContext();++ await ctx.addCookies([{ name: 'ov_token', value: tok(sam), url: h.base }]);++ const p = await ctx.newPage();++ watch(p);++ await p.goto(url, { waitUntil: 'load', timeout: 15000 });++ const text = await settled(p);++ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);++ assert.ok(/@sam/.test(text), 'the gate names the account it is signed in as');++ await ctx.close();++ }, 'headless Chrome: a stranger is offered Ask for access');++++ await check(async () => {++ assert.deepStrictEqual(errors.filter((e) => REFUSED.test(e)), []);++ }, 'headless Chrome: no console error and no CSP refusal');++ } finally {++ if (browser) await browser.close().catch(() => {});++ await h.stop(); await h.closeDb();++ process.exit(failures ? 1 : 0);++ }++})();
@@ -316,7 +316,7 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
@@ -316,7 +316,7 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
- PostgreSQL 18 and Valkey 9 (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
- `openvibe-publishing` v1.3.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`
- `openvibe-publishing` v1.3.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`
- v0.107.0, `openvibe-shared` v4.122.0
+ v0.107.0, `openvibe-shared` v4.123.0
(Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.91.0 (db, auth, PostgreSQL
(Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.91.0 (db, auth, PostgreSQL
events outbox, per-actor limits, account export and deletion, testing) — pinned release tarballs.
events outbox, per-actor limits, account export and deletion, testing) — pinned release tarballs.
- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
@@ -446,6 +446,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -446,6 +446,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.91.0
- openvibe-sdk: v0.91.0
-- openvibe-shared: v4.122.0
+- openvibe-shared: v4.123.0
- openvibe-publishing: v1.3.0
- openvibe-publishing: v1.3.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,10 +9,10 @@
@@ -9,10 +9,10 @@
"domain": "openvibe.wiki",
"domain": "openvibe.wiki",
"contracts": "openvibe-contracts v0.289.1",
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.91.0",
"sdk": "openvibe-sdk v0.91.0",
- "shared": "openvibe-shared v4.122.0",
+ "shared": "openvibe-shared v4.123.0",
"packages": [
"packages": [
"openvibe-publishing v1.3.0",
"openvibe-publishing v1.3.0",
- "openvibe-shared v4.122.0",
+ "openvibe-shared v4.123.0",
"openvibe-sdk v0.91.0"
"openvibe-sdk v0.91.0"
],
],
"capabilities": [
"capabilities": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+-- phase: expand
+-- Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 4): the tables openvibe-sdk/share v0.91.0's
+-- sharesSchema() creates, kept verbatim so Blog's database has the same shape as every other
+-- service that adopts it. Expand only: nothing is dropped or rewritten.
+
+
+CREATE TABLE IF NOT EXISTS shares (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
+ revision integer NOT NULL DEFAULT 1,
+ lock text NOT NULL DEFAULT 'none',
+ reseal_nonce_base text,
+ PRIMARY KEY (service, kind, resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_members (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
+ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
+ key_slot jsonb,
+ PRIMARY KEY (service, kind, resource_id, subject),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_transfers (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
+ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
+ key_slot jsonb,
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_access_requests (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_log (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
+ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
+ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
+ END IF;
+END $$;
+CREATE TABLE IF NOT EXISTS share_deliveries (
+ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
+ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE TABLE IF NOT EXISTS share_name_limits (
+ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
+);
+CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
+CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
+CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
+-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
+-- slot a transfer was offered with. Repeating this migration is a no-op.
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
+DO $$ BEGIN
+ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
+
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+-- phase: migrate
+-- Every page that existed before sharing is registered once, so canView has a row for all of them.
+--
+-- a published page of a public space, public itself → public (Everyone)
+-- everything else (drafts, members, vip, private) → private (Only me; members/vip keep the gate)
+--
+-- The owner is the person who created the page; a page made by the service or an import with no
+-- person gets Wiki itself, which nobody can sign in as. Deleted pages are not registered.
+INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
+SELECT
+ 'wiki', 'page', p.id,
+ CASE
+ WHEN p.created_by LIKE 'usr\_%' THEN jsonb_build_object('type', 'user', 'id', p.created_by)
+ WHEN p.created_by LIKE 'gst\_%' THEN jsonb_build_object('type', 'guest', 'id', p.created_by)
+ ELSE jsonb_build_object('type', 'service', 'id', 'wiki')
+ END,
+ CASE
+ WHEN p.state = 'published' AND p.visibility = 'public' AND s.visibility = 'public' THEN 'public'
+ ELSE 'private'
+ END
+FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
+WHERE p.state <> 'deleted'
+ON CONFLICT (service, kind, resource_id) DO NOTHING;
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1196,9 +1196,9 @@
@@ -1196,9 +1196,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.122.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",
+ "version": "4.123.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
+ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -19,11 +19,13 @@ const { createApi } = require('./http/api');
@@ -19,11 +19,13 @@ const { createApi } = require('./http/api');
const { createPages } = require('./http/pages');
const { createPages } = require('./http/pages');
const { createMachine } = require('./http/machine');
const { createMachine } = require('./http/machine');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
+const { actorMiddleware } = require('./http/common');
+const { describeRef } = require('./sharing');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
-function createApp({ config, telemetry = null, svc, viewers: baseViewers, platform, keys, db, valkey = null, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null, indexnow = null, accountData = null, accountSend = null, vibes = null }) {
+function createApp({ config, telemetry = null, svc, viewers: baseViewers, platform, keys, db, valkey = null, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null, indexnow = null, accountData = null, accountSend = null, vibes = null, sharing = null }) {
const app = express();
const app = express();
// Every resolved actor carries its wiki roles (read once per request), so the synchronous access checks work.
// Every resolved actor carries its wiki roles (read once per request), so the synchronous access checks work.
const viewers = { ...baseViewers, resolve: async (req, opts) => svc.loadRoles(await baseViewers.resolve(req, opts)) };
const viewers = { ...baseViewers, resolve: async (req, opts) => svc.loadRoles(await baseViewers.resolve(req, opts)) };
@@ -113,6 +115,21 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo
@@ -113,6 +115,21 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo
// Per-actor limits (http/actor-limits.js) on /api/v1 and the editing forms, counted once each router
// Per-actor limits (http/actor-limits.js) on /api/v1 and the editing forms, counted once each router
// resolved req.actor; the per-address limits here stay. limitsNow: the limiter's clock (tests).
// resolved req.actor; the per-address limits here stay. limitsNow: the limiter's clock (tests).
const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits, valkey });
const limits = createActorLimits({ config, now: limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: rateLimits, valkey });
+ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
+ // GET/PUT /api/v1/page/:id/share, .../share/requests, .../share/transfer[/accept|decline|cancel],
+ // GET /api/v1/share/people?name=, GET /api/v1/shared-with-me. Authentication is Wiki's own actor;
+ // the router answers only its own paths.
+ if (sharing) {
+ const shareApi = express.Router();
+ shareApi.use(actorMiddleware(viewers, { services: false }));
+ shareApi.use(sharing.shares.router({
+ service: 'wiki',
+ authenticate: (req) => sharing.shareViewer(req.actor),
+ describe: describeRef(db, config),
+ }));
+ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
+ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
+ }
app.use('/api/v1', createApi({ svc, viewers, platform, config, log, limits, vibes }));
app.use('/api/v1', createApi({ svc, viewers, platform, config, log, limits, vibes }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
@@ -135,7 +152,7 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo
@@ -135,7 +152,7 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo
app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
// Search scans the text of every published page (LIKE): a person's pace, not a crawler's.
// Search scans the text of every published page (LIKE): a person's pace, not a crawler's.
app.get('/search', limiter(60000, 60));
app.get('/search', limiter(60000, 60));
- const pages = createPages({ svc, viewers, platform, config, log, limits, vibes });
+ const pages = createPages({ svc, viewers, platform, config, log, limits, vibes, sharing });
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the
// public pages (the home, a wiki's page tree and its articles). Only the manifest and a twin request
// public pages (the home, a wiki's page tree and its articles). Only the manifest and a twin request
// reach it — the layer renders every page signed out, and a wiki page is not one page (a signed-in
// reach it — the layer renders every page signed out, and a wiki page is not one page (a signed-in
@@ -46,7 +46,7 @@ const { createFrameGroups } = require('./frame-groups');
@@ -46,7 +46,7 @@ const { createFrameGroups } = require('./frame-groups');
// What the home says in search results and share cards (≤ 160 characters).
// What the home says in search results and share cards (≤ 160 characters).
const HOME_DESCRIPTION = 'Read the OpenVibe wiki or start your own: every page keeps its full revision history, lists the sources behind it and has a discussion thread.';
const HOME_DESCRIPTION = 'Read the OpenVibe wiki or start your own: every page keeps its full revision history, lists the sources behind it and has a discussion thread.';
-function createPages({ svc, viewers, platform, config, log = console, limits, vibes = null, frameGroups = createFrameGroups({ url: config.frameUrl }) }) {
+function createPages({ svc, viewers, platform, config, log = console, limits, vibes = null, sharing = null, frameGroups = createFrameGroups({ url: config.frameUrl }) }) {
const router = express.Router();
const router = express.Router();
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 }));
@@ -114,7 +114,36 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
@@ -114,7 +114,36 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
notFound(req, res);
notFound(req, res);
return null;
return null;
}
}
- if (!svc.access.canReadPage(space, page, req.actor)) { if (page.state !== 'published' || !vipGate(req, res, space, page)) notFound(req, res); return null; }
+ if (!svc.access.canReadPage(space, page, req.actor)) {
+ // A share row can open this one page for a signed-in person: a member of its people list, or the
+ // link when the space itself is public. A public row never widens a private space.
+ const row = sharing ? await sharing.shareOf(page.id).catch(() => null) : null;
+ // An Everyone draft opens by its address in a public space; a link or people draft needs a signed-in
+ // person; a public row never widens a private space.
+ const publicDraft = Boolean(row && (row.audience === 'public' || row.audience === 'link') && page.state !== 'published' && svc.access.canReadSpace(space, req.actor));
+ const narrowed = Boolean(row && req.actor && req.actor.subject && (row.audience === 'people'
+ || (row.audience === 'link' && svc.access.canReadSpace(space, req.actor))));
+ const allowed = Boolean((publicDraft || narrowed) && await sharing.canView(req.actor, page.id).catch(() => false));
+ if (allowed) {
+ try {
+ if (!req.actor.wikiShares) Object.defineProperty(req.actor, 'wikiShares', { value: new Set(), writable: true, configurable: true });
+ req.actor.wikiShares.add(page.id);
+ } catch {
+ // The anonymous actor is frozen: a copy carries the grant for the rest of this request.
+ req.actor = { ...req.actor, wikiShares: new Set([page.id]) };
+ }
+ return { space, page };
+ }
+ // A page shared with specific people answers with the gate, never its title, text or history.
+ const s = sharing ? await sharing.shareOf(page.id).catch(() => null) : null;
+ if (s && s.audience === 'people' && !vipGate(req, res, space, page)) {
+ send(req, res, 404, views.sharedGateBody({ id: page.id, signedInAs: req.actor && req.actor.user && req.actor.user.username ? req.actor.user.username : null,
+ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }), { title: 'A page on OpenVibe.Wiki', robots: 'noindex, nofollow' });
+ return null;
+ }
+ if (page.state !== 'published' || !vipGate(req, res, space, page)) notFound(req, res);
+ return null;
+ }
return { space, page };
return { space, page };
}
}
@@ -558,7 +587,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
@@ -558,7 +587,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
async function renderPageSettings(req, res, space, page, status, extra = {}) {
async function renderPageSettings(req, res, space, page, status, extra = {}) {
const [view, list, parents] = await Promise.all([svc.view(space, page, req.actor, {}), svc.history(page, { limit: 1 }), parentsOf(space, req.actor)]);
const [view, list, parents] = await Promise.all([svc.view(space, page, req.actor, {}), svc.history(page, { limit: 1 }), parentsOf(space, req.actor)]);
const headNumber = list[0].number;
const headNumber = list[0].number;
- send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents, canManage: svc.access.canManage(space, req.actor), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });
+ send(req, res, status, views.pageSettingsPage({ space, page, v: { ...view, headNumber }, parents, canManage: svc.access.canManage(space, req.actor), shareUrl: svc.pageUrl(space, page), ...extra }), { title: `Settings of ${page.title}`, robots: 'noindex, nofollow' });
}
}
router.post('/w/:space/:slug/settings', B('wiki.page.update'), form, wrap(async (req, res) => {
router.post('/w/:space/:slug/settings', B('wiki.page.update'), form, wrap(async (req, res) => {
@@ -15,6 +15,7 @@ const { load } = require('./config');
@@ -15,6 +15,7 @@ const { load } = require('./config');
const { openDb, migrate, createStores } = require('./db');
const { openDb, migrate, createStores } = require('./db');
const { createPlatform } = require('./integrations/platform');
const { createPlatform } = require('./integrations/platform');
const { createWikiService } = require('./wiki/service');
const { createWikiService } = require('./wiki/service');
+const { createSharing, describeRef } = require('./sharing');
const { createVibes } = require('./wiki/vibe');
const { createVibes } = require('./wiki/vibe');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
@@ -70,7 +71,9 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -70,7 +71,9 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
// Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
// Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
const valkey = config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null;
const valkey = config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null;
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, vip: platform.vip, now, log, indexnow });
+ // Sharing (openvibe-sdk/share): the shares row next to each page, members, transfers and the one access check.
+ const sharing = createSharing({ db, config, outbox: platform.outbox, now, fetchImpl, log });
+ const svc = createWikiService({ db, stores, outbox: platform.outbox, config, community: platform.community, vip: platform.vip, now, log, indexnow, sharing });
// "Vibe a wiki" (server/wiki/vibe.js): generated wikis, researched and written through OpenVibe.AI.
// "Vibe a wiki" (server/wiki/vibe.js): generated wikis, researched and written through OpenVibe.AI.
const vibes = createVibes({ db, svc, platform, config, now, log, ...(imageFetch ? { fetchImg: imageFetch } : {}) });
const vibes = createVibes({ db, svc, platform, config, now, log, ...(imageFetch ? { fetchImg: imageFetch } : {}) });
const reconciled = await svc.reconcileIndex();
const reconciled = await svc.reconcileIndex();
@@ -89,7 +92,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -89,7 +92,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
// on, a one-minute tick writes each interval (the collector has no timer of its own) and the table keeps 14 days.
// on, a one-minute tick writes each interval (the collector has no timer of its own) and the table keeps 14 days.
const telemetry = createHttpTelemetry({ service: 'wiki', sink: telemetrySink || createPgTelemetrySink({ db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
const telemetry = createHttpTelemetry({ service: 'wiki', sink: telemetrySink || createPgTelemetrySink({ db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
let retention = null;
let retention = null;
- const app = createApp({ config, telemetry, svc, viewers, platform, keys, db, valkey, log, rateLimits, fetchImpl, limitsNow, indexnow, accountData, accountSend, vibes });
+ const app = createApp({ config, telemetry, svc, viewers, platform, keys, db, valkey, log, rateLimits, fetchImpl, limitsNow, indexnow, accountData, accountSend, vibes, sharing });
const timers = [];
const timers = [];
if (workers) {
if (workers) {
@@ -138,7 +141,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -138,7 +141,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
async function stop() {
async function stop() {
return lifecycle.stop('stop');
return lifecycle.stop('stop');
}
}
- return { app, db, telemetry, svc, stores, platform, keys, viewers, server, config, indexnow, timers, lifecycle, stop, vibes };
+ return { app, db, telemetry, svc, stores, platform, keys, viewers, server, config, indexnow, timers, lifecycle, stop, vibes, sharing };
}
}
module.exports = { start, createLifecycle };
module.exports = { start, createLifecycle };
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
*/
*/
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const ovServe = require('openvibe-shared/serve');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const { figureHtml } = require('openvibe-publishing/media');
const { figureHtml } = require('openvibe-publishing/media');
const { infoboxToText, mediaRefs } = require('../wiki/content');
const { infoboxToText, mediaRefs } = require('../wiki/content');
@@ -426,7 +427,7 @@ function articlePage(v, { html: contentHtml, discussion, actor, mediaUrl, resolv
@@ -426,7 +427,7 @@ function articlePage(v, { html: contentHtml, discussion, actor, mediaUrl, resolv
if (v.canEdit && v.decision && !v.decision.indexable && page.state === 'published') banners.push(html`<p class="wk-notice wk-info">Search engines are told not to index this page: ${v.decision.reasons.map((r) => r.code + (r.detail ? ` (${r.detail})` : '')).join(', ')}.</p>`);
if (v.canEdit && v.decision && !v.decision.indexable && page.state === 'published') banners.push(html`<p class="wk-notice wk-info">Search engines are told not to index this page: ${v.decision.reasons.map((r) => r.code + (r.detail ? ` (${r.detail})` : '')).join(', ')}.</p>`);
const actions = [html`<a href="${base}/history">History</a>`];
const actions = [html`<a href="${base}/history">History</a>`];
if (v.canEdit) actions.unshift(html`<a href="${base}/edit">Edit</a>`), actions.push(html`<a href="${base}/settings">Page settings</a>`);
if (v.canEdit) actions.unshift(html`<a href="${base}/edit">Edit</a>`), actions.push(html`<a href="${base}/settings">Page settings</a>`);
- if (shareUrl) actions.unshift(html`${raw(kit.share({ title, url: shareUrl, text: shareText || '', image: shareImage || null }))}`);
+ if (shareUrl) actions.unshift(html`${raw(kit.share({ title, url: shareUrl, text: shareText || '', image: shareImage || null, kind: 'page', id: page.id }))}`);
return html`${crumbs(trail)}
return html`${crumbs(trail)}
<article class="wk-article" data-page-id="${page.id}" data-revision="${String(rev.number)}">
<article class="wk-article" data-page-id="${page.id}" data-revision="${String(rev.number)}">
<header><h1>${title}</h1><nav class="wk-actions" aria-label="Page actions">${actions.map((a, i) => html`${i ? ' · ' : ''}${a}`)}</nav>${pageFacts(v, base)}</header>
<header><h1>${title}</h1><nav class="wk-actions" aria-label="Page actions">${actions.map((a, i) => html`${i ? ' · ' : ''}${a}`)}</nav>${pageFacts(v, base)}</header>
@@ -578,11 +579,12 @@ ${roles.map((r) => html`<tr><td><code>${r.subject}</code></td><td>${r.role}</td>
@@ -578,11 +579,12 @@ ${roles.map((r) => html`<tr><td><code>${r.subject}</code></td><td>${r.role}</td>
</section>`;
</section>`;
}
}
-function pageSettingsPage({ space, page, v, parents, error = null, flash = null, canManage }) {
+function pageSettingsPage({ space, page, v, parents, error = null, flash = null, canManage, shareUrl = '' }) {
const base = wpath(space, page);
const base = wpath(space, page);
return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: page.title, url: base }, { name: 'Settings' }])}
return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: page.title, url: base }, { name: 'Settings' }])}
<section><h1>Settings of ${page.title}</h1>${error ? notice('error', error) : ''}${flash ? notice('ok', flash) : ''}
<section><h1>Settings of ${page.title}</h1>${error ? notice('error', error) : ''}${flash ? notice('ok', flash) : ''}
<p>State: <strong>${page.state}</strong>${page.published_revision ? html` · published revision ${String(page.published_revision)}` : ''} · visibility ${page.visibility} (wiki: ${space.visibility})</p>
<p>State: <strong>${page.state}</strong>${page.published_revision ? html` · published revision ${String(page.published_revision)}` : ''} · visibility ${page.visibility} (wiki: ${space.visibility})</p>
+<p class="wk-share">${raw(kit.share({ title: page.title, url: shareUrl, kind: 'page', id: page.id }))}</p>
<h2>Publish</h2>
<h2>Publish</h2>
<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="publish">
<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="publish">
<label>Revision <input type="number" name="revision" min="1" value="${String(v.headNumber)}"></label>
<label>Revision <input type="number" name="revision" min="1" value="${String(v.headNumber)}"></label>
@@ -608,8 +610,9 @@ ${v.attachments.length ? html`<ul>${v.attachments.map((a) => html`<li><code>${a.
@@ -608,8 +610,9 @@ ${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', 'vip', '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') : ''}>${x === 'public' ? 'Everyone' : x === 'private' ? 'Only me' : 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 class="wk-muted">The Share button above offers the same four choices for this one page, and can name specific people (“look at this before I publish it”).</p>
<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>` : ''}
</section>`;
</section>`;
@@ -667,13 +670,30 @@ ${items.length ? html`<ol class="wk-recent">${items.map((r) => html`<li><a href=
@@ -667,13 +670,30 @@ ${items.length ? html`<ol class="wk-recent">${items.map((r) => html`<li><a href=
</section>`;
</section>`;
}
}
+/**
+ * The page a person who is not on the list sees (design 2026-11-06): the audience, never the title,
+ * the text, the pictures or the history. OVShare.gate offers "Ask for access" when signed in.
+ */
+function sharedGateBody({ id, signedInAs = null, signInUrl = '/auth/login' }) {
+ return html`<section class="wk-message">
+<h1>A page on OpenVibe.Wiki</h1>
+<p>This is shared with specific people.</p>
+<div id="share-gate" data-ov-share-gate data-kind="page" data-id="${String(id)}" data-signed-in-as="${signedInAs || ''}" data-sign-in-url="${signInUrl}">
+<p>This page is shared with specific people on OpenVibe.Wiki.</p>
+<p><a class="wk-button" href="${signInUrl}">Sign in</a></p>
+</div>
+<link rel="stylesheet" href="${ovServe.url('ov-share.css')}">
+<script src="${ovServe.url('ov-share.js')}" defer></script>
+</section>`;
+}
+
function signInPage({ next, message }) {
function signInPage({ next, message }) {
return html`<section><h1>Sign in</h1><p>${message}</p><p><a class="wk-button" href="/auth/login?next=${e(next || '/')}">Sign in with OpenVibe</a></p></section>`;
return html`<section><h1>Sign in</h1><p>${message}</p><p><a class="wk-button" href="/auth/login?next=${e(next || '/')}">Sign in with OpenVibe</a></p></section>`;
}
}
module.exports = {
module.exports = {
vibePage, vibeProgress, vibeForm,
vibePage, vibeProgress, vibeForm,
- vipGateBody,
+ vipGateBody, sharedGateBody,
home, homeJsonLd, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
home, homeJsonLd, 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,
new file mode 100644
new file mode 100644
@@ -0,0 +1,202 @@
@@ -0,0 +1,202 @@
+'use strict';
+
+/**
+ * Sharing for Wiki pages and drafts (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; openvibe-sdk/share).
+ *
+ * The SDK owns the model and the rules: the `shares` row next to each page, the one access check
+ * (canView), members, access requests, transfers, the append-only log and the delivery queue. This
+ * file is the wiring a service supplies:
+ *
+ * - network.json Network's identity lookups (@name → subject, and a person's card for the sheet)
+ * - events.publish Wiki's own Events outbox, so share.* envelopes go out with every other wiki.* event
+ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
+ * - onAudienceChange keeps wiki_pages.visibility in step (public→public, link→unlisted, people→private,
+ * private→private), so an old reader that only knows the column fails closed, and
+ * re-syncs Search so a page that leaves Everyone is tombstoned at once
+ * - onOwnerChange moves wiki_pages.created_by to the new owner
+ *
+ * A draft is registered private (Only me). The sheet on the draft can name people ("look at this
+ * before I publish it"); the page at /w/:space/:slug is the link, and it answers with the gate,
+ * never the text, to anyone not on the list. `members` (OpenVibe.VIP) is the fifth audience that
+ * already existed on Blog; the sheet never offers it, and canReadPost keeps deciding it the old way.
+ */
+const { createShares } = require('openvibe-sdk/share');
+const { serviceAuth } = require('openvibe-contracts');
+
+const SERVICE = 'wiki';
+const KIND = 'page';
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+/** Wiki's visibility words to the SDK's, and back. `members` and `vip` are not share audiences. */
+const TO_SDK = { public: 'public', members: 'private', vip: 'private', private: 'private' };
+const FROM_SDK = { public: 'public', link: 'private', people: 'private', private: 'private' };
+
+const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
+
+/** Blog's viewer as the SDK wants it: a subject object, and the staff bit. */
+function shareViewer(v) {
+ if (!v || !v.subject) return null;
+ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };
+ if (String(v.subject).startsWith('gst_')) return { subject: { type: 'guest', id: v.subject }, staff: !!v.staff };
+ return null;
+}
+
+function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
+ let reindex = null;
+ const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
+ const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
+ tokenUrl: `${base}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network',
+ scope: 'identity.subject.resolve network.notifications.push',
+ fetchImpl,
+ }) : null;
+
+ async function networkJson({ path, method = 'GET', json, query } = {}) {
+ if (!tokens) throw new Error('Network is not configured for this service');
+ const url = new URL(`${base}${path}`);
+ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
+ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
+ if (json !== undefined) headers['Content-Type'] = 'application/json';
+ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
+ const body = await res.json().catch(() => null);
+ if (!res.ok) {
+ const err = new Error(`Network ${method} ${path} ${res.status}: ${(body && (body.detail || body.error)) || 'failed'}`);
+ err.status = res.status;
+ throw err;
+ }
+ return body;
+ }
+
+ /** One notification, addressed by canonical subject. It never carries a page's title or text. */
+ async function notify(n) {
+ const to = n && n.to;
+ if (!to || to.type !== 'user') return;
+ const words = {
+ 'share.added': ['A wiki page was shared with you', 'Open it on OpenVibe.Wiki.'],
+ 'share.access_requested': ['Someone asked for access to your page', 'Review the request on OpenVibe.Wiki.'],
+ 'share.transfer.requested': ['Someone wants to give you a page', 'Accept or decline it on OpenVibe.Wiki.'],
+ 'share.transfer.answered': ['A page transfer was answered', 'See what happened on OpenVibe.Wiki.'],
+ }[n.kind] || ['Sharing on OpenVibe.Wiki', 'There is something new for you.'];
+ try {
+ await networkJson({
+ path: '/internal/notifications/push', method: 'POST',
+ json: {
+ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
+ service: 'wiki', url: config.baseUrl,
+ category: 'social',
+ },
+ });
+ } catch (err) {
+ log.warn('[Sharing] notification not sent:', err.message);
+ }
+ }
+
+ /** The row's audience change keeps the old column in step and re-indexes (a no-op when already there). */
+ async function onAudienceChange(ref, audience, tx) {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return;
+ const row = await tx.maybe('SELECT state, visibility FROM wiki_pages WHERE id = $1', [ref.id]);
+ if (!row) return;
+ const visibility = FROM_SDK[audience] || 'private';
+ // `members` and `vip` have no share audience: leave those columns alone (canReadPage keeps deciding them).
+ if (!(row.visibility === 'members' || row.visibility === 'vip')) {
+ await tx.exec('UPDATE wiki_pages SET visibility = $1, updated_at = $3 WHERE id = $2', [visibility, ref.id, now()]);
+ }
+ tx.afterCommit(async () => {
+ try { if (reindex) await reindex(ref.id); }
+ catch (err) { log.warn('[Sharing] search sync:', err.message); }
+ });
+ }
+
+ async function onOwnerChange(ref, from, to, tx) {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return;
+ const owner = to && to.type === 'user' ? to.id : null;
+ await tx.exec('UPDATE wiki_pages SET created_by = $1, updated_at = $3 WHERE id = $2', [owner, ref.id, now()]);
+ }
+
+ const shares = createShares({
+ db,
+ network: { json: networkJson },
+ events: { publish: async (envelope) => await outbox.emit(envelope) },
+ notify,
+ onAudienceChange,
+ onOwnerChange,
+ });
+
+ const sv = (viewer) => shareViewer(viewer);
+ const at = (id) => refOf(id);
+
+ return {
+ shares,
+ SERVICE,
+ KIND,
+ AUDIENCES,
+ refOf,
+ shareViewer,
+ /** Wiki's own indexer, attached by the wiki service once it exists (onAudienceChange calls it after the commit). */
+ attach: (fn) => { reindex = fn; },
+ /** Register a page with the sharing system (inside the page's own transaction). */
+ register: async (viewer, id, audience = 'private') => await shares.register(at(id), {
+ owner_subject: viewer && viewer.subject ? { type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject } : { type: 'service', id: SERVICE },
+ audience: TO_SDK[audience] || 'private',
+ }),
+ /**
+ * The service's own audience move (a create form, the editor's select, a publish). The caller
+ * has already passed access.canWrite and owns the column and the Search sync; this only keeps
+ * the share row in step. The SDK's owner check is for people using the sheet.
+ */
+ syncAudience: async (id, audience) => {
+ const next = TO_SDK[audience] || 'private';
+ await db.tx(async (t) => {
+ const row = await t.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3 FOR UPDATE', [SERVICE, KIND, String(id)]);
+ if (!row || row.audience === next) return;
+ await t.exec('UPDATE shares SET audience = $1, revision = revision + 1 WHERE service = $2 AND kind = $3 AND resource_id = $4', [next, SERVICE, KIND, String(id)]);
+ });
+ },
+ shareOf: async (id) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]),
+ canView: async (viewer, id) => await shares.canView(sv(viewer), at(id)),
+ canEdit: async (viewer, id) => await shares.canEdit(sv(viewer), at(id)),
+ get: async (viewer, id) => await shares.get(sv(viewer), at(id)),
+ getWithRevision: async (viewer, id) => await shares.getWithRevision(sv(viewer), at(id)),
+ setAudience: async (viewer, id, audience) => await shares.setAudience(sv(viewer), at(id), TO_SDK[audience] || 'private'),
+ addMembers: async (viewer, id, list) => await shares.addMembers(sv(viewer), at(id), list),
+ removeMember: async (viewer, id, subject) => await shares.removeMember(sv(viewer), at(id), subject),
+ update: async (viewer, id, body, opts) => await shares.update(sv(viewer), at(id), body, opts),
+ requestAccess: async (viewer, id) => await shares.requestAccess(sv(viewer), at(id)),
+ dismiss: async (viewer, id, requestId) => await shares.dismiss(sv(viewer), at(id), requestId),
+ transfer: async (viewer, id, input) => await shares.transfer(sv(viewer), at(id), input),
+ accept: async (viewer, id, transferId) => await shares.accept(sv(viewer), at(id), transferId),
+ decline: async (viewer, id, transferId) => await shares.decline(sv(viewer), at(id), transferId),
+ cancel: async (viewer, id, transferId) => await shares.cancel(sv(viewer), at(id), transferId),
+ lookup: async (viewer, name) => await shares.lookup(sv(viewer), name),
+ sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(sv(viewer), { service: SERVICE, ...(opts || {}) }),
+ /** Expire members and transfers, then send queued events and notifications. */
+ work: async ({ flush = true } = {}) => {
+ let expired = 0;
+ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }
+ let sent = 0;
+ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }
+ return { expired, sent };
+ },
+ };
+}
+
+/** The uniform API's describe(ref): the sheet shows the page's title and link to its owner. */
+function describeRef(db, config) {
+ return async (ref) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
+ const page = await db.maybe(`SELECT p.id, p.slug, p.title, p.state, s.slug AS space_slug
+ FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id WHERE p.id = $1 AND p.state <> 'deleted'`, [ref.id]);
+ if (!page) return {};
+ const link = `/w/${page.space_slug}/${page.slug}`;
+ return {
+ title: page.title || 'Untitled',
+ link: `${String(config.baseUrl || '').replace(/\/+$/, '')}${link}`,
+ roles: ['viewer'],
+ audiences: AUDIENCES,
+ transferable: true,
+ };
+ };
+}
+
+module.exports = { createSharing, describeRef, shareViewer, refOf, AUDIENCES, TO_SDK, FROM_SDK, SERVICE, KIND };
@@ -47,6 +47,27 @@ function createAccess(db, { vip = null } = {}) {
@@ -47,6 +47,27 @@ function createAccess(db, { vip = null } = {}) {
const rolesOfSubject = db.prepare('SELECT space_id, role FROM wiki_permissions WHERE subject = ?');
const rolesOfSubject = db.prepare('SELECT space_id, role FROM wiki_permissions WHERE subject = ?');
const rolesOfSpace = db.prepare('SELECT subject, role FROM wiki_permissions WHERE space_id = ?');
const rolesOfSpace = db.prepare('SELECT subject, role FROM wiki_permissions WHERE space_id = ?');
+ /**
+ * The pages this actor may read because somebody shared that one page with them (openvibe-sdk/share):
+ * its owner, a member of its people list, or anybody when the row says public/link. Loaded with the
+ * roles, so canReadPage stays synchronous and a tree filter never races an ACL write.
+ */
+ async function loadShares(actor) {
+ if (!actor || !actor.subject || actor.wikiShares) return actor;
+ const subject = JSON.stringify({ type: /^gst_/.test(String(actor.subject)) ? 'guest' : 'user', id: actor.subject });
+ // A public/link row only counts inside a public space: a page can narrow its space's audience, never widen it.
+ const rows = await db.many(`SELECT resource_id FROM shares
+ WHERE service = 'wiki' AND kind = 'page' AND owner_subject = $1::jsonb
+ UNION SELECT resource_id FROM share_members
+ WHERE service = 'wiki' AND kind = 'page' AND subject = $1::jsonb AND (expires_at IS NULL OR expires_at > now())
+ UNION SELECT sh.resource_id FROM shares sh
+ JOIN wiki_pages p ON p.id = sh.resource_id
+ JOIN wiki_spaces s ON s.id = p.space_id
+ WHERE sh.service = 'wiki' AND sh.kind = 'page' AND sh.audience IN ('public','link') AND s.visibility = 'public'`, [subject]);
+ Object.defineProperty(actor, 'wikiShares', { value: new Set(rows.map((r) => r.resource_id)), writable: true, configurable: true, enumerable: false });
+ return actor;
+ }
+
/** Read (or re-read with fresh: true) every role the actor holds; idempotent and cheap to call again. */
/** Read (or re-read with fresh: true) every role the actor holds; idempotent and cheap to call again. */
async function loadRoles(actor, { fresh = false } = {}) {
async function loadRoles(actor, { fresh = false } = {}) {
if (!actor || actor.kind === 'system' || !actor.subject) return actor;
if (!actor || actor.kind === 'system' || !actor.subject) return actor;
@@ -93,6 +114,7 @@ function createAccess(db, { vip = null } = {}) {
@@ -93,6 +114,7 @@ function createAccess(db, { vip = null } = {}) {
VISIBILITIES,
VISIBILITIES,
role,
role,
loadRoles,
loadRoles,
+ loadShares,
subjectActors,
subjectActors,
isPerson,
isPerson,
effectiveVisibility,
effectiveVisibility,
@@ -103,6 +125,9 @@ function createAccess(db, { vip = null } = {}) {
@@ -103,6 +125,9 @@ function createAccess(db, { vip = null } = {}) {
/** 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;
+ // A share row for this one page opens it before the space's own visibility: that is what
+ // "share this page with @x" means, and for a draft it is the only way in.
+ if (actor && actor.wikiShares && actor.wikiShares.has(page.id)) return true;
if (!canReadVisibility(space, effectiveVisibility(space, page), actor, page)) 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);
@@ -33,6 +33,23 @@ const TABLES = [
@@ -33,6 +33,23 @@ const TABLES = [
{ table: 'wiki_page_reviews', subject: 'reviewer', file: 'reviews.json', columns: ['entity_id', 'revision', 'decision', 'note', 'reviewed_at'], order: 'reviewed_at', erase: { keep: 'a person\'s approval is what lets reviewed text stay published' } },
{ table: 'wiki_page_reviews', subject: 'reviewer', file: 'reviews.json', columns: ['entity_id', 'revision', 'decision', 'note', 'reviewed_at'], order: 'reviewed_at', erase: { keep: 'a person\'s approval is what lets reviewed text stay published' } },
];
];
+
+/** The sharing system's rows (openvibe-sdk/share): jsonb subjects, so the SDK's table map cannot name them. */
+async function shareExport(db, subject) {
+ const owned = await db.many("SELECT * FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['wiki', subject]);
+ const member = await db.many("SELECT * FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['wiki', subject]);
+ const asked = await db.many("SELECT * FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['wiki', subject]);
+ return owned.length || member.length || asked.length ? [{ name: 'shares.json', content: { owned, member_of: member, requests: asked } }] : [];
+}
+async function shareErase(t, subjects) {
+ for (const subject of subjects) {
+ await t.exec("DELETE FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['wiki', subject]);
+ await t.exec("DELETE FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['wiki', subject]);
+ await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['wiki', subject]);
+ await t.exec("DELETE FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['wiki', subject]);
+ }
+}
+
async function extraExport(db, subject) {
async function extraExport(db, subject) {
const files = [];
const files = [];
const spaces = await db.many(`SELECT id, slug, name, description, kind, visibility, created_at, updated_at, deleted_at FROM wiki_spaces
const spaces = await db.many(`SELECT id, slug, name, description, kind, visibility, created_at, updated_at, deleted_at FROM wiki_spaces
@@ -47,12 +64,14 @@ async function extraExport(db, subject) {
@@ -47,12 +64,14 @@ async function extraExport(db, subject) {
const proposals = await db.many(`SELECT id, page_id, space_id, revision, status, note, created_at FROM wiki_ai_proposals
const proposals = await db.many(`SELECT id, page_id, space_id, revision, status, note, created_at FROM wiki_ai_proposals
WHERE proposed_by = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
WHERE proposed_by = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
if (proposals.length) files.push({ name: 'ai-proposals.json', content: proposals });
if (proposals.length) files.push({ name: 'ai-proposals.json', content: proposals });
+ files.push(...await shareExport(db, subject));
return files;
return files;
}
}
/** The account-data handle over Wiki's database and service (service.deleteSpace for the person's own spaces). */
/** The account-data handle over Wiki's database and service (service.deleteSpace for the person's own spaces). */
function create({ db, svc, log = console } = {}) {
function create({ db, svc, log = console } = {}) {
async function extraErase(t, subjects, counts) {
async function extraErase(t, subjects, counts) {
+ await shareErase(t, subjects);
// Their own user spaces go the way their owner would delete them (a savepoint inside this transaction).
// Their own user spaces go the way their owner would delete them (a savepoint inside this transaction).
const spaces = await t.many("SELECT id FROM wiki_spaces WHERE kind = 'user' AND owner = ANY($1::text[]) AND deleted_at IS NULL", [subjects]);
const spaces = await t.many("SELECT id FROM wiki_spaces WHERE kind = 'user' AND owner = ANY($1::text[]) AND deleted_at IS NULL", [subjects]);
for (const s of spaces) await svc.deleteSpace(s.id, SYSTEM);
for (const s of spaces) await svc.deleteSpace(s.id, SYSTEM);
@@ -84,13 +84,13 @@ function disclosureFor(rec, review) {
@@ -84,13 +84,13 @@ function disclosureFor(rec, review) {
};
};
}
}
-function createWikiService({ db, stores, outbox, config, community = null, vip = null, now = () => Date.now(), log = console, indexnow = null }) {
+function createWikiService({ db, stores, outbox, config, community = null, vip = null, now = () => Date.now(), log = console, indexnow = null, sharing = null }) {
const { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;
const { revisions, citations, redirects, reviews, attachments, discussions, scheduler, sequencer } = stores;
const access = createAccess(db, { vip });
const access = createAccess(db, { vip });
const origin = config.baseUrl;
const origin = config.baseUrl;
const policy = config.gate;
const policy = config.gate;
// Every entry point reads the actor's roles again (one indexed query): a role granted a moment ago counts.
// Every entry point reads the actor's roles again (one indexed query): a role granted a moment ago counts.
- const roles = (actor, opts = { fresh: true }) => access.loadRoles(actor, opts);
+ const roles = async (actor, opts = { fresh: true }) => { await access.loadRoles(actor, opts); await access.loadShares(actor); return actor; };
// ── Queries (PostgreSQL; async prepared statements via openvibe-sdk/db prepare) ──
// ── Queries (PostgreSQL; async prepared statements via openvibe-sdk/db prepare) ──
const q = {
const q = {
@@ -510,6 +510,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -510,6 +510,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
await writeRevisionExtras(space, page, revision, box);
await writeRevisionExtras(space, page, revision, box);
await attachCitationList(page, revision.number, cites, actor);
await attachCitationList(page, revision.number, cites, actor);
await revisionEvent(space, page, revision, actor);
await revisionEvent(space, page, revision, actor);
+ // A new page is a draft: Only me until its author shares it or publishes it.
+ if (sharing) await sharing.register(actor, id, 'private');
if (/^usr_/.test(actorId(actor))) await q.watch.run(id, actorId(actor), t);
if (/^usr_/.test(actorId(actor))) await q.watch.run(id, actorId(actor), t);
return { page, revision };
return { page, revision };
});
});
@@ -526,6 +528,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -526,6 +528,8 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
}
}
const b = await before(page);
const b = await before(page);
await q.setPublished.run({ n: revisionNumber, title: rev.fields.title || page.title, t: now(), id: page.id });
await q.setPublished.run({ n: revisionNumber, title: rev.fields.title || page.title, t: now(), id: page.id });
+ // Publishing widens a draft to the visibility its author chose (the Share sheet keeps the column in step).
+ if (sharing) await sharing.syncAudience(page.id, page.visibility);
return await sync(b, page.id, actor);
return await sync(b, page.id, actor);
}
}
@@ -536,6 +540,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -536,6 +540,13 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
*/
*/
async function revisionVisible(space, page, n, actor, proposal) {
async function revisionVisible(space, page, n, actor, proposal) {
if (access.canEdit(space, actor)) return true;
if (access.canEdit(space, actor)) return true;
+ // A shared *draft* opens at its head revision. A published page still shows only its published revision
+ // to a reader who is not an editor, however it was shared.
+ if (page.state !== 'published' && actor && actor.wikiShares && actor.wikiShares.has(page.id)) return true;
+ if (sharing && page.state !== 'published') {
+ const row = await sharing.shareOf(page.id).catch(() => null);
+ if (row && (row.audience === 'public' || row.audience === 'link')) return true;
+ }
if (page.state !== 'published' || !page.published_revision || n > page.published_revision) return false;
if (page.state !== 'published' || !page.published_revision || n > page.published_revision) return false;
const p = proposal === undefined ? await q.proposalByRev.get(page.id, n) : proposal;
const p = proposal === undefined ? await q.proposalByRev.get(page.id, n) : proposal;
return !p || p.status === 'approved';
return !p || p.status === 'approved';
@@ -1000,8 +1011,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1000,8 +1011,10 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space changes page visibility');
if (!access.canManage(space, actor)) fail(403, 'page.forbidden', 'Only an owner of this space changes page visibility');
return await tx(async () => {
return await tx(async () => {
const b = await before(page);
const b = await before(page);
+ const nextVisibility = checkVisibility(visibility, page.visibility, space);
await db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')
await db.prepare('UPDATE wiki_pages SET visibility = ?, noindex = ?, updated_at = ? WHERE id = ?')
- .run(checkVisibility(visibility, page.visibility, space), noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);
+ .run(nextVisibility, noindex == null ? page.noindex : (noindex ? 1 : 0), now(), page.id);
+ if (sharing) await sharing.syncAudience(page.id, nextVisibility);
const out = await sync(b, page.id, actor);
const out = await sync(b, page.id, actor);
return { page: await q.pageById.get(page.id), ...out };
return { page: await q.pageById.get(page.id), ...out };
});
});
@@ -1517,6 +1530,16 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1517,6 +1530,16 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
},
},
};
};
+ /** The Share sheet moved a page's audience: send Search what it should hold (a tombstone when it leaves Everyone). */
+ async function reindex(pageId) {
+ await tx(async () => {
+ const page = await q.pageById.get(pageId);
+ if (!page) return;
+ await sync(await before(page), pageId, null);
+ });
+ }
+ if (sharing) sharing.attach(reindex);
+
return svc;
return svc;
}
}
@@ -50,6 +50,8 @@ async function check(name, fn) {
@@ -50,6 +50,8 @@ async function check(name, fn) {
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Wiki applied',
account_data_events: 'the ledger of requests itself: which export and deletion Wiki applied',
wiki_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",
wiki_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",
+ share_members: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
+ share_access_requests: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
},
},
});
});
if (missing.length) { failures++; console.log(` ✗ every table that names a person is covered or exempted with a reason\n ${missing.join(', ')}`); }
if (missing.length) { failures++; console.log(` ✗ every table that names a person is covered or exempted with a reason\n ${missing.join(', ')}`); }
@@ -87,7 +89,7 @@ async function check(name, fn) {
@@ -87,7 +89,7 @@ async function check(name, fn) {
const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
assert.strictEqual(part.auth, 'Bearer tok_wiki');
assert.strictEqual(part.auth, 'Bearer tok_wiki');
const names = part.body.files.map((f) => f.name).sort();
const names = part.body.files.map((f) => f.name).sort();
- assert.deepStrictEqual(names, ['pages.json', 'revisions.json', 'roles.json', 'spaces.json', 'watching.json']);
+ assert.deepStrictEqual(names, ['pages.json', 'revisions.json', 'roles.json', 'shares.json', 'spaces.json', 'watching.json']);
assert.ok(!JSON.stringify(part.body).includes(bob.subject));
assert.ok(!JSON.stringify(part.body).includes(bob.subject));
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+'use strict';
+/**
+ * Sharing on Wiki pages and drafts (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; the pilot is
+ * Community's pastes, the same openvibe-sdk/share). A new page is Only me; the four audiences are
+ * enforced by canView on the page; a page that leaves Everyone leaves the sitemap and the feeds at
+ * once; publishing a draft widens it to the visibility its author chose.
+ */
+const assert = require('assert');
+const H = require('./helpers');
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }
+}
+
+(async () => {
+ const people = new Map();
+ /** Network's two identity routes plus the notification push, without a socket. */
+ const fetchImpl = async (url, opts = {}) => {
+ const u = new URL(String(url));
+ const json = (status, body) => ({ ok: status < 400, status, json: async () => body });
+ if (u.pathname === '/oauth/token') return json(200, { access_token: 'stub-token', token_type: 'Bearer', expires_in: 300 });
+ if (u.pathname === '/internal/identity/resolve-batch') {
+ const b = JSON.parse(opts.body || '{}');
+ const results = {};
+ for (const id of b.subject_ids || []) results[id] = people.get(id) || null;
+ return json(200, { results });
+ }
+ if (u.pathname === '/internal/identity/resolve') {
+ const name = (u.searchParams.get('username') || '').toLowerCase();
+ for (const p of people.values()) if (p.username.toLowerCase() === name) return json(200, p);
+ return json(404, { error: 'no such person' });
+ }
+ if (u.pathname === '/internal/notifications/push') return json(202, { queued: true });
+ throw new Error(`unexpected outbound fetch ${url}`);
+ };
+ const h = await H.boot({ env: { OV_NETWORK_INTERNAL_URL: 'https://network.test', OV_OAUTH_CLIENT_SECRET: 'shh' }, fetch: fetchImpl });
+ const alice = { subject: H.subject(), username: 'alice' };
+ const bob = { subject: H.subject(), username: 'bob' };
+ const sam = { subject: H.subject(), username: 'sam' };
+ for (const p of [alice, bob, sam]) people.set(p.subject, { subject: { type: 'user', id: p.subject }, username: p.username, display_name: p.username, avatar_url: null, banned: false });
+ const tok = (u) => H.userToken({ subject: u.subject, username: u.username, aud: ['openvibe.network', 'openvibe.wiki'] });
+ const cookies = (u) => ({ cookie: H.cookieFor(tok(u)) });
+
+ await H.req(h, 'POST', '/api/v1/spaces', { token: tok(alice), body: { slug: 'handbook', name: 'Handbook', kind: 'user', visibility: 'public' } });
+ const make = async (title = 'Draft notes') => {
+ const r = await H.req(h, 'POST', '/api/v1/spaces/handbook/pages', { token: tok(alice), body: { title, body: `${H.LONG} [1]`, visibility: 'public', citations: [{ url: 'https://example.com/source', title: 'A source', retrievedAt: '2026-11-06T00:00:00Z' }] } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json.page;
+ };
+ const share = (id, opts = {}) => H.req(h, opts.method || 'GET', `/api/v1/page/${id}/share`, { headers: { 'x-requested-with': 'ov-share' }, ...opts });
+ const putShare = (id, body, u = alice) => share(id, { method: 'PUT', token: tok(u), body });
+ const row = async (id) => await h.db.prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('wiki', 'page', id);
+
+ await check('a new page is registered Only me, owned by its author; only its author reads the draft', async () => {
+ const p = await make();
+ const s = await row(p.id);
+ assert.strictEqual(s.audience, 'private');
+ assert.deepStrictEqual(s.owner_subject, { type: 'user', id: alice.subject });
+ const url = `/w/handbook/${p.slug}`;
+ assert.strictEqual((await H.req(h, 'GET', url, { token: tok(alice) })).status, 200);
+ assert.strictEqual((await H.req(h, 'GET', url, { token: tok(bob) })).status, 404);
+ assert.strictEqual((await H.req(h, 'GET', url)).status, 404);
+ });
+
+ await check('Specific people: the named person reads the draft; a stranger gets the gate and no title', async () => {
+ const p = await make('Board pack');
+ const put = await putShare(p.id, { audience: 'people', members: [{ subject: { type: 'user', id: bob.subject }, role: 'viewer' }] });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.strictEqual(put.json.audience, 'people');
+ assert.strictEqual((await h.db.prepare('SELECT visibility FROM wiki_pages WHERE id = ?').get(p.id)).visibility, 'private');
+ const url = `/w/handbook/${p.slug}`;
+ const member = await H.req(h, 'GET', url, { token: tok(bob) });
+ assert.strictEqual(member.status, 200);
+ assert.ok(member.text.includes(H.LONG.slice(0, 40)), 'the body');
+ const stranger = await H.req(h, 'GET', url, { token: tok(sam) });
+ assert.strictEqual(stranger.status, 404);
+ assert.ok(stranger.text.includes('shared with specific people'), 'OVShare.gate');
+ assert.ok(stranger.text.includes('ov-share.js') && stranger.text.includes('data-ov-share-gate'));
+ assert.ok(!stranger.text.includes('Board pack'), 'no title');
+ const anon = await H.req(h, 'GET', url);
+ assert.strictEqual(anon.status, 404);
+ assert.ok(anon.text.includes('/auth/login?next='), 'sign in');
+ assert.strictEqual((await H.req(h, 'GET', `/api/v1/page/${p.id}/share`, { token: tok(sam) })).status, 404, 'the sheet is the owner’s');
+ });
+
+ await check('Everyone and the link: a shared draft opens by its address, still not listed', async () => {
+ const p = await make('Everyone draft');
+ assert.strictEqual((await putShare(p.id, { audience: 'public' })).status, 200);
+ assert.strictEqual((await H.req(h, 'GET', `/w/handbook/${p.slug}`)).status, 200);
+ assert.strictEqual((await putShare(p.id, { audience: 'link' })).status, 200);
+ assert.strictEqual((await H.req(h, 'GET', `/w/handbook/${p.slug}`)).status, 200, 'the link is the key');
+ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes(`/w/handbook/${p.slug}`), 'a draft is never in the sitemap');
+ });
+
+ await check('publishing widens a draft to its author’s chosen visibility; leaving Everyone removes it at once', async () => {
+ const p = await make('Ready to ship');
+ assert.strictEqual((await row(p.id)).audience, 'private');
+ const pub = await H.req(h, 'POST', `/api/v1/pages/${p.id}/publish`, { token: tok(alice), body: {} });
+ assert.strictEqual(pub.status, 200, pub.text);
+ assert.strictEqual((await row(p.id)).audience, 'public');
+ const url = `/w/handbook/${p.slug}`;
+ assert.strictEqual((await H.req(h, 'GET', url)).status, 200);
+ assert.ok((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes(url), 'a public page is in the sitemap');
+ const put = await putShare(p.id, { audience: 'private' });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.strictEqual((await H.req(h, 'GET', url)).status, 404);
+ assert.ok(!(await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.includes(url), 'and out of the sitemap');
+ assert.strictEqual((await H.req(h, 'GET', url, { token: tok(alice) })).status, 200, 'the owner still reads it');
+ const events = await H.outbox(h);
+ const tombstones = events.filter((e) => /^wiki\.index_document\.deleted$/.test(e.event_type));
+ assert.ok(tombstones.some((e) => JSON.stringify(e.payload || {}).includes(p.id)), 'a search tombstone went out');
+ });
+
+ await check('the members list is shown to the owner and the sheet refuses a non-owner', async () => {
+ const p = await make('Two readers');
+ await putShare(p.id, { audience: 'people', members: [{ subject: { type: 'user', id: sam.subject }, role: 'viewer' }] });
+ const mine = await share(p.id, { token: tok(alice) });
+ assert.strictEqual(mine.status, 200);
+ assert.strictEqual(mine.json.members[0].subject.id, sam.subject);
+ assert.strictEqual(mine.json.you.role, 'owner');
+ const theirs = await share(p.id, { token: tok(bob) });
+ assert.strictEqual(theirs.status, 404);
+ });
+
+ console.log(failures ? `\n${failures} failed` : '\nall passed');
+ await h.stop();
+ process.exit(failures ? 1 : 0);
+})();
@@ -65,6 +65,7 @@ const INTERNAL = [
@@ -65,6 +65,7 @@ const INTERNAL = [
'server/integrations/platform.js': 'Community, Sources, Media, VIP, AI (configured)',
'server/integrations/platform.js': 'Community, Sources, Media, VIP, AI (configured)',
'server/integrations/projects-module.js': 'Network user modules (configured)',
'server/integrations/projects-module.js': 'Network user modules (configured)',
'server/integrations/image-fetch.js': 'pictures a generated wiki uses (research results): https only, openvibe-shared/egress safeLookup on every hop, a byte cap, magic bytes checked',
'server/integrations/image-fetch.js': 'pictures a generated wiki uses (research results): https only, openvibe-shared/egress safeLookup on every hop, a byte cap, magic bytes checked',
+ 'server/sharing.js': 'OpenVibe.Network (configured) for identity lookups and the notification push; never a URL a user chose',
};
};
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
const found = [];
const found = [];
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]):
+ * the page carries markup only, ov-share.js draws the gate, signed out it offers Sign in, and a
+ * signed-in stranger is offered Ask for access — no console error and no CSP refusal.
+ * Skipped (exit 0) without Chrome, or with OV_SKIP_BROWSER=1.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const H = require('./helpers');
+
+let playwright = null;
+try { playwright = require('playwright'); } catch {
+ try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }
+}
+const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';
+if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {
+ console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;
+let failures = 0;
+async function check(fn, name) {
+ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }
+}
+
+(async () => {
+ const people = new Map();
+ const fetchImpl = async (url, opts = {}) => {
+ const u = new URL(String(url));
+ const json = (status, body) => ({ ok: status < 400, status, json: async () => body });
+ if (u.pathname === '/oauth/token') return json(200, { access_token: 'stub-token', token_type: 'Bearer', expires_in: 300 });
+ if (u.pathname === '/internal/identity/resolve-batch') {
+ const b = JSON.parse(opts.body || '{}');
+ const results = {};
+ for (const id of b.subject_ids || []) results[id] = people.get(id) || null;
+ return json(200, { results });
+ }
+ if (u.pathname === '/internal/identity/resolve') {
+ const name = (u.searchParams.get('username') || '').toLowerCase();
+ for (const p of people.values()) if (p.username.toLowerCase() === name) return json(200, p);
+ return json(404, { error: 'no such person' });
+ }
+ if (u.pathname === '/internal/notifications/push') return json(202, { queued: true });
+ throw new Error(`unexpected outbound fetch ${url}`);
+ };
+ const h = await H.boot({ env: { OV_NETWORK_INTERNAL_URL: 'https://network.test', OV_OAUTH_CLIENT_SECRET: 'shh' }, fetch: fetchImpl });
+ const alice = { subject: H.subject(), username: 'alice' };
+ const bob = { subject: H.subject(), username: 'bob' };
+ const sam = { subject: H.subject(), username: 'sam' };
+ for (const p of [alice, bob, sam]) people.set(p.subject, { subject: { type: 'user', id: p.subject }, username: p.username, display_name: p.username, avatar_url: null, banned: false });
+ const tok = (u) => H.userToken({ subject: u.subject, username: u.username, aud: ['openvibe.network', 'openvibe.wiki'] });
+
+ await H.req(h, 'POST', '/api/v1/spaces', { token: tok(alice), body: { slug: 'handbook', name: 'Handbook', kind: 'user', visibility: 'public' } });
+ const made = await H.req(h, 'POST', '/api/v1/spaces/handbook/pages', { token: tok(alice), body: { title: 'Board pack', body: `${H.LONG} [1]`, visibility: 'public', citations: [{ url: 'https://example.com/source', title: 'A source', retrievedAt: '2026-11-06T00:00:00Z' }] } });
+ assert.strictEqual(made.status, 201, made.text);
+ const page = made.json.page;
+ const put = await H.req(h, 'PUT', `/api/v1/page/${page.id}/share`, { headers: { 'x-requested-with': 'ov-share' }, token: tok(alice), body: { audience: 'people', members: [{ subject: { type: 'user', id: bob.subject }, role: 'viewer' }] } });
+ assert.strictEqual(put.status, 200, put.text);
+ const url = `${h.base}/w/handbook/${page.slug}`;
+
+ let browser;
+ const errors = [];
+ const watch = (p) => {
+ p.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
+ p.on('pageerror', (e) => errors.push(String(e)));
+ };
+ const settled = async (p) => {
+ await p.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });
+ return await p.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());
+ };
+
+ try {
+ await check(async () => {
+ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
+ const ctx = await browser.newContext();
+ const p = await ctx.newPage();
+ watch(p);
+ const res = await p.goto(url, { waitUntil: 'load', timeout: 15000 });
+ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the page');
+ const text = await settled(p);
+ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);
+ const href = await p.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));
+ assert.match(href, /^\/auth\/login\?next=/);
+ await ctx.close();
+ }, 'headless Chrome: the gate draws itself, signed out, with Sign in');
+
+ await check(async () => {
+ const ctx = await browser.newContext();
+ await ctx.addCookies([{ name: 'ov_token', value: tok(sam), url: h.base }]);
+ const p = await ctx.newPage();
+ watch(p);
+ await p.goto(url, { waitUntil: 'load', timeout: 15000 });
+ const text = await settled(p);
+ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);
+ assert.ok(/@sam/.test(text), 'the gate names the account it is signed in as');
+ await ctx.close();
+ }, 'headless Chrome: a stranger is offered Ask for access');
+
+ await check(async () => {
+ assert.deepStrictEqual(errors.filter((e) => REFUSED.test(e)), []);
+ }, 'headless Chrome: no console error and no CSP refusal');
+ } finally {
+ if (browser) await browser.close().catch(() => {});
+ await h.stop(); await h.closeDb();
+ process.exit(failures ? 1 : 0);
+ }
+})();