openvibers/openvibe.wiki Commits

Wiki: the four audiences on pages and drafts (sharing step 4) (#223)

OP OpenVibers committed 6 days ago · 3e329f6592 · 18 files, +698 −25

* Wiki: the four audiences on pages and drafts (sharing step 4)

Every page gets an openvibe-sdk/share row next to it. A new page is Only me;
the Share sheet on the article and in Page settings offers Everyone, Anyone
with the link, Specific people and Only me. The actor carries the pages shared
with them, loaded with its roles, so canReadPage stays synchronous and a tree,
a feed or a sitemap never races an ACL write. Publishing widens a draft to the
visibility its author chose; leaving Everyone updates the column, removes the
page from the sitemap and sends Search a tombstone. The uniform API is mounted
at /api/v1/page/:id/share.

Migrations 0005 (the SDK's tables) and 0006 (the backfill of every page).

* Account export and deletion cover the share rows; keep drafts and members pages closed

* The share gate draws from markup alone (openvibe-shared 4.123.0), with a headless-Chrome check; MediaHub's cache and column follow the audience

* Merge remote-tracking branch 'origin/main' into feat/share-wiki

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

Changes

ddiff --git a/README.md b/README.md
iindex afae435..236acef 100644
---- a/README.md
++++ b/README.md
@@@ -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, PostgreSQL
events 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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 56635b6..78c9da8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/migrations/0005_sharing.sql b/migrations/0005_sharing.sql
nnew file mode 100644
iindex 0000000..2c9ce12
---- /dev/null
++++ b/migrations/0005_sharing.sql
@@@ -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;
++
ddiff --git a/migrations/0006_sharing_backfill.sql b/migrations/0006_sharing_backfill.sql
nnew file mode 100644
iindex 0000000..8539ee6
---- /dev/null
++++ b/migrations/0006_sharing_backfill.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 9332959..ea5649a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 31c0a4b..06ce98f 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/app.js b/server/app.js
iindex 0b153e6..fa358e0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, platfo
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.
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
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 6b3c342..661a822 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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, vi
notFound(req, res);
return null;
}
-- if (!svc.access.canReadPage(space, page, req.actor)) { if (page.state !== 'published' || !vipGate(req, res, space, page)) notFound(req, res); return null; }
++ 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, vi
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 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) => {
ddiff --git a/server/index.js b/server/index.js
iindex 00fcad3..3816ee8 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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(), fetch
async 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 };
ddiff --git a/server/render/views.js b/server/render/views.js
iindex aecf00e..b2e1e5f 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -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, 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>`);
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,
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..9ce58ca
---- /dev/null
++++ b/server/sharing.js
@@@ -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 };
ddiff --git a/server/wiki/access.js b/server/wiki/access.js
iindex de92167..9ab3602 100644
---- a/server/wiki/access.js
++++ b/server/wiki/access.js
@@@ -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);
ddiff --git a/server/wiki/account-data.js b/server/wiki/account-data.js
iindex 7d0cef4..862c2ec 100644
---- a/server/wiki/account-data.js
++++ b/server/wiki/account-data.js
@@@ -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_proposals
WHERE 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);
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex ea7794c..24ce4d1 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -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;
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 61fc629..4a417fc 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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));
});
ddiff --git a/test/page-sharing.test.js b/test/page-sharing.test.js
nnew file mode 100644
iindex 0000000..42e956b
---- /dev/null
++++ b/test/page-sharing.test.js
@@@ -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);
++})();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 4355265..1c01a12 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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 = [];
ddiff --git a/test/share-gate-chrome.test.js b/test/share-gate-chrome.test.js
nnew file mode 100644
iindex 0000000..f1f6be8
---- /dev/null
++++ b/test/share-gate-chrome.test.js
@@@ -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);
++ }
++})();