openvibers/openvibe.blog Commits

Blog: the four audiences on posts and drafts (sharing step 4) (#210)

OP OpenVibers committed 6 days ago · 51eee77550 · 20 files, +638 −22

* Blog: the four audiences on posts and drafts (sharing step 4)

Every post gets an openvibe-sdk/share row next to it. A new draft is Only me;
the Share sheet on the post (and in the editor) offers Everyone, Anyone with
the link, Specific people and Only me, and canView decides the page, the JSON
twin and the API. Publishing widens a draft to the visibility its author chose.
Leaving Everyone updates the old column, takes the post out of the feed and the
sitemap, and sends Search a tombstone. The uniform API is mounted at
/api/v1/post/:id/share with the people list and transfers.

Migrations 0006 (the SDK's tables) and 0007 (the backfill of every post).

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

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

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

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

Changes

ddiff --git a/README.md b/README.md
iindex 3b2a439..b46747b 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 6ded29c..3f6beca 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/migrations/0006_sharing.sql b/migrations/0006_sharing.sql
nnew file mode 100644
iindex 0000000..2c9ce12
---- /dev/null
++++ b/migrations/0006_sharing.sql
@@@ -0,0 +1,72 @@
++-- phase: expand
++-- Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 4): the tables openvibe-sdk/share v0.91.0's
++-- sharesSchema() creates, kept verbatim so Blog's database has the same shape as every other
++-- service that adopts it. Expand only: nothing is dropped or rewritten.
++
++
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ lock text NOT NULL DEFAULT 'none',
++ reseal_nonce_base text,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ key_slot jsonb,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ key_slot jsonb,
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
++-- slot a transfer was offered with. Repeating this migration is a no-op.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
++DO $$ BEGIN
++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
++
ddiff --git a/migrations/0007_sharing_backfill.sql b/migrations/0007_sharing_backfill.sql
nnew file mode 100644
iindex 0000000..3d0f90d
---- /dev/null
++++ b/migrations/0007_sharing_backfill.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 8d1c048..d23f68d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 881d07e..d8b512e 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/app.js b/server/app.js
iindex e1b366d..8cff2ae 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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.
ddiff --git a/server/domain/access.js b/server/domain/access.js
iindex 3b78055..95c00fd 100644
---- a/server/domain/access.js
++++ b/server/domain/access.js
@@@ -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' };
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
iindex 31eac5a..1e3f86e 100644
---- a/server/domain/account-data.js
++++ b/server/domain/account-data.js
@@@ -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_revisions
WHERE 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]);
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex 7b6a5c1..d46f29a 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -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 = console
meta: { 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 = console
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`)
.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 = console
const 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 = console
if (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 };
});
},
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 203cb57..5f3af36 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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');
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 67fe2c6..c5168bb 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -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;
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex c0237aa..3a39f51 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -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 })) : ''}
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 6005767..97a9054 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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 };
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..29c8226
---- /dev/null
++++ b/server/sharing.js
@@@ -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 };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex f0f163d..4234f96 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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(); }
ddiff --git a/test/draft-sharing.test.js b/test/draft-sharing.test.js
nnew file mode 100644
iindex 0000000..fb0f0c5
---- /dev/null
++++ b/test/draft-sharing.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 614951f..f838c8b 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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;
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 5d18ac5..818dae3 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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 = [];
ddiff --git a/test/share-gate-chrome.test.js b/test/share-gate-chrome.test.js
nnew file mode 100644
iindex 0000000..adc9b27
---- /dev/null
++++ b/test/share-gate-chrome.test.js
@@@ -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();
++ }
++})();