openvibers/openvibe.news Commits
News: the four audiences on stories and drafts (sharing step 4) (#217)
Changes
@@@ -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 -->@@@ -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": [nnew file mode 100644@@@ -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 $$;nnew file mode 100644@@@ -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;@@@ -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",@@@ -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": {@@@ -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 };@@@ -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 ────────────────@@@ -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 = nullawait 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 = nullconst 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 = nullif (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 };});},@@@ -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]) });@@@ -42,7 +42,7 @@ const HOME_DESCRIPTION = 'News you can check: AI briefs and stories where everyconst 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) {@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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(', ')}`);nnew file mode 100644@@@ -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();++ }++})();nnew file mode 100644@@@ -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();++})();
@@ -449,6 +449,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -449,6 +449,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.91.0
- openvibe-sdk: v0.91.0
-- openvibe-shared: v4.122.0
+- openvibe-shared: v4.123.0
- openvibe-publishing: v1.4.1
- openvibe-publishing: v1.4.1
<!-- versions:end -->
<!-- versions:end -->
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"domain": "openvibe.news",
"domain": "openvibe.news",
"contracts": "openvibe-contracts v0.289.1",
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.91.0",
"sdk": "openvibe-sdk v0.91.0",
- "shared": "openvibe-shared v4.122.0",
+ "shared": "openvibe-shared v4.123.0",
"packages": [
"packages": [
"openvibe-publishing v1.4.1",
"openvibe-publishing v1.4.1",
"openvibe-contracts v0.289.1",
"openvibe-contracts v0.289.1",
- "openvibe-shared v4.122.0",
+ "openvibe-shared v4.123.0",
"openvibe-sdk v0.91.0"
"openvibe-sdk v0.91.0"
],
],
"capabilities": [
"capabilities": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -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 $$;
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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;
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1210,9 +1210,9 @@
@@ -1210,9 +1210,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.122.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",
+ "version": "4.123.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
+ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -26,8 +26,25 @@ const TABLES = [
@@ -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') },
{ 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 } = {}) {
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 };
module.exports = { create, TABLES, TOPICS };
@@ -33,6 +33,7 @@ const { createDiscussion } = require('./domain/discussion');
@@ -33,6 +33,7 @@ const { createDiscussion } = require('./domain/discussion');
const { createClusters } = require('./domain/clusters');
const { createClusters } = require('./domain/clusters');
const { createIngest } = require('./domain/source-items');
const { createIngest } = require('./domain/source-items');
const { createStories } = require('./domain/stories');
const { createStories } = require('./domain/stories');
+const { createSharing, describeRef } = require('./sharing');
const { createTopics } = require('./domain/topics');
const { createTopics } = require('./domain/topics');
const { createReading } = require('./domain/reading');
const { createReading } = require('./domain/reading');
const { createPublicRoutes } = require('./http/public');
const { createPublicRoutes } = require('./http/public');
@@ -84,7 +85,9 @@ async function createApp(opts = {}) {
@@ -84,7 +85,9 @@ async function createApp(opts = {}) {
const community = createCommunity({ store, config, fetchImpl, log });
const community = createCommunity({ store, config, fetchImpl, log });
const discussion = createDiscussion({ store, publication, community, log });
const discussion = createDiscussion({ store, publication, community, log });
const ingest = createIngest({ store, config, clusters, outbox, sources, discussion, 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);
ingest.setStories(stories);
const topics = createTopics({ store });
const topics = createTopics({ store });
// Topics are the only seed (idempotent: missing slugs are added, existing ones never changed).
// Topics are the only seed (idempotent: missing slugs are added, existing ones never changed).
@@ -107,7 +110,7 @@ async function createApp(opts = {}) {
@@ -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 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 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();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// 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 = {}) {
@@ -206,6 +209,18 @@ async function createApp(opts = {}) {
}));
}));
// ── API ─────────────────────────────────────────────────
// ── 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));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
// ── Discovery, editor desk, public pages ────────────────
// ── Discovery, editor desk, public pages ────────────────
@@ -55,7 +55,7 @@ function formatBody(paragraphs) {
@@ -55,7 +55,7 @@ function formatBody(paragraphs) {
return (paragraphs || []).map((p) => `${p.text}${p.sources && p.sources.length ? ` [${p.sources.join(', ')}]` : ''}`).join('\n\n');
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 { db } = store;
const q = {
const q = {
byId: db.prepare('SELECT * FROM news_stories WHERE id = ?'),
byId: db.prepare('SELECT * FROM news_stories WHERE id = ?'),
@@ -270,7 +270,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -270,7 +270,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
/** Published and retracted stories (retracted ones stay listed with their notice). */
/** Published and retracted stories (retracted ones stay listed with their notice). */
async listPublished({ topicId = null, limit = 20, offset = 0 } = {}) {
async listPublished({ topicId = null, limit = 20, offset = 0 } = {}) {
- const where = ["state IN ('published','retracted')"];
+ const where = ["state IN ('published','retracted')", "audience = 'public'"];
const args = [];
const args = [];
if (topicId) { where.push('topic_id = ?'); args.push(topicId); }
if (topicId) { where.push('topic_id = ?'); args.push(topicId); }
const w = where.join(' AND ');
const w = where.join(' AND ');
@@ -308,6 +308,8 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -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)
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);
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);
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) {
if (cluster) {
const items = (await clusters.members(cluster.id)).filter((it) => it.status === 'active')
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));
.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
@@ -511,6 +513,8 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
const before = publication.snapshot(cur);
const before = publication.snapshot(cur);
await db.prepare(`UPDATE news_stories SET state = 'published', published_revision = ?, first_published_at = COALESCE(first_published_at, ?),
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);
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;
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'))) {
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);
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
@@ -530,6 +534,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
if (discussion) await discussion.watch(story.id);
if (discussion) await discussion.watch(story.id);
const before = publication.snapshot(cur);
const before = publication.snapshot(cur);
await db.prepare("UPDATE news_stories SET state = 'unpublished', updated_at = ? WHERE id = ?").run(store.now(), story.id);
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 };
return { changed: true, story: (await publication.afterChange(before, story.id, { actor: viewer, traceparent })).story };
});
});
},
},
@@ -41,7 +41,7 @@ function createDiscoveryRoutes(ctx) {
@@ -41,7 +41,7 @@ function createDiscoveryRoutes(ctx) {
async function storyEntries() {
async function storyEntries() {
// Revisions in one query, whatever the number of stories (no N+1).
// 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 revs = await store.revisions.getMany(rows.map((s) => ({ entityId: s.id, revision: s.published_revision })));
const out = [];
const out = [];
for (const [i, story] of rows.entries()) out.push({ story, rev: revs[i], decision: await publication.decide(story, revs[i]) });
for (const [i, story] of rows.entries()) out.push({ story, rev: revs[i], decision: await publication.decide(story, revs[i]) });
@@ -42,7 +42,7 @@ const HOME_DESCRIPTION = 'News you can check: AI briefs and stories where every
@@ -42,7 +42,7 @@ const HOME_DESCRIPTION = 'News you can check: AI briefs and stories where every
const PER_PAGE = 20;
const PER_PAGE = 20;
function createPublicRoutes(ctx) {
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 homeData = ctx.home || createHome({ store });
const router = express.Router();
const router = express.Router();
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
@@ -319,8 +319,41 @@ function createPublicRoutes(ctx) {
@@ -319,8 +319,41 @@ function createPublicRoutes(ctx) {
const asJson = req.params.slug.endsWith('.json');
const asJson = req.params.slug.endsWith('.json');
const slug = asJson ? req.params.slug.slice(0, -5) : req.params.slug;
const slug = asJson ? req.params.slug.slice(0, -5) : req.params.slug;
const story = await stories.bySlug(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);
return notFound(req, res);
}
}
if (asJson) {
if (asJson) {
@@ -10,6 +10,8 @@
@@ -10,6 +10,8 @@
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const { html: h, raw } = 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 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 }));
const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));
@@ -194,6 +196,7 @@ ${raw(notices.join(''))}
@@ -194,6 +196,7 @@ ${raw(notices.join(''))}
<h1>${m.headline}</h1>
<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>
<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>` : ''}
${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>
</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>
<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>
<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>
@@ -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 }) : '')}`);
${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 }) {
function message({ heading, text, action }) {
return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
return String(h`<section class="message"><h1>${heading}</h1><p>${text}</p>${action ? h`<p><a class="button" href="${action.href}">${action.label}</a></p>` : ''}</section>`);
}
}
-module.exports = { 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -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 };
@@ -32,6 +32,8 @@ const B = 'usr_01JZ0000000000000000000BBB';
@@ -32,6 +32,8 @@ const B = 'usr_01JZ0000000000000000000BBB';
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion News applied',
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",
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(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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();
+ }
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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();
+})();