openvibers/openvibe.news Commits

News: the four audiences on stories and drafts (sharing step 4) (#217)

OP OpenVibers committed 6 days ago · ad5153f260 · 16 files, +559 −18

* News: the four audiences on stories and drafts (sharing step 4)

Every story gets an openvibe-sdk/share row next to it and an audience column
the lists read. A draft is Only me; its editor's Share sheet can name someone
("look at this before I post it"), and /stories/:slug then opens the head
revision for them, with the gate (never the headline, text or sources) for
anyone else. Publishing makes it Everyone; unpublishing takes it back to Only
me. The home, topic pages, feeds and sitemap list only Everyone stories. The
uniform API is mounted at /api/v1/story/:id/share.

Migrations 0007 (the SDK's tables and the audience column) and 0008 (the
backfill).

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

* Sharing: a row written before the model falls back to the old column; the second account-data guard too

* 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-news

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

Changes

ddiff --git a/README.md b/README.md
iindex 603e132..343811d 100644
---- a/README.md
++++ b/README.md
@@@ -449,6 +449,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 53ee18d..3fd4ead 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,11 +9,11 @@
"domain": "openvibe.news",
"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/0007_sharing.sql b/migrations/0007_sharing.sql
nnew file mode 100644
iindex 0000000..6049150
---- /dev/null
++++ b/migrations/0007_sharing.sql
@@@ -0,0 +1,82 @@
++-- 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 News' database has the same shape as every other
++-- service that adopts it. Expand only: nothing is dropped or rewritten.
++
++-- phase: expand
++-- The one sharing system (openvibe-sdk/share v0.91.0), for the companions characters.
++-- Kept verbatim from openvibe-sdk src/share/schema.js, so the SDKs own store finds the tables it expects, and
++-- the test database (which applies this directory) has them too. The characters own rows live in
++-- exchange_characters; these rows are the audience, the members, the log and the delivery queue the SDK writes.
++
++
++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;
++
++
++-- What the lists read: public is Everyone, anything narrower is kept out of them.
++ALTER TABLE news_stories ADD COLUMN IF NOT EXISTS audience text COLLATE "C" NOT NULL DEFAULT 'private';
++DO $$ BEGIN ALTER TABLE news_stories ADD CONSTRAINT news_stories_audience CHECK (audience IN ('public','link','people','private')); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
ddiff --git a/migrations/0008_sharing_backfill.sql b/migrations/0008_sharing_backfill.sql
nnew file mode 100644
iindex 0000000..0994230
---- /dev/null
++++ b/migrations/0008_sharing_backfill.sql
@@@ -0,0 +1,19 @@
++-- phase: migrate
++-- Every story that existed before sharing is registered once, and its audience column says what
++-- the lists should hold: a published (or retracted) story was Everyone; a draft is Only me.
++--
++-- The owner is the editor who opened it; a service-made story gets News itself, which nobody can
++-- sign in as.
++UPDATE news_stories SET audience = CASE WHEN state IN ('published', 'retracted') THEN 'public' ELSE 'private' END;
++
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT
++ 'news', 'story', s.id,
++ CASE
++ WHEN s.created_by LIKE 'usr\_%' THEN jsonb_build_object('type', 'user', 'id', s.created_by)
++ WHEN s.created_by LIKE 'gst\_%' THEN jsonb_build_object('type', 'guest', 'id', s.created_by)
++ ELSE jsonb_build_object('type', 'service', 'id', 'news')
++ END,
++ CASE WHEN s.state IN ('published', 'retracted') THEN 'public' ELSE 'private' END
++FROM news_stories s
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/package-lock.json b/package-lock.json
iindex f0f26e5..4ab8b2c 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 0b919d5..e3dae54 100644
---- a/package.json
++++ b/package.json
@@@ -32,7 +32,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/account-data.js b/server/account-data.js
iindex 1ff5e34..46320a7 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -26,8 +26,25 @@ const TABLES = [
{ table: 'news_story_revisions', subject: 'author', file: 'story-revisions.json', order: 'created_at', columns: ['id', 'entity_id', 'number', 'kind', 'message', 'created_at'], erase: editorial('a published story keeps its edit history and byline as the publication record') },
];
++
++/** 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", ['news', subject]);
++ const member = await db.many("SELECT * FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['news', subject]);
++ const asked = await db.many("SELECT * FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['news', 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", ['news', subject]);
++ await t.exec("DELETE FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['news', subject]);
++ await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['news', subject]);
++ await t.exec("DELETE FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['news', subject]);
++ }
++}
++
function create({ db, log = console } = {}) {
-- return createAccountData({ db, service: 'news', tables: TABLES, log });
++ return createAccountData({ db, service: 'news', tables: TABLES, extraExport: (d, subject) => shareExport(d, subject), extraErase: (t, subjects) => shareErase(t, subjects), log });
}
module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/app.js b/server/app.js
iindex 5d59386..39ac4f7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -33,6 +33,7 @@ const { createDiscussion } = require('./domain/discussion');
const { createClusters } = require('./domain/clusters');
const { createIngest } = require('./domain/source-items');
const { createStories } = require('./domain/stories');
++const { createSharing, describeRef } = require('./sharing');
const { createTopics } = require('./domain/topics');
const { createReading } = require('./domain/reading');
const { createPublicRoutes } = require('./http/public');
@@@ -84,7 +85,9 @@ async function createApp(opts = {}) {
const community = createCommunity({ store, config, fetchImpl, log });
const discussion = createDiscussion({ store, publication, community, log });
const ingest = createIngest({ store, config, clusters, outbox, sources, discussion, log });
-- const stories = createStories({ store, config, publication, clusters, outbox, ai, discussion, log });
++ // Sharing (openvibe-sdk/share): a story's share row, its people and the one access check for the story page.
++ const sharing = opts.sharing || createSharing({ db: store.db, config, outbox, now: store.now, fetchImpl: opts.fetchImpl, log });
++ const stories = createStories({ store, config, publication, clusters, outbox, ai, discussion, log, sharing });
ingest.setStories(stories);
const topics = createTopics({ store });
// Topics are the only seed (idempotent: missing slugs are added, existing ones never changed).
@@@ -107,7 +110,7 @@ async function createApp(opts = {}) {
const briefs = createBriefs({ store, ai: config.briefs.enabled ? ai : null, log, minOutlets: config.indexing && config.indexing.minIndependentSources });
const worker = createWorker({ config, ingest, outbox, briefs, log });
-- const ctx = { config, store, accountData, accountSend, outbox, publication, clusters, sources, ai, community, discussion, ingest, stories, topics, people, reading, auth, viewers, worker, indexnow, briefs };
++ const ctx = { config, store, accountData, accountSend, outbox, publication, clusters, sources, ai, community, discussion, ingest, stories, topics, people, reading, auth, viewers, worker, indexnow, briefs, sharing };
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@@ -206,6 +209,18 @@ async function createApp(opts = {}) {
}));
// ── API ─────────────────────────────────────────────────
++ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
++ {
++ const shareApi = express.Router();
++ shareApi.use(viewers.middleware());
++ shareApi.use(sharing.shares.router({
++ service: 'news',
++ 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()));
++ }
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
// ── Discovery, editor desk, public pages ────────────────
ddiff --git a/server/domain/stories.js b/server/domain/stories.js
iindex 8ac6d54..7c275ac 100644
---- a/server/domain/stories.js
++++ b/server/domain/stories.js
@@@ -55,7 +55,7 @@ function formatBody(paragraphs) {
return (paragraphs || []).map((p) => `${p.text}${p.sources && p.sources.length ? ` [${p.sources.join(', ')}]` : ''}`).join('\n\n');
}
--function createStories({ store, config, publication, clusters, outbox, ai = null, discussion = null, log = console }) {
++function createStories({ store, config, publication, clusters, outbox, ai = null, discussion = null, log = console, sharing = null }) {
const { db } = store;
const q = {
byId: db.prepare('SELECT * FROM news_stories WHERE id = ?'),
@@@ -270,7 +270,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
/** Published and retracted stories (retracted ones stay listed with their notice). */
async listPublished({ topicId = null, limit = 20, offset = 0 } = {}) {
-- const where = ["state IN ('published','retracted')"];
++ const where = ["state IN ('published','retracted')", "audience = 'public'"];
const args = [];
if (topicId) { where.push('topic_id = ?'); args.push(topicId); }
const w = where.join(' AND ');
@@@ -308,6 +308,8 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
await db.prepare(`INSERT INTO news_stories (id, slug, working_headline, cluster_id, topic_id, state, noindex, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'draft', 0, ?, ?, ?)`).run(id, slug, headline, cluster ? cluster.id : null, topic ? topic.id : null, subject, now, now);
const story = await q.byId.get(id);
++ // A new story is a draft: Only me until its editor shares it or publishes it.
++ if (sharing) await sharing.register({ subject: /^usr_/.test(String(subject)) ? subject : null }, id, 'private');
if (cluster) {
const items = (await clusters.members(cluster.id)).filter((it) => it.status === 'active')
.sort((a, b) => String(a.published_at || '').localeCompare(String(b.published_at || '')) || a.first_seen_at - b.first_seen_at || (a.id < b.id ? -1 : 1));
@@@ -511,6 +513,8 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
const before = publication.snapshot(cur);
await db.prepare(`UPDATE news_stories SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
published_at = ?, updated_at = ? WHERE id = ?`).run(rev.number, now, now, now, story.id);
++ // Publishing makes it Everyone (the sheet keeps the row in step afterwards).
++ if (sharing) await sharing.syncAudience(story.id, 'public');
const subject = viewer.subject;
for (const f of (await q.flags.all(story.id)).filter((x) => x.status === 'open' && (x.kind === 'correction' || x.kind === 'update'))) {
await db.prepare("UPDATE news_editorial_flags SET status = 'published', revision = ?, resolved_by = ?, resolved_at = ? WHERE id = ?").run(rev.number, subject, now, f.id);
@@@ -530,6 +534,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
if (discussion) await discussion.watch(story.id);
const before = publication.snapshot(cur);
await db.prepare("UPDATE news_stories SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), story.id);
++ if (sharing) await sharing.syncAudience(story.id, 'private');
return { changed: true, story: (await publication.afterChange(before, story.id, { actor: viewer, traceparent })).story };
});
},
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex cf33857..3abd016 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -41,7 +41,7 @@ function createDiscoveryRoutes(ctx) {
async function storyEntries() {
// Revisions in one query, whatever the number of stories (no N+1).
-- const rows = await db.prepare("SELECT * FROM news_stories WHERE state IN ('published','retracted') AND published_revision IS NOT NULL ORDER BY published_at DESC, id LIMIT 50000").all();
++ const rows = await db.prepare("SELECT * FROM news_stories WHERE state IN ('published','retracted') AND audience = 'public' AND published_revision IS NOT NULL ORDER BY published_at DESC, id LIMIT 50000").all();
const revs = await store.revisions.getMany(rows.map((s) => ({ entityId: s.id, revision: s.published_revision })));
const out = [];
for (const [i, story] of rows.entries()) out.push({ story, rev: revs[i], decision: await publication.decide(story, revs[i]) });
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 6422125..a4385ab 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -42,7 +42,7 @@ const HOME_DESCRIPTION = 'News you can check: AI briefs and stories where every
const PER_PAGE = 20;
function createPublicRoutes(ctx) {
-- const { config, store, stories, publication, reading, topics, viewers, community, discussion, clusters, briefs } = ctx;
++ const { config, store, stories, publication, reading, topics, viewers, community, discussion, clusters, briefs, sharing } = ctx;
const homeData = ctx.home || createHome({ store });
const router = express.Router();
router.use(viewers.middleware({ services: false }));
@@@ -319,8 +319,41 @@ function createPublicRoutes(ctx) {
const asJson = req.params.slug.endsWith('.json');
const slug = asJson ? req.params.slug.slice(0, -5) : req.params.slug;
const story = await stories.bySlug(slug);
-- if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) {
-- if (story && story.state === 'unpublished') return messagePage(req, res, 410, 'Gone', 'This story was unpublished.', { href: '/', label: 'OpenVibe.News' });
++ const live = Boolean(story && story.published_revision && (story.state === 'published' || story.state === 'retracted'));
++ const editor = story && access.isEditor(config, req.viewer);
++ if (!story) return notFound(req, res);
++ if (!live) {
++ if (story.state === 'unpublished' && !editor) return messagePage(req, res, 410, 'Gone', 'This story was unpublished.', { href: '/', label: 'OpenVibe.News' });
++ // A draft is Only me, unless its editor shared it: then its head revision opens, and a stranger gets the gate.
++ const allowed = editor || (sharing ? await sharing.canView(req.viewer, story.id).catch(() => false) : false);
++ if (allowed) {
++ const head = await store.revisions.head(story.id);
++ if (head) return await renderStory(req, res, { story, rev: head, preview: true });
++ }
++ const row = sharing ? await sharing.shareOf(story.id).catch(() => null) : null;
++ if (row && row.audience === 'people') {
++ return send(req, res, 404, {
++ title: 'A story on OpenVibe.News', description: 'A story shared with specific people on OpenVibe.News.',
++ decision: pageDecision(req.path, { indexable: false }),
++ body: pages.sharedGate({ id: story.id, signedInAs: req.viewer && req.viewer.user && req.viewer.user.username ? req.viewer.user.username : null,
++ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }),
++ });
++ }
++ return notFound(req, res);
++ }
++ // A published story the owner narrowed: the share row decides, editors aside. A story from before
++ // sharing has no row, and was public.
++ const publishedRow = sharing ? await sharing.shareOf(story.id).catch(() => null) : null;
++ if (!editor && publishedRow && !await sharing.canView(req.viewer, story.id).catch(() => false)) {
++ const row = await sharing.shareOf(story.id).catch(() => null);
++ if (row && row.audience === 'people') {
++ return send(req, res, 404, {
++ title: 'A story on OpenVibe.News', description: 'A story shared with specific people on OpenVibe.News.',
++ decision: pageDecision(req.path, { indexable: false }),
++ body: pages.sharedGate({ id: story.id, signedInAs: req.viewer && req.viewer.user && req.viewer.user.username ? req.viewer.user.username : null,
++ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` }),
++ });
++ }
return notFound(req, res);
}
if (asJson) {
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 745f559..55f0ff0 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -10,6 +10,8 @@
const ssr = require('openvibe-publishing/ssr');
const { html: h, raw } = ssr;
++const kit = require('openvibe-shared/kit');
++const ovServe = require('openvibe-shared/serve');
const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);
const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));
@@@ -194,6 +196,7 @@ ${raw(notices.join(''))}
<h1>${m.headline}</h1>
<p class="byline">${m.authors.length ? h`By ${m.authors.map((a) => a.name).join(', ')} · ` : ''}OpenVibe.News${m.first_published_at ? h` · ${time(Date.parse(m.first_published_at))}` : ''}${m.rev.number > 1 && m.first_published_at && Date.parse(m.updated_at) > Date.parse(m.first_published_at) + 60000 ? h` · updated ${time(Date.parse(m.updated_at))}` : ''}${m.topic ? h` · <a href="/topics/${m.topic.slug}">${m.topic.name}</a>` : ''}${m.story.state !== 'published' && m.story.state !== 'retracted' ? h` <span class="badge">${m.story.state}</span>` : ''}</p>
${m.disclosure ? h`<p class="disclosure" role="note"><strong>${m.disclosure.short}.</strong> ${m.disclosure.long}</p>` : ''}
++<p class="story-share">${raw(kit.share({ title: m.headline, url: m.url, text: m.paragraphs[0] ? m.paragraphs[0].text.slice(0, 200) : '', kind: 'story', id: m.story.id }))}</p>
</header>
<div class="story-body">${m.paragraphs.map((p) => h`<p${p.supported ? '' : raw(' class="unsupported"')}>${p.text} ${cites(p.sources)}${p.supported ? '' : h` <span class="badge">source removed</span>`}</p>`)}</div>
<p class="meta">Numbers in brackets point to the sources below. Headlines, outlets, authors and dates in the table are as each source published them; the text above is written by OpenVibe.News.</p>
@@@ -208,8 +211,25 @@ ${m.corrections.length || m.retraction ? h`<ol>${m.corrections.map((c) => h`<li>
${raw(comments ? commentsSection(comments, { path: m.path, csrf, signedIn, loginUrl }) : '')}`);
}
++/**
++ * The page a person who is not on the list sees (design 2026-11-06): the audience, never the
++ * headline, the text or the sources. OVShare.gate offers "Ask for access" when signed in.
++ */
++function sharedGate({ id, signedInAs = null, signInUrl = '/auth/login' }) {
++ return String(h`<section class="message">
++<h1>A story on OpenVibe.News</h1>
++<p>This is shared with specific people.</p>
++<div id="share-gate" data-ov-share-gate data-kind="story" data-id="${String(id)}" data-signed-in-as="${signedInAs || ''}" data-sign-in-url="${signInUrl}">
++<p>This story is shared with specific people on OpenVibe.News.</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 = { home, topicsIndex, topicPage, storyPage, commentsSection, message, storyList, time, dateLabel, cites, httpHref, wireSection, wirePage };
++module.exports = { home, topicsIndex, topicPage, storyPage, commentsSection, sharedGate, message, storyList, time, dateLabel, cites, httpHref, wireSection, wirePage };
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..0c6987e
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,192 @@
++'use strict';
++
++/**
++ * Sharing for News stories 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 story, 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 News' own outbox, so share.* envelopes go out with every news.* event
++ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
++ * - onAudienceChange keeps news_stories.audience in step (public→public, link→public but unlisted in intent? no: the
++ * story page reads the share row; the column mirrors public/private for the lists)
++ * - onOwnerChange moves news_stories.created_by to the new owner
++ *
++ * A draft is Only me. The sheet on it can name someone ("look at this before I post it"); the
++ * story page at /stories/:slug is the link, and it answers with the gate, never the headline or the
++ * text, to anyone not on the list. A published story is Everyone unless its owner narrows it.
++ */
++const { createShares } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++
++const SERVICE = 'news';
++const KIND = 'story';
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++/** News' visibility words to the SDK's, and back. */
++const TO_SDK = { public: 'public', unlisted: 'link', private: 'private' };
++const FROM_SDK = { public: 'public', link: 'public', people: 'private', private: 'private' };
++
++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
++
++/** Blog's viewer as the SDK wants it: a subject object, and the staff bit. */
++function shareViewer(v) {
++ if (!v || !v.subject) return null;
++ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };
++ if (String(v.subject).startsWith('gst_')) return { subject: { type: 'guest', id: v.subject }, staff: !!v.staff };
++ return null;
++}
++
++function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
++ async function publish(envelope) { await outbox.emit(envelope); }
++ 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 story was shared with you', 'Open it on OpenVibe.News.'],
++ 'share.access_requested': ['Someone asked for access to your story', 'Review the request on OpenVibe.News.'],
++ 'share.transfer.requested': ['Someone wants to give you a story', 'Accept or decline it on OpenVibe.News.'],
++ 'share.transfer.answered': ['A story transfer was answered', 'See what happened on OpenVibe.News.'],
++ }[n.kind] || ['Sharing on OpenVibe.News', '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: 'news', url: n.ref && n.ref.id ? `${config.baseUrl}/stories/${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 column the lists read in step (a story page reads the row itself). */
++ async function onAudienceChange(ref, audience, tx) {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ const row = await tx.maybe('SELECT state FROM news_stories WHERE id = $1', [ref.id]);
++ if (!row) return;
++ await tx.exec('UPDATE news_stories SET audience = $1, updated_at = $2 WHERE id = $3', [FROM_SDK[audience] || 'private', now(), ref.id]);
++ }
++
++ 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 news_stories SET created_by = $1, updated_at = $3 WHERE id = $2', [owner, ref.id, now()]);
++ }
++
++ const shares = createShares({
++ db,
++ network: { json: networkJson },
++ events: { publish },
++ 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)]);
++ // News' lists read the column: publishing and unpublishing move it with the row.
++ await t.exec('UPDATE news_stories SET audience = $1, updated_at = $2 WHERE id = $3', [FROM_SDK[next] || 'private', now(), 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 story = await db.maybe('SELECT id, slug, working_headline, state FROM news_stories WHERE id = $1', [ref.id]);
++ if (!story) return {};
++ return {
++ title: story.working_headline || 'Untitled story',
++ link: `${String(config.baseUrl || '').replace(/\/+$/, '')}/stories/${story.slug}`,
++ 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 caf7729..a0c59d3 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -32,6 +32,8 @@ const B = 'usr_01JZ0000000000000000000BBB';
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion News applied',
news_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",
++ share_members: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
++ share_access_requests: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
},
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
ddiff --git a/test/share-gate-chrome.test.js b/test/share-gate-chrome.test.js
nnew file mode 100644
iindex 0000000..d61d871
---- /dev/null
++++ b/test/share-gate-chrome.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++/**
++ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]):
++ * markup only; signed out it offers Sign in, a signed-in stranger is offered Ask for access, and the
++ * page has no console error and no CSP refusal. Skipped without Chrome, or with OV_SKIP_BROWSER=1.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const { boot, check, done, launchReports } = 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 sam = t.network.addUser('sam');
++ const reports = launchReports(t.sources, { a: { fields: { body: 'Report A' } } });
++ await t.pull();
++ const clusterId = (await t.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id)).cluster_id;
++ const made = await t.api('/stories', { json: { cluster: clusterId, headline: 'The LOOI bridge notes' } });
++ assert.strictEqual(made.status, 201, made.text);
++ const story = made.json().story;
++ const rev = await t.api(`/stories/${story.id}/revisions`, { json: { paragraphs: [{ text: 'SECRET-LOOI text about a bridge and the words around it. [1]', sources: [1] }], expected_revision: 0 } });
++ assert.strictEqual(rev.status, 201, rev.text);
++ await t.ctx.sharing.shares.setAudience(
++ { subject: { type: 'user', id: t.editor.subject } }, { service: 'news', kind: 'story', id: story.id }, 'people',
++ );
++ const url = `${t.base}/stories/${story.slug}`;
++
++ let browser;
++ const errors = [];
++ const watch = (p) => {
++ p.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
++ p.on('pageerror', (e) => errors.push(String(e)));
++ };
++ const settled = async (p) => {
++ await p.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });
++ return await p.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());
++ };
++
++ try {
++ await check('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 p = await ctx.newPage();
++ watch(p);
++ const res = await p.goto(url, { waitUntil: 'load', timeout: 15000 });
++ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the story');
++ const text = await settled(p);
++ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);
++ const href = await p.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));
++ assert.match(href, /^\/auth\/login\?next=/);
++ await ctx.close();
++ });
++
++ 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 p = await ctx.newPage();
++ watch(p);
++ await p.goto(url, { waitUntil: 'load', timeout: 15000 });
++ const text = await settled(p);
++ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);
++ assert.ok(/@sam/.test(text), 'the gate names the account it is signed in as');
++ await ctx.close();
++ });
++
++ await check('headless Chrome: no console error and no CSP refusal', async () => {
++ assert.deepStrictEqual(errors.filter((e) => REFUSED.test(e)), []);
++ });
++ } finally {
++ if (browser) await browser.close().catch(() => {});
++ await t.close();
++ done();
++ }
++})();
ddiff --git a/test/story-sharing.test.js b/test/story-sharing.test.js
nnew file mode 100644
iindex 0000000..3d32783
---- /dev/null
++++ b/test/story-sharing.test.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * Sharing on News stories (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; openvibe-sdk/share).
++ * A draft is Only me; its editor can name someone ("look at this before I post it"). The story page
++ * answers a stranger with the gate, never the headline or the text. Published stories are Everyone.
++ */
++const assert = require('assert');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const reader = t.network.addUser('rita');
++ const mate = t.network.addUser('milo');
++ const stranger = t.network.addUser('sam');
++ const share = (id, o = {}) => t.get(`/api/v1/story/${id}/share`, { as: t.editor, headers: { 'x-requested-with': 'ov-share' }, ...o });
++ const putShare = (id, body) => share(id, { method: 'PUT', json: body });
++
++ const reports = launchReports(t.sources, { a: { fields: { body: 'Report A' } } });
++ await t.pull();
++ const clusterId = (await t.db().prepare('SELECT cluster_id FROM news_source_items WHERE sources_item_id = ?').get(reports.a.id)).cluster_id;
++ const make = async () => {
++ const r = await t.api('/stories', { json: { cluster: clusterId, headline: 'The LOOI bridge notes' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const story = r.json().story;
++ const rev = await t.api(`/stories/${story.id}/revisions`, { json: { paragraphs: [{ text: 'SECRET-LOOI text about a bridge and the words around it. [1]', sources: [1] }], expected_revision: 0 } });
++ assert.strictEqual(rev.status, 201, rev.text);
++ return story;
++ };
++ const row = async (id) => await t.db().prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('news', 'story', id);
++
++ await check('a new story is a draft, registered Only me and owned by its editor', async () => {
++ const story = await make();
++ const s = await row(story.id);
++ assert.strictEqual(s.audience, 'private');
++ assert.deepStrictEqual(s.owner_subject, { type: 'user', id: t.editor.subject });
++ assert.strictEqual((await t.get(`/stories/${story.slug}`, { as: t.editor })).status, 200);
++ assert.strictEqual((await t.get(`/stories/${story.slug}`, { as: reader })).status, 404);
++ assert.strictEqual((await t.get(`/stories/${story.slug}`)).status, 404);
++ });
++
++ await check('Specific people: the named person reads the draft; a stranger gets the gate and no headline', async () => {
++ const story = await make();
++ const put = await putShare(story.id, { audience: 'people', members: [{ subject: { type: 'user', id: reader.subject }, role: 'viewer' }] });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.strictEqual(put.json().audience, 'people');
++ const member = await t.get(`/stories/${story.slug}`, { as: reader });
++ assert.strictEqual(member.status, 200);
++ assert.ok(member.text.includes('SECRET-LOOI'));
++ const strangerPage = await t.get(`/stories/${story.slug}`, { as: stranger });
++ assert.strictEqual(strangerPage.status, 404);
++ assert.ok(strangerPage.text.includes('shared with specific people') && strangerPage.text.includes('data-ov-share-gate'));
++ assert.ok(!strangerPage.text.includes('The LOOI bridge notes'), 'no headline');
++ const anon = await t.get(`/stories/${story.slug}`);
++ assert.strictEqual(anon.status, 404);
++ assert.ok(anon.text.includes('/auth/login?next='));
++ assert.strictEqual((await t.get(`/api/v1/story/${story.id}/share`, { as: stranger })).status, 404, 'the sheet is the editor’s');
++ });
++
++ await check('the sheet offers the four audiences; the editor keeps reading the draft either way', async () => {
++ const story = await make();
++ const sheet = await share(story.id);
++ assert.strictEqual(sheet.status, 200);
++ assert.deepStrictEqual(sheet.json().audiences, ['public', 'link', 'people', 'private']);
++ await putShare(story.id, { audience: 'private' });
++ assert.strictEqual((await row(story.id)).audience, 'private');
++ assert.strictEqual((await t.get(`/stories/${story.slug}`, { as: t.editor })).status, 200);
++ assert.strictEqual((await t.get(`/stories/${story.slug}`, { as: mate })).status, 404);
++ });
++
++ await done();
++ await t.close();
++})();