openvibers/openvibe.news Commits

Add personal News draft sharing defaults (#285)

OP OpenVibers committed 2 days ago · b06fc02f57 · 11 files, +168 −16

Changes

ddiff --git a/migrations/0011_story_share_defaults.sql b/migrations/0011_story_share_defaults.sql
nnew file mode 100644
iindex 0000000..7dd2825
---- /dev/null
++++ b/migrations/0011_story_share_defaults.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- Each editor's new story drafts start Only me, or Specific people until names are added in the sheet.
++CREATE TABLE IF NOT EXISTS news_share_defaults (
++ owner_subject text COLLATE "C" PRIMARY KEY,
++ audience text NOT NULL CHECK (audience IN ('private', 'people')),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 46320a7..27a4a21 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -16,6 +16,7 @@ const editorial = (why) => ({ keep: why });
const TABLES = [
{ table: 'news_story_drafts', subject: 'owner', file: 'story-drafts.json', order: 'updated_at', columns: ['entity_id', 'base_revision', 'content', 'fields', 'created_at', 'updated_at'] },
++ { table: 'news_share_defaults', subject: 'owner_subject', file: 'story-share-defaults.json', columns: ['audience', 'updated_at'] },
{ table: 'subject_projections', subject: 'subject', file: 'profile.json', order: 'refreshed_at' },
{ table: 'news_stories', subject: 'created_by', file: 'stories.json', order: 'first_published_at', columns: ['id', 'slug', 'working_headline', 'topic_id', 'state', 'first_published_at', 'published_at'], erase: editorial('published journalism keeps its editor byline as the publication record') },
{ table: 'news_perspectives', subject: 'created_by', file: 'perspectives.json', order: 'created_at', columns: ['id', 'story_id', 'label', 'description', 'position', 'created_at'], erase: editorial('a published perspective is part of the story') },
ddiff --git a/server/app.js b/server/app.js
iindex 0a5bdfe..c1005ea 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -87,7 +87,7 @@ async function createApp(opts = {}) {
const discussion = createDiscussion({ store, publication, community, log });
const ingest = createIngest({ store, config, clusters, outbox, sources, 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 sharing = opts.sharing || createSharing({ db: store.db, config, outbox, publication, 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 });
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex 41d8d8f..3bcf1a7 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -126,7 +126,7 @@ function createPublication({ store, config, outbox, indexnow }) {
const rec = authorshipOf(rev);
const facts = {
state: gateState(story, state),
-- visibility: 'public',
++ visibility: story.audience === 'public' ? 'public' : 'private',
canonicalUrl: storyUrl(story),
text: plain(rev),
citationCount: sup.independent,
@@@ -181,7 +181,7 @@ function createPublication({ store, config, outbox, indexnow }) {
}
function snapshot(story) {
-- return story ? { state: story.state, visibility: 'public', revision: story.published_revision, url: storyUrl(story) } : null;
++ return story ? { state: story.state, visibility: story.audience === 'public' ? 'public' : 'private', revision: story.published_revision, url: storyUrl(story) } : null;
}
/**
ddiff --git a/server/domain/stories.js b/server/domain/stories.js
iindex cfea50b..62bdbff 100644
---- a/server/domain/stories.js
++++ b/server/domain/stories.js
@@@ -312,6 +312,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
cluster = await clusters.resolve(cluster.id);
}
const topic = await topicFrom(input.topic);
++ const draftAudience = sharing && /^usr_/.test(String(subject)) ? await sharing.defaultAudience(subject) : 'private';
return await store.tx(async () => {
const now = store.now();
const id = newStoryId(now);
@@@ -320,7 +321,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
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 (sharing) await sharing.register({ subject: /^usr_/.test(String(subject)) ? subject : null }, id, draftAudience);
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));
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 4e6708c..a9e9d7a 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -74,7 +74,7 @@ function itemDto(it) {
}
function createApi(ctx) {
-- const { config, stories, clusters, briefs, publication, reading, topics, ingest, viewers, limits } = ctx;
++ const { config, stories, clusters, briefs, publication, reading, topics, ingest, viewers, limits, sharing } = ctx;
const router = express.Router();
router.use(cors(config.apiCorsOrigins));
router.use(viewers.middleware());
@@@ -177,6 +177,7 @@ function createApi(ctx) {
return { story: await storyDto(story), published: m ? reading.storyJson(m) : null };
}
if (!story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) throw new ApiError(404, 'story.not_found', 'No such story');
++ if (sharing && !await sharing.canView(req.viewer, story.id)) throw new ApiError(404, 'story.not_found', 'No such story');
return { published: reading.storyJson(await reading.storyModel(story)) };
}));
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex f7a633e..6a3f4d8 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -20,7 +20,7 @@ const express = require('express');
const { renderPage } = require('../render/layout');
const editor = require('../render/editor');
const { csrfToken, checkCsrf } = require('../auth/forms');
--const { asApiError } = require('./errors');
++const { ApiError, asApiError } = require('./errors');
const access = require('../domain/access');
const cache = require('openvibe-shared/cache-policy');
@@@ -28,7 +28,7 @@ const cache = require('openvibe-shared/cache-policy');
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
function createEditorRoutes(ctx) {
-- const { config, store, stories, clusters, topics, ingest, publication, viewers, publicRoutes, ai, limits } = ctx;
++ const { config, store, stories, clusters, topics, ingest, publication, viewers, publicRoutes, ai, limits, sharing } = ctx;
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '300kb' });
router.use(['/edit', '/clusters'], viewers.middleware({ services: false }), (req, res, next) => {
@@@ -84,10 +84,19 @@ function createEditorRoutes(ctx) {
clusters: await Promise.all((await clusters.recent({ limit: 40 })).map(async (c) => ({ ...c, count: (await clusters.members(c.id)).length }))),
stories: await stories.listAll({ limit: 100 }), flags, runs: await ingest.runs({ limit: 20 }), sources: await ingest.sourceStatus(),
cursor: await ingest.cursor(), csrf: csrf(req), message: flashOf(req), aiEnabled: Boolean(ai && ai.enabled),
++ shareDefault: await sharing.defaultAudience(req.viewer.subject),
pullOn: Boolean(config.worker.enabled && config.sources.pullIntervalMs),
}));
}));
++ router.post('/edit/share-default', B('news.story.revise'), wrap(async (req, res) => {
++ const b = input(req.body);
++ if (!['private', 'people'].includes(b.audience)) throw new ApiError(422, 'share.default_invalid', 'Choose Only me or Specific people for new drafts');
++ await sharing.setDefaultAudience(req.viewer.subject, b.audience);
++ const changed = b.apply === '1' ? await sharing.applyDefault(req.viewer.subject, b.audience) : 0;
++ done(res, '/edit', `Draft default saved${changed ? `; ${changed} of your drafts changed` : ''}.`);
++ }));
++
router.post('/edit/pull', B('news.ingest.pull'), wrap(async (req, res) => {
const r = await ingest.pull();
done(res, '/edit', r.ok ? `Pulled: ${JSON.stringify(r.counts)}` : `Pull failed (${r.error}): ${r.detail}`);
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 9177c9f..95b3e91 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -299,7 +299,7 @@ function createPublicRoutes(ctx) {
const group = ['outlet', 'perspective'].includes(req.query.group) ? req.query.group : 'number';
const decision = preview ? await publication.decide(story, m.rev, { state: 'draft' }) : m.decision;
const crumbs = [{ name: 'OpenVibe.News', url: '/' }, ...(m.topic ? [{ name: m.topic.name, url: publication.topicPath(m.topic) }] : []), { name: m.headline }];
-- const cacheable = !preview && (story.state === 'published' || story.state === 'retracted');
++ const cacheable = !preview && story.audience === 'public' && (story.state === 'published' || story.state === 'retracted');
const comments = preview ? null : await commentsFor(req, story, m.headline);
const signedIn = req.viewer.kind === 'user' && Boolean(req.viewer.subject);
send(req, res, 200, {
@@@ -364,7 +364,7 @@ function createPublicRoutes(ctx) {
}
if (asJson) {
const m = await reading.storyModel(story);
-- cacheHeaders(res, { cacheable: req.viewer.kind === 'anonymous', robots: m.decision.robots });
++ cacheHeaders(res, { cacheable: req.viewer.kind === 'anonymous' && story.audience === 'public', robots: m.decision.robots });
return res.json(reading.storyJson(m));
}
return await renderStory(req, res, { story });
@@@ -374,6 +374,7 @@ function createPublicRoutes(ctx) {
router.post('/stories/:slug/comments', rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false }), ctx.limits.signedIn('news.discussion.comment'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
const story = await stories.bySlug(req.params.slug);
if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) return notFound(req, res);
++ if (!access.isEditor(config, req.viewer) && sharing && !await sharing.canView(req.viewer, story.id)) return notFound(req, res);
const path = publication.storyPath(story);
if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(path)}`);
if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the story and try again.', { href: path, label: 'Back to the story' });
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex 508154a..f2ad922 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -35,10 +35,14 @@ function why(reason) {
return r.rule + (r.from ? ` from ${r.from}` : '');
}
--function dashboard({ clusters, stories, flags, runs, sources, cursor, csrf, message, aiEnabled, pullOn }) {
++function dashboard({ clusters, stories, flags, runs, sources, cursor, csrf, message, aiEnabled, pullOn, shareDefault = 'private' }) {
return String(h`<h1>Editor desk</h1>
${flash(message)}
<p class="meta">Stories are written by editors from source items. Nothing is published until an editor publishes it${aiEnabled ? '; AI drafts wait for a person’s review' : ''}.</p>
++<form method="post" action="/edit/share-default" class="card">${csrfField(csrf)}
++<label>New story drafts ${select('audience', [['private', 'Only me'], ['people', 'Specific people']], shareDefault)}</label>
++<label><input type="checkbox" name="apply" value="1"> Apply to all my existing drafts</label>
++<button type="submit">Save default</button><p class="meta">Add names in each draft’s Share sheet before others can read it.</p></form>
<section><h2>Open flags</h2>${flags.length ? h`<ul>${flags.map((f) => h`<li><a href="/edit/stories/${f.story_id}">${f.working_headline}</a> — ${f.kind.replace('_', ' ')}${f.pending_revision ? h` (pending revision ${f.pending_revision})` : ''} · ${time(f.created_at)}</li>`)}</ul>` : h`<p class="empty">None.</p>`}</section>
<section><h2>Stories</h2>${stories.length ? h`<table><thead><tr><th>Headline</th><th>State</th><th>Revision</th><th>Updated</th></tr></thead><tbody>${stories.map((s) => h`<tr><td><a href="/edit/stories/${s.id}">${s.working_headline}</a></td><td>${STATE_LABEL[s.state]}</td><td>${s.published_revision || '—'}</td><td>${time(s.updated_at)}</td></tr>`)}</tbody></table>` : h`<p class="empty">No stories yet. Open one from a cluster below.</p>`}</section>
<section><h2>Recent clusters</h2>${clusters.length ? h`<table><thead><tr><th>Cluster</th><th>Items</th><th>Latest</th></tr></thead><tbody>${clusters.map((c) => h`<tr><td><a href="/clusters/${c.id}">${c.label}</a></td><td>${c.count}</td><td>${time(c.window_end)}</td></tr>`)}</tbody></table>` : h`<p class="empty">No source items have arrived yet.</p>`}</section>
ddiff --git a/server/sharing.js b/server/sharing.js
iindex 0c6987e..af7c760 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -10,8 +10,7 @@
* - 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)
++ * - onAudienceChange keeps news_stories.audience in step; only Everyone is public to lists and Search
* - 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
@@@ -25,8 +24,8 @@ 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 TO_SDK = { public: 'public', link: 'link', unlisted: 'link', people: 'people', private: 'private' };
++const FROM_SDK = { public: 'public', link: 'private', people: 'private', private: 'private' };
const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
@@@ -38,7 +37,7 @@ function shareViewer(v) {
return null;
}
--function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
++function createSharing({ db, config, outbox, publication, 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({
@@@ -95,9 +94,13 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
/** 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]);
++ const row = await tx.maybe('SELECT * 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]);
++ if (row.state === 'published' && publication) {
++ const changed = await tx.maybe('SELECT * FROM news_stories WHERE id = $1', [ref.id]);
++ await publication.syncIndex(changed);
++ }
}
async function onOwnerChange(ref, from, to, tx) {
@@@ -118,6 +121,33 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
const sv = (viewer) => shareViewer(viewer);
const at = (id) => refOf(id);
++ async function defaultAudience(owner) {
++ const row = await db.maybe('SELECT audience FROM news_share_defaults WHERE owner_subject = $1', [owner]);
++ return row ? row.audience : 'private';
++ }
++ async function setDefaultAudience(owner, audience) {
++ if (!['private', 'people'].includes(audience)) throw new Error('Invalid story draft default');
++ await db.exec(`INSERT INTO news_share_defaults (owner_subject, audience) VALUES ($1, $2)
++ ON CONFLICT (owner_subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [owner, audience]);
++ }
++ async function applyDefault(owner, audience) {
++ if (!['private', 'people'].includes(audience)) throw new Error('Invalid story draft default');
++ const rows = await db.many(`SELECT id FROM news_stories WHERE created_by = $1 AND state IN ('draft', 'unpublished')`, [owner]);
++ let changed = 0;
++ for (const row of rows) {
++ const current = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, row.id]);
++ if (!current) {
++ await shares.register(at(row.id), { owner_subject: { type: 'user', id: owner }, audience });
++ await onAudienceChange(at(row.id), audience, db);
++ changed++;
++ } else if (current.audience !== audience) {
++ await shares.setAudience(sv({ subject: owner }), at(row.id), audience);
++ changed++;
++ }
++ }
++ return changed;
++ }
++
return {
shares,
SERVICE,
@@@ -125,6 +155,7 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
AUDIENCES,
refOf,
shareViewer,
++ defaultAudience, setDefaultAudience, applyDefault,
/** 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 },
ddiff --git a/test/story-share-defaults.test.js b/test/story-share-defaults.test.js
nnew file mode 100644
iindex 0000000..206e7e4
---- /dev/null
++++ b/test/story-share-defaults.test.js
@@@ -0,0 +1,97 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const make = async (headline) => {
++ const r = await t.api('/stories', { json: { headline } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json().story;
++ };
++ const save = (audience, apply = false, extra = {}) => t.get('/edit/share-default', {
++ as: t.editor, method: 'POST', form: { _csrf: t.csrf(t.editor), audience, ...(apply ? { apply: '1' } : {}) }, ...extra,
++ });
++ try {
++ await check('the editor desk saves a personal draft default; new drafts can override it', async () => {
++ const desk = await t.get('/edit', { as: t.editor });
++ assert.strictEqual(desk.status, 200);
++ assert.ok(desk.text.includes('Apply to all my existing drafts'));
++ assert.strictEqual(await t.ctx.sharing.defaultAudience(t.editor.subject), 'private');
++ assert.strictEqual((await save('people')).status, 303);
++ assert.strictEqual(await t.ctx.sharing.defaultAudience(t.editor.subject), 'people');
++ const draft = await make('A draft for named readers');
++ assert.strictEqual((await t.ctx.sharing.shareOf(draft.id)).audience, 'people');
++ const stranger = t.network.addUser('stranger');
++ const gate = await t.get(`/stories/${draft.slug}`, { as: stranger });
++ assert.strictEqual(gate.status, 404);
++ assert.ok(gate.text.includes('data-ov-share-gate') && !gate.text.includes(draft.working_headline));
++ const override = await t.get(`/api/v1/story/${draft.id}/share`, {
++ as: t.editor, method: 'PUT', json: { audience: 'private' }, headers: { 'x-requested-with': 'ov-share' },
++ });
++ assert.strictEqual(override.status, 200, override.text);
++ assert.strictEqual((await t.ctx.sharing.shareOf(draft.id)).audience, 'private');
++ });
++
++ await check('apply to all changes this editor’s drafts, not published or another editor’s story', async () => {
++ await save('private');
++ const own = await make('Own unfinished story');
++ const published = await make('Published editorial story');
++ const other = await make('Someone else owns this draft');
++ const nextOwner = t.network.addUser('another-editor');
++ await t.db().exec("UPDATE news_stories SET state = 'published' WHERE id = $1", [published.id]);
++ await t.ctx.sharing.syncAudience(published.id, 'public');
++ await t.db().exec('UPDATE news_stories SET created_by = $1 WHERE id = $2', [nextOwner.subject, other.id]);
++ await t.db().exec("UPDATE shares SET owner_subject = $1::jsonb WHERE service = 'news' AND kind = 'story' AND resource_id = $2",
++ [JSON.stringify({ type: 'user', id: nextOwner.subject }), other.id]);
++ const applied = await save('people', true);
++ assert.strictEqual(applied.status, 303, applied.text);
++ assert.strictEqual((await t.ctx.sharing.shareOf(own.id)).audience, 'people');
++ assert.strictEqual((await t.ctx.sharing.shareOf(published.id)).audience, 'public');
++ assert.strictEqual((await t.ctx.sharing.shareOf(other.id)).audience, 'private');
++ });
++
++ await check('invalid default and missing form token are refused', async () => {
++ assert.strictEqual((await save('public')).status, 422);
++ assert.strictEqual((await save('private', false, { form: { _csrf: 'wrong', audience: 'private' } })).status, 403);
++ });
++
++ await check('a published story leaving Everyone drops from lists and Search while its link stays readable', async () => {
++ const reports = launchReports(t.sources);
++ await t.pull();
++ const cluster = await t.db().maybe('SELECT cluster_id FROM news_source_items WHERE sources_item_id = $1', [reports.a.id]);
++ const created = await t.api('/stories', { json: { cluster: cluster.cluster_id, headline: 'Visibility Example for sharing' } });
++ assert.strictEqual(created.status, 201, created.text);
++ const story = created.json().story;
++ const body = [
++ 'NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center on Tuesday morning, starting a long cruise toward Jupiter and its icy moon Europa. [1, 2]',
++ 'The mission is scheduled to reach Jupiter in 2030, and scientists expect the probe to fly past Europa many times to study the ocean beneath its ice shell. [2, 3]',
++ ].join('\n\n');
++ const revised = await t.api(`/stories/${story.id}/revisions`, { json: { body, expected_revision: 0 } });
++ assert.strictEqual(revised.status, 201, revised.text);
++ const published = await t.api(`/stories/${story.id}/publish`, { json: {} });
++ assert.strictEqual(published.status, 200, published.text);
++ assert.ok((await t.get('/')).text.includes('Visibility Example for sharing'));
++ const narrowed = await t.get(`/api/v1/story/${story.id}/share`, {
++ as: t.editor, method: 'PUT', json: { audience: 'link' }, headers: { 'x-requested-with': 'ov-share' },
++ });
++ assert.strictEqual(narrowed.status, 200, narrowed.text);
++ assert.strictEqual((await t.db().maybe('SELECT audience FROM news_stories WHERE id = $1', [story.id])).audience, 'private');
++ assert.ok(!(await t.get('/')).text.includes('Visibility Example for sharing'));
++ assert.ok(!(await t.get('/sitemaps/stories.xml')).text.includes(story.slug));
++ const direct = await t.get(`/stories/${story.slug}`);
++ assert.strictEqual(direct.status, 200);
++ assert.ok(direct.headers.get('cache-control').includes('no-store'));
++ assert.ok(direct.text.includes('noindex'));
++ assert.ok((await t.events('news.index_document.deleted')).length >= 1);
++ const privateShare = await t.get(`/api/v1/story/${story.id}/share`, {
++ as: t.editor, method: 'PUT', json: { audience: 'people' }, headers: { 'x-requested-with': 'ov-share' },
++ });
++ assert.strictEqual(privateShare.status, 200, privateShare.text);
++ assert.strictEqual((await t.get(`/api/v1/stories/${story.id}`)).status, 404);
++ const outsider = t.network.addUser('comment-outsider');
++ assert.strictEqual((await t.get(`/stories/${story.slug}/comments`, { as: outsider, method: 'POST', form: { message: 'test' } })).status, 404);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((error) => { console.error(error); process.exit(1); });