openvibers/openvibe.blog Commits
Blog: the four audiences on posts and drafts (sharing step 4) (#210)
Changes
@@@ -332,7 +332,7 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.4.1 (async PostgreSQL stores: revisions,schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), `openvibe-contracts`-- v0.97.0, `openvibe-shared` v4.122.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),++ v0.97.0, `openvibe-shared` v4.123.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),`openvibe-sdk` v0.91.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).- **OpenVibe.Network:**- SSO: the OAuth client `blog` is already seeded with redirect@@@ -543,6 +543,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.4.1<!-- versions:end -->@@@ -9,11 +9,11 @@"domain": "openvibe.blog","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.4.1","openvibe-contracts v0.289.1",-- "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,27 @@++-- phase: migrate++-- Every post that existed before sharing is registered once, so canView has a row for all of them.++--++-- state draft | scheduled | unpublished → private (Only me until the author says otherwise)++-- state published, visibility public → public (Everyone)++-- state published, visibility unlisted → link++-- state published, visibility members/private → private (members stays the VIP gate in the column)++--++-- The owner is the author's subject. A post with no usable author (an imported or AI post) gets Blog++-- itself, which nobody can sign in as. Deleted posts are not registered.++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)++SELECT++ 'blog', 'post', p.id,++ CASE++ WHEN p.author_subject LIKE 'usr\_%' THEN jsonb_build_object('type', 'user', 'id', p.author_subject)++ WHEN p.author_subject LIKE 'gst\_%' THEN jsonb_build_object('type', 'guest', 'id', p.author_subject)++ ELSE jsonb_build_object('type', 'service', 'id', 'blog')++ END,++ CASE++ WHEN p.state <> 'published' THEN 'private'++ WHEN p.visibility = 'public' THEN 'public'++ WHEN p.visibility = 'unlisted' THEN 'link'++ ELSE 'private'++ END++FROM blog_posts p++WHERE p.state <> 'deleted'++ON CONFLICT (service, kind, resource_id) DO NOTHING;@@@ -18,7 +18,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.4.1","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": {@@@ -1210,9 +1210,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",@@@ -30,7 +30,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.4.1","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": {@@@ -42,6 +42,7 @@ const { createDiscoveryRoutes } = require('./http/discovery');const { createActorLimits } = require('./http/actor-limits');const { createBlogReadiness } = require('./observability');const { createWorker } = require('./worker');++const { createSharing, describeRef } = require('./sharing');const { assetVersion } = require('./render/layout');const PUBLIC_DIR = path.join(__dirname, '..', 'public');@@@ -73,7 +74,10 @@ async function createApp(opts = {}) {});const blogs = createBlogs({ store, config });const publication = createPublication({ store, config, outbox, indexnow });-- const posts = createPosts({ store, blogs, publication, access, outbox, log });++ // Sharing (openvibe-sdk/share): the shares row next to each post, members, transfers and the one++ // access check every read path asks (domain/access.js canReadPost).++ const sharing = opts.sharing || createSharing({ db: store.db, config, outbox, publication, now: store.now, fetchImpl: opts.fetchImpl, log });++ const posts = createPosts({ store, blogs, publication, access, outbox, log, sharing });const people = createPeople({ store, config, fetchImpl });const community = createCommunity({ store, config, fetchImpl });const media = createMedia({ config, fetchImpl });@@@ -104,7 +108,7 @@ async function createApp(opts = {}) {posts.setPublishGate(patchNotes.gate);// The AI desks (server/desks/): digests from Sources' items and the satire desk, beside the blogs.const desks = opts.desks || require('./desks/desks').createDesks({ config, store, fetchImpl, log });-- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, patchNotes, desks, indexnow };++ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, patchNotes, desks, indexnow, sharing };const app = express();app.disable('x-powered-by');@@@ -213,6 +217,28 @@ async function createApp(opts = {}) {},}));++ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────++ // GET/PUT /api/v1/post/:id/share, .../share/requests, .../share/transfer[/accept|decline|cancel],++ // GET /api/v1/share/people?name=, GET /api/v1/shared-with-me. Authentication is Blog's own viewer;++ // the router answers only its own paths, so the rest of /api/v1 keeps its own 404.++ {++ const shareApi = express.Router();++ shareApi.use(viewers.middleware({ services: false }));++ shareApi.use(sharing.shares.router({++ service: 'blog',++ authenticate: (req) => sharing.shareViewer(req.viewer),++ describe: describeRef(store.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()));++ }++ // Expire members and transfers and send queued share events/notifications.++ if (opts.startSharingWorker !== false) {++ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);++ if (timer.unref) timer.unref();++ app.locals.sharingTimer = timer;++ }++// ── API ─────────────────────────────────────────────────// GET /openapi.json: the API document, generated from the table the /api/v1 router mounts (http/openapi.js).// Mounted before the API router, whose last layer answers 404 for anything it does not serve.@@@ -116,10 +116,27 @@ function createEntitlementChecker({ provider = 'none', check = null, vip = null* status 404 hides existence (drafts, private, deleted); 403 says "members only" (the caller shows* a teaser and a join link, never the body); `vip` is the entitlement answer's reason.*/--async function canReadPost(store, viewer, blog, post, entitlements) {++async function canReadPost(store, viewer, blog, post, entitlements, sharing = null) {if (!post || post.state === 'deleted') return { allowed: false, status: 404, reason: 'not_found' };if (blog.status !== 'active' && !isStaff(viewer)) return { allowed: false, status: 404, reason: 'blog_suspended' };const member = await memberCanSee(store, viewer, blog, post) || isStaff(viewer);++ // The share row is the authority for published and draft posts, except `members` (VIP), which keeps++ // the entitlement gate below. A row that says private/people denies everyone not on it, published or++ // not; the page never says whether the post exists. A row that says public/link falls through to the++ // column's own rules, which onAudienceChange keeps in step.++ if (sharing && post.visibility !== 'members') {++ const row = await sharing.shareOf(post.id);++ if (row) {++ if (member) return { allowed: true, status: 200, reason: 'member' };++ const allowed = await sharing.canView(viewer, post.id).catch(() => false);++ if (allowed) return { allowed: true, status: 200, reason: row.audience === 'public' ? 'public' : 'share' };++ if (post.state !== 'published') {++ // An unpublished post with people on its list shows the gate; every other draft is simply absent.++ return { allowed: false, status: 404, reason: row.audience === 'people' ? 'private' : 'not_published' };++ }++ if (row.audience === 'private' || row.audience === 'people') return { allowed: false, status: 404, reason: 'private' };++ }++ }if (post.state !== 'published') return member ? { allowed: true, status: 200, reason: 'member' } : { allowed: false, status: 404, reason: 'not_published' };if (post.visibility === 'public' || post.visibility === 'unlisted') return { allowed: true, status: 200, reason: 'public' };if (member) return { allowed: true, status: 200, reason: 'member' };@@@ -30,6 +30,23 @@ const TABLES = [{ table: 'blog_post_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", ['blog', subject]);++ const member = await db.many("SELECT * FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['blog', subject]);++ const asked = await db.many("SELECT * FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['blog', 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", ['blog', subject]);++ await t.exec("DELETE FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['blog', subject]);++ await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['blog', subject]);++ await t.exec("DELETE FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['blog', subject]);++ }++}++async function extraExport(db, subject) {const files = [];const blogs = await db.many('SELECT id, kind, handle, title, description, theme, language, status, created_at FROM blogs WHERE owner_subject = $1', [subject]);@@@ -42,12 +59,14 @@ async function extraExport(db, subject) {const revisions = await db.many(`SELECT entity_id, number, kind, content, fields, message, created_at FROM blog_post_revisionsWHERE author = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);if (revisions.length) files.push({ name: 'revisions.json', content: revisions });++ files.push(...await shareExport(db, subject));return files;}/** The account-data handle over Blog's database and posts (posts.remove for the person's own blog). */function create({ db, posts, log = console } = {}) {async function extraErase(t, subjects, counts) {++ await shareErase(t, subjects);// Their member blog: each post removed as its owner would remove it (a savepoint in this transaction), while// their membership still says they own it; then the blog is suspended and loses what names them.const own = await t.many("SELECT id, owner_subject FROM blogs WHERE kind = 'member' AND owner_subject = ANY($1::text[])", [subjects]);@@@ -41,7 +41,7 @@ function listOf(v) {return arr.map((s) => String(s).replace(/\s+/g, ' ').trim()).filter(Boolean);}--function createPosts({ store, blogs, publication, access, outbox, log = console }) {++function createPosts({ store, blogs, publication, access, outbox, log = console, sharing = null }) {const { db } = store;const q = {byId: db.prepare('SELECT * FROM blog_posts WHERE id = ?'),@@@ -269,6 +269,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = consolemeta: { authorship: rec }, author: authorLabel(viewer), message: input.message || 'First draft',});await attachCitations({ id }, revision.number, input.citations);++ // A new post is a draft: Only me until its author shares it (the Share sheet) or publishes it.++ if (sharing) await sharing.register(viewer, id, 'private');const post = await q.byId.get(id);await setTerms(blog, post, input);await outbox.emit({@@@ -334,6 +336,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = consoleconst keys = Object.keys(sets);await db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`).run({ ...sets, now: store.now(), id: post.id });++ // The editor's "Who can read it" moves the share row too, so the sheet and the select never disagree.++ if (sharing && sets.visibility) await sharing.syncAudience(post.id, sets.visibility);await setTerms(blog, post, input);const { post: after } = await publication.afterChange(before, post.id, { actor: viewer, traceparent });return { post: after, revision, created };@@@ -361,6 +365,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = consoleconst now = store.now();await db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, post.id);++ // Publishing a draft that was never shared widens it to the visibility the author chose.++ if (sharing) await sharing.syncAudience(post.id, post.visibility);const out = await publication.afterChange(before, post.id, { actor, traceparent });return { changed: true, post: out.post };});@@@ -373,6 +379,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = consoleif (post.state !== 'published') return { changed: false, post };const before = publication.snapshot(await blogOf(post), post);await db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);++ // Unpublishing takes it back to Only me: the share row must not keep a public door open.++ if (sharing) await sharing.syncAudience(post.id, 'private');return { changed: true, post: (await publication.afterChange(before, post.id, { actor, traceparent })).post };});},@@@ -246,7 +246,7 @@ function createApi(ctx) {router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {const { post, blog } = await mustPost(req);if (await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: await postDto(post, { full: true }) };-- const d = await access.canReadPost(store, req.viewer, blog, post, entitlements);++ const d = await access.canReadPost(store, req.viewer, blog, post, entitlements, ctx.sharing);// Members only: the teaser (title, the author's summary) and the join link, never the body.if (!d.allowed && d.status === 403) throw new ApiError(403, 'post.members_only', 'Members only', { teaser: await reading.teaser(blog, post, { reason: d.vip || null }) });if (!d.allowed) throw new ApiError(d.status, 'post.not_found', 'No such post');@@@ -40,7 +40,7 @@ const { csrfToken, checkCsrf } = require('../auth/forms');const PER_PAGE = 15;function createPublicRoutes(ctx) {-- const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits, desks } = ctx;++ const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits, desks, sharing } = ctx;const homeData = ctx.home || createHome({ store });const router = express.Router();router.use(viewers.middleware({ services: false }));@@@ -413,7 +413,7 @@ function createPublicRoutes(ctx) {});if (r && r.status === 301) {const target = await posts.get(r.entityId);-- const decision = await access.canReadPost(store, req.viewer, await blogs.get(target.blog_id), target, entitlements);++ const decision = await access.canReadPost(store, req.viewer, await blogs.get(target.blog_id), target, entitlements, sharing);if (decision.allowed) {cacheHeaders(res, { cacheable: target.state === 'published' && target.visibility === 'public' && req.viewer.kind === 'anonymous' });res.redirect(301, r.location + (req.path.endsWith('.json') ? '.json' : ''));@@@ -427,10 +427,24 @@ function createPublicRoutes(ctx) {notFound(req, res);return null;}-- const decision = await access.canReadPost(store, req.viewer, blog, post, entitlements);++ const decision = await access.canReadPost(store, req.viewer, blog, post, entitlements, sharing);if (!decision.allowed) {if (decision.status === 403) await membersOnly(req, res, blog, post, decision);-- else notFound(req, res);++ else if (decision.reason === 'private' && sharing) {++ // The gate: the audience, never the title, text or pictures. "Ask for access" when signed in.++ const s = await sharing.shareOf(post.id).catch(() => null);++ if (s && s.audience === 'people') {++ send(req, res, 404, {++ title: 'A post on OpenVibe.Blog',++ description: 'A post shared with specific people on OpenVibe.Blog.',++ decision: pageDecision(req.path, { indexable: false }),++ body: pages.sharedGate({ id: post.id, signedInAs: req.viewer && req.viewer.user && req.viewer.user.username ? req.viewer.user.username : null,++ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }),++ });++ return null;++ }++ }++ notFound(req, res);return null;}return post;@@@ -6,6 +6,7 @@*/const ssr = require('openvibe-publishing/ssr');const { time } = require('./pages');++const kit = require('openvibe-shared/kit');const { html: h, raw } = ssr;@@@ -76,7 +77,8 @@ ${post ? h`<label for="slug">URL slug</label><input id="slug" name="slug" value=<label for="series">Series <span class="meta">(title; empty for none)</span></label><input id="series" name="series" value="${series ? series.title : ''}"><label for="seriesPosition">Part number in the series</label><input id="seriesPosition" name="seriesPosition" type="number" min="1" value="${post && post.series_position ? post.series_position : ''}"><label for="visibility">Who can read it</label>--${select('visibility', [['public', 'Public'], ['unlisted', 'Unlisted (link only; never listed, fed or indexed)'], ['members', 'Members only (the blog owner’s OpenVibe.VIP members)'], ['private', 'Private (this blog’s members)']], vis)}++${select('visibility', [['public', 'Everyone'], ['unlisted', 'Anyone with the link (never listed, fed or indexed)'], ['members', 'Members only (the blog owner’s OpenVibe.VIP members)'], ['private', 'Only me']], vis)}++<p class="meta">The Share button above opens the same four choices for this one post, and can name specific people (“look at this before I post it”).</p><label for="entitlementKey">Gate label for members-only posts <span class="meta">(informational; OpenVibe.VIP decides who reads)</span></label><input id="entitlementKey" name="entitlementKey" value="${post && post.entitlement_key ? post.entitlement_key : `vip:${blog.handle}`}"><p class="meta">Members-only posts are readable by this blog’s members and staff, and by active OpenVibe.VIP members of the blog’s owner. If the owner defines a perk bound to <code>blog gated_post</code>, members need a plan that includes it; a rule the owner sets for the post on openvibe.vip takes precedence. Everyone else sees the title, the summary above as a teaser, and a link to join — never the text. ${blog.kind === 'official' ? 'The official blog has no VIP owner, so here nobody else is admitted.' : ''}</p><label><input type="checkbox" name="allowComments" value="1"${raw(!post || post.allow_comments ? ' checked' : '')}> Comments (public posts only, hosted by OpenVibe.Community)</label>@@@ -98,6 +100,7 @@ function editPage(o) {return String(h`<p><a href="/write/@${blog.handle}">← ${blog.title}</a></p><h1>${head.fields.title}</h1><p class="meta">${STATE_LABEL[post.state] || post.state} · ${post.visibility} · revision ${head.number}${published ? h` · readers see revision ${published.number}` : ''} · <a href="/write/posts/${post.id}/revisions">History</a> · <a href="/write/posts/${post.id}/preview">Preview</a>${post.state === 'published' ? h` · <a href="${publicUrl}">View</a>` : ''}</p>++<p class="post-share">${raw(kit.share({ title: head.fields.title, url: publicUrl, text: head.fields.summary || '', kind: 'post', id: post.id }))}</p>${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}${raw(gateSummary(decision))}${patchNotes ? raw(patchNotesPanel({ post, draft: patchNotes, csrf })) : ''}@@@ -10,6 +10,7 @@const ssr = require('openvibe-publishing/ssr');const { figureHtml } = require('openvibe-publishing/media');const kit = require('openvibe-shared/kit');++const ovServe = require('openvibe-shared/serve');const { html: h, raw, escapeHtml: esc } = ssr;const MEDIA_LINE = /^[ \t]*\[\[media:(med_[0-9A-HJKMNP-TV-Z]{26})\]\][ \t]*$/m;@@@ -198,7 +199,7 @@ function postPage(o) {const cover = attachments.filter((a) => a.role === 'cover');const gallery = attachments.filter((a) => a.role === 'gallery');const shareImage = cover.length && cover[0].mediaId ? urlFor(cover[0].mediaId) : null;-- const share = shareUrl ? h`<p class="post-share">${raw(kit.share({ title: rev.fields.title, url: shareUrl, text: rev.fields.summary || '', image: shareImage || null }))}</p>` : '';++ const share = shareUrl ? h`<p class="post-share">${raw(kit.share({ title: rev.fields.title, url: shareUrl, text: rev.fields.summary || '', image: shareImage || null, kind: 'post', id: post.id }))}</p>` : '';const badge = { members: 'Members only', private: 'Private', unlisted: 'Unlisted' }[post.visibility];const updated = rev.number > 1 && post.first_published_at && Date.parse(rev.createdAt) > post.first_published_at + 60000;const body = sectionsOf(renderBody(rev.content, attachments, { urlFor, rel }));@@@ -247,9 +248,27 @@ ${signedIn ? '' : h`<p>Already a member? <a href="${loginUrl}">Sign in</a>.</p>`</article>`);}++/**++ * The page a person who is not on the list sees (design 2026-11-06): it never shows the title, the++ * text, the comments or the pictures. OVShare.gate offers "Ask for access" to a signed-in person and++ * "Sign in" to everybody else; the plain fallback is removed by the script.++ */++function sharedGate({ id, signedInAs = null, signInUrl = '/auth/login' }) {++ return String(h`<section class="message">++<h1>A post on OpenVibe.Blog</h1>++<p>This is shared with specific people.</p>++<div id="share-gate" data-ov-share-gate data-kind="post" data-id="${String(id)}" data-signed-in-as="${signedInAs || ''}" data-sign-in-url="${signInUrl}">++<p>This post is shared with specific people on OpenVibe.Blog.</p>++<p><a class="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 message({ heading, text, action }) {return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);}module.exports = {-- readMinutes, sectionsOf, renderBody, renderMarkdownWithDetails, blogIndex, collection, postPage, membersTeaser, message, postList, categoryTree, time, dateLabel };++ readMinutes, sectionsOf, renderBody, renderMarkdownWithDetails, blogIndex, collection, postPage, membersTeaser, message, sharedGate, postList, categoryTree, time, dateLabel };nnew file mode 100644@@@ -0,0 +1,204 @@++'use strict';++++/**++ * Sharing for Blog posts 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 post, 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 Blog's own Events outbox, so share.* envelopes go out with every other blog.* event++ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue++ * - onAudienceChange keeps blog_posts.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 post that leaves Everyone is tombstoned at once++ * - onOwnerChange moves blog_posts.author_subject to the new owner++ *++ * A draft is registered private (Only me). The sheet on the draft can name people ("look at this++ * before I post it"); the post's page at /@handle/: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 = 'blog';++const KIND = 'post';++const AUDIENCES = ['public', 'link', 'people', 'private'];++/** Blog's visibility words to the SDK's, and back. `members` is VIP and not a share audience. */++const TO_SDK = { public: 'public', unlisted: 'link', private: 'private', members: 'private' };++const FROM_SDK = { public: 'public', link: 'unlisted', 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, publication, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {++ 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 post's title or text. */++ async function notify(n) {++ const to = n && n.to;++ if (!to || to.type !== 'user') return;++ const words = {++ 'share.added': ['A post was shared with you', 'Open it on OpenVibe.Blog.'],++ 'share.access_requested': ['Someone asked for access to your post', 'Review the request on OpenVibe.Blog.'],++ 'share.transfer.requested': ['Someone wants to give you a post', 'Accept or decline it on OpenVibe.Blog.'],++ 'share.transfer.answered': ['A post transfer was answered', 'See what happened on OpenVibe.Blog.'],++ }[n.kind] || ['Sharing on OpenVibe.Blog', '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: 'blog', url: n.ref && n.ref.id ? `${config.baseUrl}/p/${encodeURIComponent(n.ref.id)}` : 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, slug, blog_id FROM blog_posts WHERE id = $1', [ref.id]);++ if (!row) return;++ const before = { state: row.state, visibility: row.visibility, revision: null, url: null };++ const visibility = FROM_SDK[audience] || 'private';++ // `members` is OpenVibe.VIP and has no share audience: leave that column alone.++ if (!(row.visibility === 'members' && audience === 'private')) {++ await tx.exec('UPDATE blog_posts SET visibility = $1, updated_at = $3 WHERE id = $2', [visibility, ref.id, now()]);++ }++ const blog = await tx.maybe('SELECT id, handle, kind FROM blogs WHERE id = $1', [row.blog_id]);++ if (blog) before.url = blog.kind === 'official' ? `/${row.slug}` : `/@${blog.handle}/${row.slug}`;++ tx.afterCommit(async () => {++ try { await publication.afterChange(before, ref.id, { actor: null }); }++ 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 blog_posts SET author_subject = $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,++ /** Register a post with the sharing system (inside the post'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 post's title and link to its owner. */++function describeRef(db, config) {++ return async (ref) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};++ const post = await db.maybe(`SELECT p.id, p.slug, p.state, p.visibility, b.handle, b.kind AS blog_kind++ FROM blog_posts p JOIN blogs b ON b.id = p.blog_id WHERE p.id = $1 AND p.state <> 'deleted'`, [ref.id]);++ if (!post) return {};++ const head = await db.maybe('SELECT fields FROM blog_post_revisions WHERE entity_id = $1 ORDER BY number DESC LIMIT 1', [post.id]);++ const fields = head && head.fields ? (typeof head.fields === 'string' ? JSON.parse(head.fields) : head.fields) : {};++ const link = post.blog_kind === 'official' ? `/${post.slug}` : `/@${post.handle}/${post.slug}`;++ return {++ title: fields.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 };@@@ -39,7 +39,7 @@ async function startNetworkStub() {try {const missing = await uncovered(d.db, {tables: TABLES,-- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Blog applied","blog_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash","changelog_entries":"subject is the git commit subject text, not a Network subject; author is the git author"},++ exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Blog applied","blog_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash","changelog_entries":"subject is the git commit subject text, not a Network subject; author is the git author","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"},});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);} finally { await d.close(); }nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++/**++ * Sharing on Blog posts and drafts (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; the pilot is++ * Community's pastes). A new draft is Only me; the Share sheet's four audiences are enforced by++ * canView on the page, the JSON twin and the API; a post that leaves Everyone leaves the feed and the++ * sitemap at once; publishing a draft widens it to the visibility its author chose.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot({ env: { BLOG_WORKER: 'off' } });++ const carol = t.network.addUser('carol');++ const maticus = t.network.addUser('maticus');++ const sam = t.network.addUser('sam');++ await t.get('/api/v1/blogs', { as: carol, json: {} });++++ const make = async (input = {}) => {++ const r = await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: 'The LOOI bridge notes', body: `SECRET-LOOI notes. ${Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ')}`, visibility: 'public', ...input } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json().post;++ };++ const share = (id, opts = {}) => t.get(`/api/v1/post/${id}/share`, { headers: { 'x-requested-with': 'ov-share' }, ...opts });++ const putShare = (id, json, as = carol) => share(id, { method: 'PUT', as, json });++ const row = async (id) => await t.ctx.store.db.prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('blog', 'post', id);++ const post = async (id) => await t.ctx.store.db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(id);++++ await check('a new draft is registered Only me, owned by its author; the page never opens for anyone else', async () => {++ const p = await make();++ const s = await row(p.id);++ assert.strictEqual(s.audience, 'private', 'drafts are Only me');++ assert.deepStrictEqual(s.owner_subject, { type: 'user', id: carol.subject });++ const url = `/@carol/${p.slug}`;++ assert.strictEqual((await t.get(url, { as: carol })).status, 200);++ assert.strictEqual((await t.get(url, { as: maticus })).status, 404);++ assert.strictEqual((await t.get(url)).status, 404);++ assert.strictEqual((await t.get(`/api/v1/posts/${p.id}`, { as: maticus })).status, 404);++ assert.strictEqual((await t.get(`/@carol/${p.slug}.json`, { as: maticus })).status, 404);++ });++++ await check('Specific people: the named person reads the draft, a stranger gets the gate and never the title', async () => {++ const p = await make({ title: 'Board pack', body: `SECRET-LOOI board pack. ${Array.from({ length: 100 }, (_, i) => `b${i}`).join(' ')}` });++ const put = await putShare(p.id, { audience: 'people', members: [{ name: '@maticus', role: 'viewer' }] });++ assert.strictEqual(put.status, 200, put.text);++ assert.strictEqual(put.json().audience, 'people');++ assert.strictEqual((await post(p.id)).visibility, 'private', 'the old column fails closed');++ const url = `/@carol/${p.slug}`;++ const member = await t.get(url, { as: maticus });++ assert.strictEqual(member.status, 200);++ assert.ok(member.text.includes('SECRET-LOOI board pack'));++ const strangerBody = (await t.get(url, { as: sam })).text;++ const stranger = await t.get(url, { as: 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') && !stranger.text.includes('SECRET-LOOI'), 'no title, no text');++ const anon = await t.get(url);++ assert.strictEqual(anon.status, 404);++ assert.ok(anon.text.includes('/auth/login?next='), 'sign in');++ assert.strictEqual((await t.get(`/api/v1/post/${p.id}/share`, { as: sam })).status, 404, 'the sheet is the owner’s');++ assert.strictEqual((await t.get(`/@carol/${p.slug}.json`, { as: sam })).status, 404);++ });++++ await check('Everyone and the link: a shared draft is readable by whoever holds the address, still not listed', async () => {++ const p = await make({ title: 'Everyone draft' });++ const everyone = await putShare(p.id, { audience: 'public' });++ assert.strictEqual(everyone.status, 200, everyone.text);++ assert.strictEqual((await row(p.id)).audience, 'public');++ assert.strictEqual((await t.get(`/@carol/${p.slug}`)).status, 200, 'Everyone');++ const link = await putShare(p.id, { audience: 'link' });++ assert.strictEqual(link.status, 200, link.text);++ assert.strictEqual((await t.get(`/@carol/${p.slug}`)).status, 200, 'the link is the key');++ const feed = await t.get('/@carol/feed.xml');++ assert.ok(!feed.text.includes('Everyone draft'), 'a draft is never in a feed');++ });++++ await check('publishing widens a draft to the visibility its author chose, and it enters the feed', async () => {++ const p = await make({ title: 'Ready to ship' });++ assert.strictEqual((await row(p.id)).audience, 'private');++ const pub = await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });++ assert.strictEqual(pub.status, 200, pub.text);++ assert.strictEqual((await row(p.id)).audience, 'public', 'the author chose Everyone');++ assert.strictEqual((await t.get(`/@carol/${p.slug}`)).status, 200);++ assert.ok((await t.get('/@carol/feed.xml')).text.includes('Ready to ship'));++ });++++ await check('leaving Everyone takes the post out of the feed, the sitemap and the page at once', async () => {++ const p = await make({ title: 'Going quiet' });++ await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });++ assert.ok((await t.get('/@carol/feed.xml')).text.includes('Going quiet'));++ const put = await putShare(p.id, { audience: 'private' });++ assert.strictEqual(put.status, 200, put.text);++ assert.strictEqual((await post(p.id)).visibility, 'private');++ assert.strictEqual((await t.get(`/@carol/${p.slug}`)).status, 404);++ assert.ok(!(await t.get('/@carol/feed.xml')).text.includes('Going quiet'));++ assert.ok(!(await t.get('/sitemap.xml')).text.includes(`/@carol/${p.slug}`));++ assert.strictEqual((await t.get(`/@carol/${p.slug}`, { as: carol })).status, 200, 'the owner still reads it');++ const idx = await t.events(/^blog\.index_document\.deleted$/);++ assert.ok(idx.some((e) => JSON.stringify(e.payload || {}).includes(p.id)), 'a search tombstone went out');++ });++++ await check('the agent API (subjects) and the sheet API (names) agree; members are listed for the owner', async () => {++ const p = await make({ title: 'Two readers' });++ const put = await putShare(p.id, { audience: 'people', members: [{ subject: { type: 'user', id: sam.subject }, role: 'viewer' }] });++ assert.strictEqual(put.status, 200, put.text);++ assert.strictEqual((await t.get(`/@carol/${p.slug}`, { as: sam })).status, 200);++ const mine = await share(p.id, { as: carol });++ assert.strictEqual(mine.status, 200);++ assert.strictEqual(mine.json().members[0].username, 'sam');++ assert.strictEqual(mine.json().you.role, 'owner');++ });++++ await done();++})();@@@ -62,6 +62,17 @@ async function startNetwork() {}return json(200, { results });}++ if (req.url.startsWith('/internal/identity/resolve?') && req.method === 'GET') {++ const token = String(req.headers.authorization || '').slice(7);++ const v = serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' });++ if (!v.ok || !(v.claims.cap || []).includes('identity.subject.resolve')) return json(403, { code: 'capability.denied' });++ const name = new URL(req.url, 'http://m').searchParams.get('username') || '';++ for (const [subject, u] of directory) {++ if (u.username.toLowerCase() === name.toLowerCase()) return json(200, { subject: { type: 'user', id: subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false });++ }++ return json(404, { error: 'no such person' });++ }++ if (req.url === '/internal/notifications/push' && req.method === 'POST') return json(202, { queued: true });return json(404, { error: 'not found' });});issuer = srv.url;@@@ -66,6 +66,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');'server/desks/desks.js': 'OpenVibe.Food\'s recipe library feed (OV_FOOD_URL, configured; the budget-recipes desk)','server/clients/media.js': 'OpenVibe.Media (configured)','server/clients/network.js': 'OpenVibe.Network (configured)',++ '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,81 @@++'use strict';++/**++ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]):++ * the post page carries markup only, ov-share.js draws the gate when it loads, signed out it offers++ * Sign in, and a signed-in person who is not on the list is offered Ask for access — with 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 { boot, check, done } = require('./helpers/boot');++++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;++++(async () => {++ const t = await boot();++ const carol = t.network.addUser('carol');++ const sam = t.network.addUser('sam');++ await t.get('/api/v1/blogs', { as: carol, json: {} });++ const made = await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: 'Secret draft', body: 'SECRET-GATE words about the bridge. '.repeat(6), visibility: 'public' } });++ assert.strictEqual(made.status, 201, made.text);++ const post = made.json().post;++ await t.ctx.sharing.shares.setAudience(++ { subject: { type: 'user', id: carol.subject } }, { service: 'blog', kind: 'post', id: post.id }, 'people',++ );++++ let browser;++ const errors = [];++ const watch = (page) => {++ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });++ page.on('pageerror', (e) => errors.push(String(e)));++ };++ const settled = async (page) => {++ await page.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });++ return await page.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());++ };++++ try {++ await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {++ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });++ const ctx = await browser.newContext();++ const page = await ctx.newPage();++ watch(page);++ const res = await page.goto(`${t.base}/@carol/${post.slug}`, { waitUntil: 'load', timeout: 15000 });++ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the post');++ const text = await settled(page);++ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);++ const href = await page.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));++ assert.match(href, /^\/auth\/login\?next=/);++ await ctx.close();++ });++++ await check('headless Chrome: a stranger is offered Ask for access', async () => {++ const ctx = await browser.newContext();++ await ctx.addCookies([{ name: 'ov_token', value: t.network.userToken(sam), url: t.base }]);++ const page = await ctx.newPage();++ watch(page);++ await page.goto(`${t.base}/@carol/${post.slug}`, { waitUntil: 'load', timeout: 15000 });++ const text = await settled(page);++ 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();++ });++++ await check('headless Chrome: no console error and no CSP refusal on the gate page', async () => {++ assert.deepStrictEqual(errors.filter((e) => REFUSED.test(e)), []);++ });++ } finally {++ if (browser) await browser.close().catch(() => {});++ await t.close();++ done();++ }++})();
@@ -332,7 +332,7 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
@@ -332,7 +332,7 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
`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).
- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.4.1 (async PostgreSQL stores: revisions,
- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.4.1 (async PostgreSQL stores: revisions,
schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), `openvibe-contracts`
schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), `openvibe-contracts`
- v0.97.0, `openvibe-shared` v4.122.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
+ v0.97.0, `openvibe-shared` v4.123.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
`openvibe-sdk` v0.91.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).
`openvibe-sdk` v0.91.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).
- **OpenVibe.Network:**
- **OpenVibe.Network:**
- SSO: the OAuth client `blog` is already seeded with redirect
- SSO: the OAuth client `blog` is already seeded with redirect
@@ -543,6 +543,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -543,6 +543,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.4.1
- openvibe-publishing: v1.4.1
<!-- versions:end -->
<!-- versions:end -->
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"domain": "openvibe.blog",
"domain": "openvibe.blog",
"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.4.1",
"openvibe-publishing v1.4.1",
"openvibe-contracts v0.289.1",
"openvibe-contracts v0.289.1",
- "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,27 @@
@@ -0,0 +1,27 @@
+-- phase: migrate
+-- Every post that existed before sharing is registered once, so canView has a row for all of them.
+--
+-- state draft | scheduled | unpublished → private (Only me until the author says otherwise)
+-- state published, visibility public → public (Everyone)
+-- state published, visibility unlisted → link
+-- state published, visibility members/private → private (members stays the VIP gate in the column)
+--
+-- The owner is the author's subject. A post with no usable author (an imported or AI post) gets Blog
+-- itself, which nobody can sign in as. Deleted posts are not registered.
+INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
+SELECT
+ 'blog', 'post', p.id,
+ CASE
+ WHEN p.author_subject LIKE 'usr\_%' THEN jsonb_build_object('type', 'user', 'id', p.author_subject)
+ WHEN p.author_subject LIKE 'gst\_%' THEN jsonb_build_object('type', 'guest', 'id', p.author_subject)
+ ELSE jsonb_build_object('type', 'service', 'id', 'blog')
+ END,
+ CASE
+ WHEN p.state <> 'published' THEN 'private'
+ WHEN p.visibility = 'public' THEN 'public'
+ WHEN p.visibility = 'unlisted' THEN 'link'
+ ELSE 'private'
+ END
+FROM blog_posts p
+WHERE p.state <> 'deleted'
+ON CONFLICT (service, kind, resource_id) DO NOTHING;
@@ -18,7 +18,7 @@
@@ -18,7 +18,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.4.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"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": {
@@ -1210,9 +1210,9 @@
@@ -1210,9 +1210,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",
@@ -30,7 +30,7 @@
@@ -30,7 +30,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.4.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"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": {
@@ -42,6 +42,7 @@ const { createDiscoveryRoutes } = require('./http/discovery');
@@ -42,6 +42,7 @@ const { createDiscoveryRoutes } = require('./http/discovery');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
const { createBlogReadiness } = require('./observability');
const { createBlogReadiness } = require('./observability');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
+const { createSharing, describeRef } = require('./sharing');
const { assetVersion } = require('./render/layout');
const { assetVersion } = require('./render/layout');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
@@ -73,7 +74,10 @@ async function createApp(opts = {}) {
@@ -73,7 +74,10 @@ async function createApp(opts = {}) {
});
});
const blogs = createBlogs({ store, config });
const blogs = createBlogs({ store, config });
const publication = createPublication({ store, config, outbox, indexnow });
const publication = createPublication({ store, config, outbox, indexnow });
- const posts = createPosts({ store, blogs, publication, access, outbox, log });
+ // Sharing (openvibe-sdk/share): the shares row next to each post, members, transfers and the one
+ // access check every read path asks (domain/access.js canReadPost).
+ const sharing = opts.sharing || createSharing({ db: store.db, config, outbox, publication, now: store.now, fetchImpl: opts.fetchImpl, log });
+ const posts = createPosts({ store, blogs, publication, access, outbox, log, sharing });
const people = createPeople({ store, config, fetchImpl });
const people = createPeople({ store, config, fetchImpl });
const community = createCommunity({ store, config, fetchImpl });
const community = createCommunity({ store, config, fetchImpl });
const media = createMedia({ config, fetchImpl });
const media = createMedia({ config, fetchImpl });
@@ -104,7 +108,7 @@ async function createApp(opts = {}) {
@@ -104,7 +108,7 @@ async function createApp(opts = {}) {
posts.setPublishGate(patchNotes.gate);
posts.setPublishGate(patchNotes.gate);
// The AI desks (server/desks/): digests from Sources' items and the satire desk, beside the blogs.
// The AI desks (server/desks/): digests from Sources' items and the satire desk, beside the blogs.
const desks = opts.desks || require('./desks/desks').createDesks({ config, store, fetchImpl, log });
const desks = opts.desks || require('./desks/desks').createDesks({ config, store, fetchImpl, log });
- const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, patchNotes, desks, indexnow };
+ const ctx = { config, store, outbox, blogs, publication, posts, people, community, media, reading, effects, entitlements, vip, auth, viewers, access, worker, aiDrafts, changelog, patchNotes, desks, indexnow, sharing };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -213,6 +217,28 @@ async function createApp(opts = {}) {
@@ -213,6 +217,28 @@ async function createApp(opts = {}) {
},
},
}));
}));
+ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
+ // GET/PUT /api/v1/post/:id/share, .../share/requests, .../share/transfer[/accept|decline|cancel],
+ // GET /api/v1/share/people?name=, GET /api/v1/shared-with-me. Authentication is Blog's own viewer;
+ // the router answers only its own paths, so the rest of /api/v1 keeps its own 404.
+ {
+ const shareApi = express.Router();
+ shareApi.use(viewers.middleware({ services: false }));
+ shareApi.use(sharing.shares.router({
+ service: 'blog',
+ authenticate: (req) => sharing.shareViewer(req.viewer),
+ describe: describeRef(store.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()));
+ }
+ // Expire members and transfers and send queued share events/notifications.
+ if (opts.startSharingWorker !== false) {
+ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
+ if (timer.unref) timer.unref();
+ app.locals.sharingTimer = timer;
+ }
+
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
// GET /openapi.json: the API document, generated from the table the /api/v1 router mounts (http/openapi.js).
// GET /openapi.json: the API document, generated from the table the /api/v1 router mounts (http/openapi.js).
// Mounted before the API router, whose last layer answers 404 for anything it does not serve.
// Mounted before the API router, whose last layer answers 404 for anything it does not serve.
@@ -116,10 +116,27 @@ function createEntitlementChecker({ provider = 'none', check = null, vip = null
@@ -116,10 +116,27 @@ function createEntitlementChecker({ provider = 'none', check = null, vip = null
* status 404 hides existence (drafts, private, deleted); 403 says "members only" (the caller shows
* status 404 hides existence (drafts, private, deleted); 403 says "members only" (the caller shows
* a teaser and a join link, never the body); `vip` is the entitlement answer's reason.
* a teaser and a join link, never the body); `vip` is the entitlement answer's reason.
*/
*/
-async function canReadPost(store, viewer, blog, post, entitlements) {
+async function canReadPost(store, viewer, blog, post, entitlements, sharing = null) {
if (!post || post.state === 'deleted') return { allowed: false, status: 404, reason: 'not_found' };
if (!post || post.state === 'deleted') return { allowed: false, status: 404, reason: 'not_found' };
if (blog.status !== 'active' && !isStaff(viewer)) return { allowed: false, status: 404, reason: 'blog_suspended' };
if (blog.status !== 'active' && !isStaff(viewer)) return { allowed: false, status: 404, reason: 'blog_suspended' };
const member = await memberCanSee(store, viewer, blog, post) || isStaff(viewer);
const member = await memberCanSee(store, viewer, blog, post) || isStaff(viewer);
+ // The share row is the authority for published and draft posts, except `members` (VIP), which keeps
+ // the entitlement gate below. A row that says private/people denies everyone not on it, published or
+ // not; the page never says whether the post exists. A row that says public/link falls through to the
+ // column's own rules, which onAudienceChange keeps in step.
+ if (sharing && post.visibility !== 'members') {
+ const row = await sharing.shareOf(post.id);
+ if (row) {
+ if (member) return { allowed: true, status: 200, reason: 'member' };
+ const allowed = await sharing.canView(viewer, post.id).catch(() => false);
+ if (allowed) return { allowed: true, status: 200, reason: row.audience === 'public' ? 'public' : 'share' };
+ if (post.state !== 'published') {
+ // An unpublished post with people on its list shows the gate; every other draft is simply absent.
+ return { allowed: false, status: 404, reason: row.audience === 'people' ? 'private' : 'not_published' };
+ }
+ if (row.audience === 'private' || row.audience === 'people') return { allowed: false, status: 404, reason: 'private' };
+ }
+ }
if (post.state !== 'published') return member ? { allowed: true, status: 200, reason: 'member' } : { allowed: false, status: 404, reason: 'not_published' };
if (post.state !== 'published') return member ? { allowed: true, status: 200, reason: 'member' } : { allowed: false, status: 404, reason: 'not_published' };
if (post.visibility === 'public' || post.visibility === 'unlisted') return { allowed: true, status: 200, reason: 'public' };
if (post.visibility === 'public' || post.visibility === 'unlisted') return { allowed: true, status: 200, reason: 'public' };
if (member) return { allowed: true, status: 200, reason: 'member' };
if (member) return { allowed: true, status: 200, reason: 'member' };
@@ -30,6 +30,23 @@ const TABLES = [
@@ -30,6 +30,23 @@ const TABLES = [
{ table: 'blog_post_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: 'blog_post_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", ['blog', subject]);
+ const member = await db.many("SELECT * FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['blog', subject]);
+ const asked = await db.many("SELECT * FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['blog', 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", ['blog', subject]);
+ await t.exec("DELETE FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['blog', subject]);
+ await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['blog', subject]);
+ await t.exec("DELETE FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['blog', subject]);
+ }
+}
+
async function extraExport(db, subject) {
async function extraExport(db, subject) {
const files = [];
const files = [];
const blogs = await db.many('SELECT id, kind, handle, title, description, theme, language, status, created_at FROM blogs WHERE owner_subject = $1', [subject]);
const blogs = await db.many('SELECT id, kind, handle, title, description, theme, language, status, created_at FROM blogs WHERE owner_subject = $1', [subject]);
@@ -42,12 +59,14 @@ async function extraExport(db, subject) {
@@ -42,12 +59,14 @@ async function extraExport(db, subject) {
const revisions = await db.many(`SELECT entity_id, number, kind, content, fields, message, created_at FROM blog_post_revisions
const revisions = await db.many(`SELECT entity_id, number, kind, content, fields, message, created_at FROM blog_post_revisions
WHERE author = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
WHERE author = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
if (revisions.length) files.push({ name: 'revisions.json', content: revisions });
if (revisions.length) files.push({ name: 'revisions.json', content: revisions });
+ files.push(...await shareExport(db, subject));
return files;
return files;
}
}
/** The account-data handle over Blog's database and posts (posts.remove for the person's own blog). */
/** The account-data handle over Blog's database and posts (posts.remove for the person's own blog). */
function create({ db, posts, log = console } = {}) {
function create({ db, posts, log = console } = {}) {
async function extraErase(t, subjects, counts) {
async function extraErase(t, subjects, counts) {
+ await shareErase(t, subjects);
// Their member blog: each post removed as its owner would remove it (a savepoint in this transaction), while
// Their member blog: each post removed as its owner would remove it (a savepoint in this transaction), while
// their membership still says they own it; then the blog is suspended and loses what names them.
// their membership still says they own it; then the blog is suspended and loses what names them.
const own = await t.many("SELECT id, owner_subject FROM blogs WHERE kind = 'member' AND owner_subject = ANY($1::text[])", [subjects]);
const own = await t.many("SELECT id, owner_subject FROM blogs WHERE kind = 'member' AND owner_subject = ANY($1::text[])", [subjects]);
@@ -41,7 +41,7 @@ function listOf(v) {
@@ -41,7 +41,7 @@ function listOf(v) {
return arr.map((s) => String(s).replace(/\s+/g, ' ').trim()).filter(Boolean);
return arr.map((s) => String(s).replace(/\s+/g, ' ').trim()).filter(Boolean);
}
}
-function createPosts({ store, blogs, publication, access, outbox, log = console }) {
+function createPosts({ store, blogs, publication, access, outbox, log = console, sharing = null }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
byId: db.prepare('SELECT * FROM blog_posts WHERE id = ?'),
byId: db.prepare('SELECT * FROM blog_posts WHERE id = ?'),
@@ -269,6 +269,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -269,6 +269,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || 'First draft',
meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || 'First draft',
});
});
await attachCitations({ id }, revision.number, input.citations);
await attachCitations({ id }, revision.number, input.citations);
+ // A new post is a draft: Only me until its author shares it (the Share sheet) or publishes it.
+ if (sharing) await sharing.register(viewer, id, 'private');
const post = await q.byId.get(id);
const post = await q.byId.get(id);
await setTerms(blog, post, input);
await setTerms(blog, post, input);
await outbox.emit({
await outbox.emit({
@@ -334,6 +336,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -334,6 +336,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
const keys = Object.keys(sets);
const keys = Object.keys(sets);
await db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`)
await db.prepare(`UPDATE blog_posts SET ${keys.map((k) => `${k} = @${k}`).concat('updated_at = @now').join(', ')} WHERE id = @id`)
.run({ ...sets, now: store.now(), id: post.id });
.run({ ...sets, now: store.now(), id: post.id });
+ // The editor's "Who can read it" moves the share row too, so the sheet and the select never disagree.
+ if (sharing && sets.visibility) await sharing.syncAudience(post.id, sets.visibility);
await setTerms(blog, post, input);
await setTerms(blog, post, input);
const { post: after } = await publication.afterChange(before, post.id, { actor: viewer, traceparent });
const { post: after } = await publication.afterChange(before, post.id, { actor: viewer, traceparent });
return { post: after, revision, created };
return { post: after, revision, created };
@@ -361,6 +365,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -361,6 +365,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
const now = store.now();
const now = store.now();
await db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
await db.prepare(`UPDATE blog_posts SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, post.id);
published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, post.id);
+ // Publishing a draft that was never shared widens it to the visibility the author chose.
+ if (sharing) await sharing.syncAudience(post.id, post.visibility);
const out = await publication.afterChange(before, post.id, { actor, traceparent });
const out = await publication.afterChange(before, post.id, { actor, traceparent });
return { changed: true, post: out.post };
return { changed: true, post: out.post };
});
});
@@ -373,6 +379,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -373,6 +379,8 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
if (post.state !== 'published') return { changed: false, post };
if (post.state !== 'published') return { changed: false, post };
const before = publication.snapshot(await blogOf(post), post);
const before = publication.snapshot(await blogOf(post), post);
await db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);
await db.prepare("UPDATE blog_posts SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), post.id);
+ // Unpublishing takes it back to Only me: the share row must not keep a public door open.
+ if (sharing) await sharing.syncAudience(post.id, 'private');
return { changed: true, post: (await publication.afterChange(before, post.id, { actor, traceparent })).post };
return { changed: true, post: (await publication.afterChange(before, post.id, { actor, traceparent })).post };
});
});
},
},
@@ -246,7 +246,7 @@ function createApi(ctx) {
@@ -246,7 +246,7 @@ function createApi(ctx) {
router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {
router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {
const { post, blog } = await mustPost(req);
const { post, blog } = await mustPost(req);
if (await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: await postDto(post, { full: true }) };
if (await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: await postDto(post, { full: true }) };
- const d = await access.canReadPost(store, req.viewer, blog, post, entitlements);
+ const d = await access.canReadPost(store, req.viewer, blog, post, entitlements, ctx.sharing);
// Members only: the teaser (title, the author's summary) and the join link, never the body.
// Members only: the teaser (title, the author's summary) and the join link, never the body.
if (!d.allowed && d.status === 403) throw new ApiError(403, 'post.members_only', 'Members only', { teaser: await reading.teaser(blog, post, { reason: d.vip || null }) });
if (!d.allowed && d.status === 403) throw new ApiError(403, 'post.members_only', 'Members only', { teaser: await reading.teaser(blog, post, { reason: d.vip || null }) });
if (!d.allowed) throw new ApiError(d.status, 'post.not_found', 'No such post');
if (!d.allowed) throw new ApiError(d.status, 'post.not_found', 'No such post');
@@ -40,7 +40,7 @@ const { csrfToken, checkCsrf } = require('../auth/forms');
@@ -40,7 +40,7 @@ const { csrfToken, checkCsrf } = require('../auth/forms');
const PER_PAGE = 15;
const PER_PAGE = 15;
function createPublicRoutes(ctx) {
function createPublicRoutes(ctx) {
- const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits, desks } = ctx;
+ const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits, desks, sharing } = ctx;
const homeData = ctx.home || createHome({ store });
const homeData = ctx.home || createHome({ store });
const router = express.Router();
const router = express.Router();
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
@@ -413,7 +413,7 @@ function createPublicRoutes(ctx) {
@@ -413,7 +413,7 @@ function createPublicRoutes(ctx) {
});
});
if (r && r.status === 301) {
if (r && r.status === 301) {
const target = await posts.get(r.entityId);
const target = await posts.get(r.entityId);
- const decision = await access.canReadPost(store, req.viewer, await blogs.get(target.blog_id), target, entitlements);
+ const decision = await access.canReadPost(store, req.viewer, await blogs.get(target.blog_id), target, entitlements, sharing);
if (decision.allowed) {
if (decision.allowed) {
cacheHeaders(res, { cacheable: target.state === 'published' && target.visibility === 'public' && req.viewer.kind === 'anonymous' });
cacheHeaders(res, { cacheable: target.state === 'published' && target.visibility === 'public' && req.viewer.kind === 'anonymous' });
res.redirect(301, r.location + (req.path.endsWith('.json') ? '.json' : ''));
res.redirect(301, r.location + (req.path.endsWith('.json') ? '.json' : ''));
@@ -427,10 +427,24 @@ function createPublicRoutes(ctx) {
@@ -427,10 +427,24 @@ function createPublicRoutes(ctx) {
notFound(req, res);
notFound(req, res);
return null;
return null;
}
}
- const decision = await access.canReadPost(store, req.viewer, blog, post, entitlements);
+ const decision = await access.canReadPost(store, req.viewer, blog, post, entitlements, sharing);
if (!decision.allowed) {
if (!decision.allowed) {
if (decision.status === 403) await membersOnly(req, res, blog, post, decision);
if (decision.status === 403) await membersOnly(req, res, blog, post, decision);
- else notFound(req, res);
+ else if (decision.reason === 'private' && sharing) {
+ // The gate: the audience, never the title, text or pictures. "Ask for access" when signed in.
+ const s = await sharing.shareOf(post.id).catch(() => null);
+ if (s && s.audience === 'people') {
+ send(req, res, 404, {
+ title: 'A post on OpenVibe.Blog',
+ description: 'A post shared with specific people on OpenVibe.Blog.',
+ decision: pageDecision(req.path, { indexable: false }),
+ body: pages.sharedGate({ id: post.id, signedInAs: req.viewer && req.viewer.user && req.viewer.user.username ? req.viewer.user.username : null,
+ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }),
+ });
+ return null;
+ }
+ }
+ notFound(req, res);
return null;
return null;
}
}
return post;
return post;
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
*/
*/
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const { time } = require('./pages');
const { time } = require('./pages');
+const kit = require('openvibe-shared/kit');
const { html: h, raw } = ssr;
const { html: h, raw } = ssr;
@@ -76,7 +77,8 @@ ${post ? h`<label for="slug">URL slug</label><input id="slug" name="slug" value=
@@ -76,7 +77,8 @@ ${post ? h`<label for="slug">URL slug</label><input id="slug" name="slug" value=
<label for="series">Series <span class="meta">(title; empty for none)</span></label><input id="series" name="series" value="${series ? series.title : ''}">
<label for="series">Series <span class="meta">(title; empty for none)</span></label><input id="series" name="series" value="${series ? series.title : ''}">
<label for="seriesPosition">Part number in the series</label><input id="seriesPosition" name="seriesPosition" type="number" min="1" value="${post && post.series_position ? post.series_position : ''}">
<label for="seriesPosition">Part number in the series</label><input id="seriesPosition" name="seriesPosition" type="number" min="1" value="${post && post.series_position ? post.series_position : ''}">
<label for="visibility">Who can read it</label>
<label for="visibility">Who can read it</label>
-${select('visibility', [['public', 'Public'], ['unlisted', 'Unlisted (link only; never listed, fed or indexed)'], ['members', 'Members only (the blog owner’s OpenVibe.VIP members)'], ['private', 'Private (this blog’s members)']], vis)}
+${select('visibility', [['public', 'Everyone'], ['unlisted', 'Anyone with the link (never listed, fed or indexed)'], ['members', 'Members only (the blog owner’s OpenVibe.VIP members)'], ['private', 'Only me']], vis)}
+<p class="meta">The Share button above opens the same four choices for this one post, and can name specific people (“look at this before I post it”).</p>
<label for="entitlementKey">Gate label for members-only posts <span class="meta">(informational; OpenVibe.VIP decides who reads)</span></label><input id="entitlementKey" name="entitlementKey" value="${post && post.entitlement_key ? post.entitlement_key : `vip:${blog.handle}`}">
<label for="entitlementKey">Gate label for members-only posts <span class="meta">(informational; OpenVibe.VIP decides who reads)</span></label><input id="entitlementKey" name="entitlementKey" value="${post && post.entitlement_key ? post.entitlement_key : `vip:${blog.handle}`}">
<p class="meta">Members-only posts are readable by this blog’s members and staff, and by active OpenVibe.VIP members of the blog’s owner. If the owner defines a perk bound to <code>blog gated_post</code>, members need a plan that includes it; a rule the owner sets for the post on openvibe.vip takes precedence. Everyone else sees the title, the summary above as a teaser, and a link to join — never the text. ${blog.kind === 'official' ? 'The official blog has no VIP owner, so here nobody else is admitted.' : ''}</p>
<p class="meta">Members-only posts are readable by this blog’s members and staff, and by active OpenVibe.VIP members of the blog’s owner. If the owner defines a perk bound to <code>blog gated_post</code>, members need a plan that includes it; a rule the owner sets for the post on openvibe.vip takes precedence. Everyone else sees the title, the summary above as a teaser, and a link to join — never the text. ${blog.kind === 'official' ? 'The official blog has no VIP owner, so here nobody else is admitted.' : ''}</p>
<label><input type="checkbox" name="allowComments" value="1"${raw(!post || post.allow_comments ? ' checked' : '')}> Comments (public posts only, hosted by OpenVibe.Community)</label>
<label><input type="checkbox" name="allowComments" value="1"${raw(!post || post.allow_comments ? ' checked' : '')}> Comments (public posts only, hosted by OpenVibe.Community)</label>
@@ -98,6 +100,7 @@ function editPage(o) {
@@ -98,6 +100,7 @@ function editPage(o) {
return String(h`<p><a href="/write/@${blog.handle}">← ${blog.title}</a></p>
return String(h`<p><a href="/write/@${blog.handle}">← ${blog.title}</a></p>
<h1>${head.fields.title}</h1>
<h1>${head.fields.title}</h1>
<p class="meta">${STATE_LABEL[post.state] || post.state} · ${post.visibility} · revision ${head.number}${published ? h` · readers see revision ${published.number}` : ''} · <a href="/write/posts/${post.id}/revisions">History</a> · <a href="/write/posts/${post.id}/preview">Preview</a>${post.state === 'published' ? h` · <a href="${publicUrl}">View</a>` : ''}</p>
<p class="meta">${STATE_LABEL[post.state] || post.state} · ${post.visibility} · revision ${head.number}${published ? h` · readers see revision ${published.number}` : ''} · <a href="/write/posts/${post.id}/revisions">History</a> · <a href="/write/posts/${post.id}/preview">Preview</a>${post.state === 'published' ? h` · <a href="${publicUrl}">View</a>` : ''}</p>
+<p class="post-share">${raw(kit.share({ title: head.fields.title, url: publicUrl, text: head.fields.summary || '', kind: 'post', id: post.id }))}</p>
${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}
${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}
${raw(gateSummary(decision))}
${raw(gateSummary(decision))}
${patchNotes ? raw(patchNotesPanel({ post, draft: patchNotes, csrf })) : ''}
${patchNotes ? raw(patchNotesPanel({ post, draft: patchNotes, csrf })) : ''}
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const { figureHtml } = require('openvibe-publishing/media');
const { figureHtml } = require('openvibe-publishing/media');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const ovServe = require('openvibe-shared/serve');
const { html: h, raw, escapeHtml: esc } = ssr;
const { html: h, raw, escapeHtml: esc } = ssr;
const MEDIA_LINE = /^[ \t]*\[\[media:(med_[0-9A-HJKMNP-TV-Z]{26})\]\][ \t]*$/m;
const MEDIA_LINE = /^[ \t]*\[\[media:(med_[0-9A-HJKMNP-TV-Z]{26})\]\][ \t]*$/m;
@@ -198,7 +199,7 @@ function postPage(o) {
@@ -198,7 +199,7 @@ function postPage(o) {
const cover = attachments.filter((a) => a.role === 'cover');
const cover = attachments.filter((a) => a.role === 'cover');
const gallery = attachments.filter((a) => a.role === 'gallery');
const gallery = attachments.filter((a) => a.role === 'gallery');
const shareImage = cover.length && cover[0].mediaId ? urlFor(cover[0].mediaId) : null;
const shareImage = cover.length && cover[0].mediaId ? urlFor(cover[0].mediaId) : null;
- const share = shareUrl ? h`<p class="post-share">${raw(kit.share({ title: rev.fields.title, url: shareUrl, text: rev.fields.summary || '', image: shareImage || null }))}</p>` : '';
+ const share = shareUrl ? h`<p class="post-share">${raw(kit.share({ title: rev.fields.title, url: shareUrl, text: rev.fields.summary || '', image: shareImage || null, kind: 'post', id: post.id }))}</p>` : '';
const badge = { members: 'Members only', private: 'Private', unlisted: 'Unlisted' }[post.visibility];
const badge = { members: 'Members only', private: 'Private', unlisted: 'Unlisted' }[post.visibility];
const updated = rev.number > 1 && post.first_published_at && Date.parse(rev.createdAt) > post.first_published_at + 60000;
const updated = rev.number > 1 && post.first_published_at && Date.parse(rev.createdAt) > post.first_published_at + 60000;
const body = sectionsOf(renderBody(rev.content, attachments, { urlFor, rel }));
const body = sectionsOf(renderBody(rev.content, attachments, { urlFor, rel }));
@@ -247,9 +248,27 @@ ${signedIn ? '' : h`<p>Already a member? <a href="${loginUrl}">Sign in</a>.</p>`
@@ -247,9 +248,27 @@ ${signedIn ? '' : h`<p>Already a member? <a href="${loginUrl}">Sign in</a>.</p>`
</article>`);
</article>`);
}
}
+/**
+ * The page a person who is not on the list sees (design 2026-11-06): it never shows the title, the
+ * text, the comments or the pictures. OVShare.gate offers "Ask for access" to a signed-in person and
+ * "Sign in" to everybody else; the plain fallback is removed by the script.
+ */
+function sharedGate({ id, signedInAs = null, signInUrl = '/auth/login' }) {
+ return String(h`<section class="message">
+<h1>A post on OpenVibe.Blog</h1>
+<p>This is shared with specific people.</p>
+<div id="share-gate" data-ov-share-gate data-kind="post" data-id="${String(id)}" data-signed-in-as="${signedInAs || ''}" data-sign-in-url="${signInUrl}">
+<p>This post is shared with specific people on OpenVibe.Blog.</p>
+<p><a class="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 message({ heading, text, action }) {
function message({ heading, text, action }) {
return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
}
}
module.exports = {
module.exports = {
- readMinutes, sectionsOf, renderBody, renderMarkdownWithDetails, blogIndex, collection, postPage, membersTeaser, message, postList, categoryTree, time, dateLabel };
+ readMinutes, sectionsOf, renderBody, renderMarkdownWithDetails, blogIndex, collection, postPage, membersTeaser, message, sharedGate, postList, categoryTree, time, dateLabel };
new file mode 100644
new file mode 100644
@@ -0,0 +1,204 @@
@@ -0,0 +1,204 @@
+'use strict';
+
+/**
+ * Sharing for Blog posts 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 post, 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 Blog's own Events outbox, so share.* envelopes go out with every other blog.* event
+ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
+ * - onAudienceChange keeps blog_posts.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 post that leaves Everyone is tombstoned at once
+ * - onOwnerChange moves blog_posts.author_subject to the new owner
+ *
+ * A draft is registered private (Only me). The sheet on the draft can name people ("look at this
+ * before I post it"); the post's page at /@handle/: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 = 'blog';
+const KIND = 'post';
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+/** Blog's visibility words to the SDK's, and back. `members` is VIP and not a share audience. */
+const TO_SDK = { public: 'public', unlisted: 'link', private: 'private', members: 'private' };
+const FROM_SDK = { public: 'public', link: 'unlisted', 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, publication, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
+ 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 post's title or text. */
+ async function notify(n) {
+ const to = n && n.to;
+ if (!to || to.type !== 'user') return;
+ const words = {
+ 'share.added': ['A post was shared with you', 'Open it on OpenVibe.Blog.'],
+ 'share.access_requested': ['Someone asked for access to your post', 'Review the request on OpenVibe.Blog.'],
+ 'share.transfer.requested': ['Someone wants to give you a post', 'Accept or decline it on OpenVibe.Blog.'],
+ 'share.transfer.answered': ['A post transfer was answered', 'See what happened on OpenVibe.Blog.'],
+ }[n.kind] || ['Sharing on OpenVibe.Blog', '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: 'blog', url: n.ref && n.ref.id ? `${config.baseUrl}/p/${encodeURIComponent(n.ref.id)}` : 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, slug, blog_id FROM blog_posts WHERE id = $1', [ref.id]);
+ if (!row) return;
+ const before = { state: row.state, visibility: row.visibility, revision: null, url: null };
+ const visibility = FROM_SDK[audience] || 'private';
+ // `members` is OpenVibe.VIP and has no share audience: leave that column alone.
+ if (!(row.visibility === 'members' && audience === 'private')) {
+ await tx.exec('UPDATE blog_posts SET visibility = $1, updated_at = $3 WHERE id = $2', [visibility, ref.id, now()]);
+ }
+ const blog = await tx.maybe('SELECT id, handle, kind FROM blogs WHERE id = $1', [row.blog_id]);
+ if (blog) before.url = blog.kind === 'official' ? `/${row.slug}` : `/@${blog.handle}/${row.slug}`;
+ tx.afterCommit(async () => {
+ try { await publication.afterChange(before, ref.id, { actor: null }); }
+ 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 blog_posts SET author_subject = $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,
+ /** Register a post with the sharing system (inside the post'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 post's title and link to its owner. */
+function describeRef(db, config) {
+ return async (ref) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
+ const post = await db.maybe(`SELECT p.id, p.slug, p.state, p.visibility, b.handle, b.kind AS blog_kind
+ FROM blog_posts p JOIN blogs b ON b.id = p.blog_id WHERE p.id = $1 AND p.state <> 'deleted'`, [ref.id]);
+ if (!post) return {};
+ const head = await db.maybe('SELECT fields FROM blog_post_revisions WHERE entity_id = $1 ORDER BY number DESC LIMIT 1', [post.id]);
+ const fields = head && head.fields ? (typeof head.fields === 'string' ? JSON.parse(head.fields) : head.fields) : {};
+ const link = post.blog_kind === 'official' ? `/${post.slug}` : `/@${post.handle}/${post.slug}`;
+ return {
+ title: fields.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 };
@@ -39,7 +39,7 @@ async function startNetworkStub() {
@@ -39,7 +39,7 @@ async function startNetworkStub() {
try {
try {
const missing = await uncovered(d.db, {
const missing = await uncovered(d.db, {
tables: TABLES,
tables: TABLES,
- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Blog applied","blog_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash","changelog_entries":"subject is the git commit subject text, not a Network subject; author is the git author"},
+ exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Blog applied","blog_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash","changelog_entries":"subject is the git commit subject text, not a Network subject; author is the git author","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"},
});
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
} finally { await d.close(); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+/**
+ * Sharing on Blog posts and drafts (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; the pilot is
+ * Community's pastes). A new draft is Only me; the Share sheet's four audiences are enforced by
+ * canView on the page, the JSON twin and the API; a post that leaves Everyone leaves the feed and the
+ * sitemap at once; publishing a draft widens it to the visibility its author chose.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot({ env: { BLOG_WORKER: 'off' } });
+ const carol = t.network.addUser('carol');
+ const maticus = t.network.addUser('maticus');
+ const sam = t.network.addUser('sam');
+ await t.get('/api/v1/blogs', { as: carol, json: {} });
+
+ const make = async (input = {}) => {
+ const r = await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: 'The LOOI bridge notes', body: `SECRET-LOOI notes. ${Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ')}`, visibility: 'public', ...input } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json().post;
+ };
+ const share = (id, opts = {}) => t.get(`/api/v1/post/${id}/share`, { headers: { 'x-requested-with': 'ov-share' }, ...opts });
+ const putShare = (id, json, as = carol) => share(id, { method: 'PUT', as, json });
+ const row = async (id) => await t.ctx.store.db.prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('blog', 'post', id);
+ const post = async (id) => await t.ctx.store.db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(id);
+
+ await check('a new draft is registered Only me, owned by its author; the page never opens for anyone else', async () => {
+ const p = await make();
+ const s = await row(p.id);
+ assert.strictEqual(s.audience, 'private', 'drafts are Only me');
+ assert.deepStrictEqual(s.owner_subject, { type: 'user', id: carol.subject });
+ const url = `/@carol/${p.slug}`;
+ assert.strictEqual((await t.get(url, { as: carol })).status, 200);
+ assert.strictEqual((await t.get(url, { as: maticus })).status, 404);
+ assert.strictEqual((await t.get(url)).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/posts/${p.id}`, { as: maticus })).status, 404);
+ assert.strictEqual((await t.get(`/@carol/${p.slug}.json`, { as: maticus })).status, 404);
+ });
+
+ await check('Specific people: the named person reads the draft, a stranger gets the gate and never the title', async () => {
+ const p = await make({ title: 'Board pack', body: `SECRET-LOOI board pack. ${Array.from({ length: 100 }, (_, i) => `b${i}`).join(' ')}` });
+ const put = await putShare(p.id, { audience: 'people', members: [{ name: '@maticus', role: 'viewer' }] });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.strictEqual(put.json().audience, 'people');
+ assert.strictEqual((await post(p.id)).visibility, 'private', 'the old column fails closed');
+ const url = `/@carol/${p.slug}`;
+ const member = await t.get(url, { as: maticus });
+ assert.strictEqual(member.status, 200);
+ assert.ok(member.text.includes('SECRET-LOOI board pack'));
+ const strangerBody = (await t.get(url, { as: sam })).text;
+ const stranger = await t.get(url, { as: 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') && !stranger.text.includes('SECRET-LOOI'), 'no title, no text');
+ const anon = await t.get(url);
+ assert.strictEqual(anon.status, 404);
+ assert.ok(anon.text.includes('/auth/login?next='), 'sign in');
+ assert.strictEqual((await t.get(`/api/v1/post/${p.id}/share`, { as: sam })).status, 404, 'the sheet is the owner’s');
+ assert.strictEqual((await t.get(`/@carol/${p.slug}.json`, { as: sam })).status, 404);
+ });
+
+ await check('Everyone and the link: a shared draft is readable by whoever holds the address, still not listed', async () => {
+ const p = await make({ title: 'Everyone draft' });
+ const everyone = await putShare(p.id, { audience: 'public' });
+ assert.strictEqual(everyone.status, 200, everyone.text);
+ assert.strictEqual((await row(p.id)).audience, 'public');
+ assert.strictEqual((await t.get(`/@carol/${p.slug}`)).status, 200, 'Everyone');
+ const link = await putShare(p.id, { audience: 'link' });
+ assert.strictEqual(link.status, 200, link.text);
+ assert.strictEqual((await t.get(`/@carol/${p.slug}`)).status, 200, 'the link is the key');
+ const feed = await t.get('/@carol/feed.xml');
+ assert.ok(!feed.text.includes('Everyone draft'), 'a draft is never in a feed');
+ });
+
+ await check('publishing widens a draft to the visibility its author chose, and it enters the feed', async () => {
+ const p = await make({ title: 'Ready to ship' });
+ assert.strictEqual((await row(p.id)).audience, 'private');
+ const pub = await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });
+ assert.strictEqual(pub.status, 200, pub.text);
+ assert.strictEqual((await row(p.id)).audience, 'public', 'the author chose Everyone');
+ assert.strictEqual((await t.get(`/@carol/${p.slug}`)).status, 200);
+ assert.ok((await t.get('/@carol/feed.xml')).text.includes('Ready to ship'));
+ });
+
+ await check('leaving Everyone takes the post out of the feed, the sitemap and the page at once', async () => {
+ const p = await make({ title: 'Going quiet' });
+ await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });
+ assert.ok((await t.get('/@carol/feed.xml')).text.includes('Going quiet'));
+ const put = await putShare(p.id, { audience: 'private' });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.strictEqual((await post(p.id)).visibility, 'private');
+ assert.strictEqual((await t.get(`/@carol/${p.slug}`)).status, 404);
+ assert.ok(!(await t.get('/@carol/feed.xml')).text.includes('Going quiet'));
+ assert.ok(!(await t.get('/sitemap.xml')).text.includes(`/@carol/${p.slug}`));
+ assert.strictEqual((await t.get(`/@carol/${p.slug}`, { as: carol })).status, 200, 'the owner still reads it');
+ const idx = await t.events(/^blog\.index_document\.deleted$/);
+ assert.ok(idx.some((e) => JSON.stringify(e.payload || {}).includes(p.id)), 'a search tombstone went out');
+ });
+
+ await check('the agent API (subjects) and the sheet API (names) agree; members are listed for the owner', async () => {
+ const p = await make({ title: 'Two readers' });
+ const put = await putShare(p.id, { audience: 'people', members: [{ subject: { type: 'user', id: sam.subject }, role: 'viewer' }] });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.strictEqual((await t.get(`/@carol/${p.slug}`, { as: sam })).status, 200);
+ const mine = await share(p.id, { as: carol });
+ assert.strictEqual(mine.status, 200);
+ assert.strictEqual(mine.json().members[0].username, 'sam');
+ assert.strictEqual(mine.json().you.role, 'owner');
+ });
+
+ await done();
+})();
@@ -62,6 +62,17 @@ async function startNetwork() {
@@ -62,6 +62,17 @@ async function startNetwork() {
}
}
return json(200, { results });
return json(200, { results });
}
}
+ if (req.url.startsWith('/internal/identity/resolve?') && req.method === 'GET') {
+ const token = String(req.headers.authorization || '').slice(7);
+ const v = serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' });
+ if (!v.ok || !(v.claims.cap || []).includes('identity.subject.resolve')) return json(403, { code: 'capability.denied' });
+ const name = new URL(req.url, 'http://m').searchParams.get('username') || '';
+ for (const [subject, u] of directory) {
+ if (u.username.toLowerCase() === name.toLowerCase()) return json(200, { subject: { type: 'user', id: subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false });
+ }
+ return json(404, { error: 'no such person' });
+ }
+ if (req.url === '/internal/notifications/push' && req.method === 'POST') return json(202, { queued: true });
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
issuer = srv.url;
issuer = srv.url;
@@ -66,6 +66,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
@@ -66,6 +66,7 @@ const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
'server/desks/desks.js': 'OpenVibe.Food\'s recipe library feed (OV_FOOD_URL, configured; the budget-recipes desk)',
'server/desks/desks.js': 'OpenVibe.Food\'s recipe library feed (OV_FOOD_URL, configured; the budget-recipes desk)',
'server/clients/media.js': 'OpenVibe.Media (configured)',
'server/clients/media.js': 'OpenVibe.Media (configured)',
'server/clients/network.js': 'OpenVibe.Network (configured)',
'server/clients/network.js': 'OpenVibe.Network (configured)',
+ '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,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]):
+ * the post page carries markup only, ov-share.js draws the gate when it loads, signed out it offers
+ * Sign in, and a signed-in person who is not on the list is offered Ask for access — with 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 { boot, check, done } = require('./helpers/boot');
+
+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;
+
+(async () => {
+ const t = await boot();
+ const carol = t.network.addUser('carol');
+ const sam = t.network.addUser('sam');
+ await t.get('/api/v1/blogs', { as: carol, json: {} });
+ const made = await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: 'Secret draft', body: 'SECRET-GATE words about the bridge. '.repeat(6), visibility: 'public' } });
+ assert.strictEqual(made.status, 201, made.text);
+ const post = made.json().post;
+ await t.ctx.sharing.shares.setAudience(
+ { subject: { type: 'user', id: carol.subject } }, { service: 'blog', kind: 'post', id: post.id }, 'people',
+ );
+
+ let browser;
+ const errors = [];
+ const watch = (page) => {
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
+ page.on('pageerror', (e) => errors.push(String(e)));
+ };
+ const settled = async (page) => {
+ await page.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });
+ return await page.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());
+ };
+
+ try {
+ await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {
+ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
+ const ctx = await browser.newContext();
+ const page = await ctx.newPage();
+ watch(page);
+ const res = await page.goto(`${t.base}/@carol/${post.slug}`, { waitUntil: 'load', timeout: 15000 });
+ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the post');
+ const text = await settled(page);
+ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);
+ const href = await page.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));
+ assert.match(href, /^\/auth\/login\?next=/);
+ await ctx.close();
+ });
+
+ await check('headless Chrome: a stranger is offered Ask for access', async () => {
+ const ctx = await browser.newContext();
+ await ctx.addCookies([{ name: 'ov_token', value: t.network.userToken(sam), url: t.base }]);
+ const page = await ctx.newPage();
+ watch(page);
+ await page.goto(`${t.base}/@carol/${post.slug}`, { waitUntil: 'load', timeout: 15000 });
+ const text = await settled(page);
+ 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();
+ });
+
+ await check('headless Chrome: no console error and no CSP refusal on the gate page', async () => {
+ assert.deepStrictEqual(errors.filter((e) => REFUSED.test(e)), []);
+ });
+ } finally {
+ if (browser) await browser.close().catch(() => {});
+ await t.close();
+ done();
+ }
+})();