openvibers/openvibe.blog Commits

Blog: listings show only posts the viewer may open, batched sitemap reviews, bounded paging, case-insensitive nginx guards (#20)

OP OpenVibers committed 10 Oct 2026 · dc292395af · 11 files, +280 −30

Changes

ddiff --git a/deploy/nginx/openvibe.blog.conf b/deploy/nginx/openvibe.blog.conf
iindex b80bed1..f19dbfc 100644
---- a/deploy/nginx/openvibe.blog.conf
++++ b/deploy/nginx/openvibe.blog.conf
@@@ -13,6 +13,7 @@
limit_req_zone $binary_remote_addr zone=ovblog_api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=ovblog_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=ovblog_write:10m rate=5r/s;
++limit_req_zone $binary_remote_addr zone=ovblog_crawl:10m rate=3r/s;
server {
listen 80;
@@@ -100,13 +101,28 @@ server {
}
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4810/metrics) ──
-- location = /metrics { return 404; }
++ # Case-insensitive: Express routes case-insensitively, so /Metrics must not slip past the block.
++ location ~* ^/metrics(/|$) { return 404; }
# ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
# Events posts to the service's loopback port directly; never proxied from outside (ADR-033).
-- location /internal/ { return 404; }
++ # Case-insensitive for the same reason: /Internal/events must not reach the app.
++ location ~* ^/internal(/|$) { return 404; }
-- # ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──
++ # ── Crawl artifacts: each request is built from the whole public corpus, so bound the rate ──
++ location /sitemap {
++ limit_req zone=ovblog_crawl burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4810;
++ }
++
++ location = /llms-full.txt {
++ limit_req zone=ovblog_crawl burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4810;
++ }
++
++ # ── Pages, feeds, robots.txt, llms.txt, static assets ──
location / {
proxy_pass http://127.0.0.1:4810;
proxy_set_header X-Real-IP $remote_addr;
ddiff --git a/server/domain/access.js b/server/domain/access.js
iindex 6be4fc9..3b78055 100644
---- a/server/domain/access.js
++++ b/server/domain/access.js
@@@ -55,6 +55,22 @@ async function memberCanSee(store, viewer, blog, post) {
return post.author_subject === viewer.subject;
}
++/**
++ * A listing's visibility scope: the options posts.listPublished needs so a list only names posts this
++ * viewer may actually open, per memberCanSee/canReadPost — never a private or members-only post the
++ * post page would refuse (an author-level member may read their own, not another author's).
++ * editor of the blog, or staff → restricted: every members/private post
++ * author-level member → the public posts, plus that author's own members/private posts
++ * anyone else → the public posts only
++ */
++async function listingScope(store, viewer, blog) {
++ if (isStaff(viewer)) return { restricted: true };
++ const role = await effectiveRole(store, viewer, blog);
++ if (atLeast(role, 'editor')) return { restricted: true };
++ if (role === 'author' && viewer && viewer.subject) return { restrictedAuthor: viewer.subject };
++ return {};
++}
++
/**
* The entitlement seam for members-only (VIP) posts.
* decide({ subject, blog, post }) → { allow, reason } never throws: an error is a "no"
@@@ -138,4 +154,4 @@ async function canWrite(store, viewer, blog, action, post = null) {
}
}
--module.exports = { ROLE_RANK, roleOf, effectiveRole, atLeast, isStaff, memberCanSee, canReadPost, canWrite, createEntitlementChecker };
++module.exports = { ROLE_RANK, roleOf, effectiveRole, atLeast, isStaff, memberCanSee, listingScope, canReadPost, canWrite, createEntitlementChecker };
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex 255fae8..3900705 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -190,15 +190,21 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
},
/**
-- * Published posts for listings. `restricted` = also members/private posts (the viewer is a
-- * member of the blog or staff). Unlisted posts are never listed.
-- * filters: blogId, termIds (any), seriesId, authorSubject, restricted, limit, offset
++ * Published posts for listings. `restricted` = also every members/private post (the viewer is
++ * an editor of the blog or staff); `restrictedAuthor` = also the members/private posts that
++ * author wrote (an author-level member, who may read only their own). Unlisted posts are never
++ * listed. The caller picks the scope with access.listingScope so a list never names a post the
++ * same viewer's post page would refuse.
++ * filters: blogId, termIds (any), seriesId, authorSubject, restricted, restrictedAuthor, limit, offset
*/
-- async listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, limit = 20, offset = 0, order = 'recent' } = {}) {
++ async listPublished({ blogId = null, termIds = null, seriesId = null, authorSubject = null, restricted = false, restrictedAuthor = null, limit = 20, offset = 0, order = 'recent' } = {}) {
const where = ["p.state = 'published'"];
const args = [];
if (restricted) where.push("p.visibility IN ('public','members','private')");
-- else where.push("p.visibility = 'public'");
++ else if (restrictedAuthor) {
++ where.push("(p.visibility = 'public' OR (p.visibility IN ('members','private') AND p.author_subject = ?))");
++ args.push(restrictedAuthor);
++ } else where.push("p.visibility = 'public'");
if (blogId) { where.push('p.blog_id = ?'); args.push(blogId); }
if (seriesId) { where.push('p.series_id = ?'); args.push(seriesId); }
if (authorSubject) { where.push('p.author_subject = ?'); args.push(authorSubject); }
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex 25f72e1..e731416 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -44,8 +44,12 @@ function createPublication({ store, config, outbox, indexnow }) {
return rev ? await store.reviews.latest(post.id, rev.number) : null;
}
-- /** The gate's decision for a post at one revision (the published one by default). */
-- async function decide(blog, post, rev, { state } = {}) {
++ /**
++ * The gate's decision for a post at one revision (the published one by default). `review` lets a
++ * caller that already fetched reviews in one batch (sitemaps, llms-full) skip the per-post lookup;
++ * omit it and the review is read here.
++ */
++ async function decide(blog, post, rev, { state, review } = {}) {
const rec = authorshipOf(rev);
const facts = {
state: state || post.state,
@@@ -54,7 +58,7 @@ function createPublication({ store, config, outbox, indexnow }) {
text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',
noindex: Boolean(post.noindex),
};
-- if (rec) Object.assign(facts, authorship.gateFacts(rec, await reviewOf(post, rev)));
++ if (rec) Object.assign(facts, authorship.gateFacts(rec, review !== undefined ? review : await reviewOf(post, rev)));
return seo.evaluate(facts, { policy: POLICY, now: store.now() });
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex e8da776..3872b9c 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -178,13 +178,14 @@ function createApi(ctx) {
router.get('/blogs/:handle/posts', run(async (req) => {
const blog = await mustBlog(req);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
-- const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
++ // A pathological ?offset= must never reach LIMIT's OFFSET (Postgres scans the skipped rows).
++ const offset = Math.min(Math.max(parseInt(req.query.offset, 10) || 0, 0), 100000);
if (req.query.all === '1') {
if (req.viewer.kind === 'service' && !checkCapability(req.viewer.claims, 'blog.post.read').allowed) throw new ApiError(403, 'capability.denied', 'blog.post.read not granted');
if (!await isMember(blog, req.viewer)) throw new ApiError(403, 'blog.forbidden', 'Only the blog’s members can list its drafts');
return { posts: await Promise.all((await posts.listForDashboard(blog, req.viewer)).slice(offset, offset + limit).map(async (p) => await postDto(p, { full: false }))) };
}
-- const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, restricted: await isMember(blog, req.viewer), limit, offset });
++ const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, ...(await access.listingScope(store, req.viewer, blog)), limit, offset });
return { total, posts: await Promise.all(rows.map(async (p) => await postDto(p))) };
}));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex b393d01..e6ec9e6 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -31,17 +31,17 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
ORDER BY p.published_at DESC LIMIT 50000`).all();
async function postEntries() {
-- // Revisions in one query and each blog once, whatever the number of posts (no N+1).
++ // Revisions and reviews in one query each, and each blog once, whatever the number of posts
++ // (no per-post N+1: the gate's review lookup is batched, not one round-trip per post).
const rows = await publicPosts();
-- const revs = await store.revisions.getMany(rows.map((p) => ({ entityId: p.id, revision: p.published_revision })));
++ const refs = rows.map((p) => ({ entityId: p.id, revision: p.published_revision }));
++ const [revs, reviews] = await Promise.all([store.revisions.getMany(refs), store.reviews.latestMany(refs)]);
const blogById = new Map();
for (const id of new Set(rows.map((p) => p.blog_id))) blogById.set(id, await blogs.get(id));
-- const out = [];
-- for (const [i, post] of rows.entries()) {
++ return await Promise.all(rows.map(async (post, i) => {
const blog = blogById.get(post.blog_id);
-- out.push({ post, blog, rev: revs[i], decision: await publication.decide(blog, post, revs[i]) });
-- }
-- return out;
++ return { post, blog, rev: revs[i], decision: await publication.decide(blog, post, revs[i], { review: reviews[i] }) };
++ }));
}
const xml = (res, body) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);
ddiff --git a/server/http/public.js b/server/http/public.js
iindex e85b85c..6427aeb 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -63,11 +63,12 @@ function createPublicRoutes(ctx) {
}
const notFound = (req, res) => messagePage(req, res, 404, 'Not found', 'There is nothing at this address.', { href: '/', label: 'The OpenVibe blog' });
-- const isMember = async (blog, viewer) => Boolean(await access.effectiveRole(store, viewer, blog)) || access.isStaff(viewer);
++ // The highest page a listing will query: a pathological ?page= must never reach LIMIT's OFFSET.
++ const MAX_PAGE = 10000;
function pageNumber(req) {
const n = parseInt(req.query.page, 10);
-- return Number.isInteger(n) && n > 0 ? n : 1;
++ return Number.isInteger(n) && n > 0 ? Math.min(n, MAX_PAGE) : 1;
}
async function activeBlog(req) {
@@@ -103,9 +104,9 @@ function createPublicRoutes(ctx) {
async function blogFront(req, res, blog, { home = false } = {}) {
const page = pageNumber(req);
-- const restricted = await isMember(blog, req.viewer);
++ const scope = await access.listingScope(store, req.viewer, blog);
const path = publication.blogPath(blog);
-- const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });
++ const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, ...scope, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
if (pager.outOfRange && total) return notFound(req, res);
const items = await reading.listItems(rows);
@@@ -113,7 +114,7 @@ function createPublicRoutes(ctx) {
const feeds = await reading.feedsOf(blog);
// Series and categories are named when a draft or restricted post uses them: list only those
// holding a post this viewer may see in the list, so their names never leak ahead of a post.
-- const shows = async (filter) => (await posts.listPublished({ ...filter, blogId: blog.id, restricted, limit: 1 })).total > 0;
++ const shows = async (filter) => (await posts.listPublished({ ...filter, blogId: blog.id, ...scope, limit: 1 })).total > 0;
const prune = async (nodes) => {
const out = [];
for (const n of nodes) {
@@@ -146,7 +147,7 @@ function createPublicRoutes(ctx) {
series: seriesShown, categories: await prune(await reading.categoriesTree(blog)),
canWrite: await access.canWrite(store, req.viewer, blog, 'create'),
}) + (home && pager.page === 1 ? frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) : ''),
-- }, { cacheable: !restricted });
++ }, { cacheable: !scope.restricted && !scope.restrictedAuthor });
}
router.get('/', wrap(async (req, res) => await blogFront(req, res, await blogs.official(), { home: true })));
@@@ -199,8 +200,8 @@ function createPublicRoutes(ctx) {
async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty, hideEmpty = false }) {
const page = pageNumber(req);
-- const restricted = blog ? await isMember(blog, req.viewer) : false;
-- const { total, posts: rows } = await posts.listPublished({ ...filter, blogId: blog ? blog.id : null, restricted, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });
++ const scope = blog ? await access.listingScope(store, req.viewer, blog) : {};
++ const { total, posts: rows } = await posts.listPublished({ ...filter, blogId: blog ? blog.id : null, ...scope, limit: PER_PAGE, offset: (page - 1) * PER_PAGE, order });
// A series or category named only by drafts or posts this viewer cannot see does not exist for them.
if (hideEmpty && !total) return notFound(req, res);
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
@@@ -214,7 +215,7 @@ function createPublicRoutes(ctx) {
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
jsonLd: [seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : canonical })))],
body: pages.collection({ heading, intro, breadcrumbs: crumbs, items, pager, empty }),
-- }, { cacheable: !restricted });
++ }, { cacheable: !scope.restricted && !scope.restrictedAuthor });
}
const blogCrumb = (blog) => ({ name: blog.title, url: publication.blogPath(blog) });
ddiff --git a/test/discovery-batch.test.js b/test/discovery-batch.test.js
nnew file mode 100644
iindex 0000000..baf29c7
---- /dev/null
++++ b/test/discovery-batch.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++/**
++ * The sitemap and llms-full routes build their gate decisions from the whole public corpus per
++ * request (roadmap hardening): each post's review must be fetched in ONE batched query, never one
++ * round-trip per post — an O(posts) sequential lookup that 40 concurrent /sitemap.xml requests would
++ * turn into tens of thousands of queries. This pins that no per-post review lookup happens while the
++ * sitemap is built (the decision batch is passed in), and that the sitemap is still correct.
++ *
++ * node test/discovery-batch.test.js
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
++
++(async () => {
++ const t = await boot();
++ const carol = t.network.addUser('carol');
++ await t.get('/api/v1/blogs', { as: carol, json: {} });
++ const posts = [];
++ for (const title of ['First Post', 'Second Post', 'Third Post']) {
++ const p = (await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title, body: `${title} ${LONG}`, visibility: 'public' } })).json().post;
++ await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });
++ posts.push(p);
++ }
++
++ // Count the per-post review lookup the route used to make (publication.decide → reviewOf → latest).
++ let perPostLookups = 0;
++ const realLatest = t.ctx.store.reviews.latest.bind(t.ctx.store.reviews);
++ t.ctx.store.reviews.latest = (...args) => { perPostLookups++; return realLatest(...args); };
++
++ await check('the sitemap builds every decision without a per-post review lookup', async () => {
++ perPostLookups = 0;
++ const r = await t.get('/sitemaps/posts.xml');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 120));
++ assert.strictEqual(perPostLookups, 0, `per-post review lookups: ${perPostLookups}`);
++ for (const p of posts) assert.ok(r.text.includes(`/@carol/${p.slug}`), `sitemap names ${p.slug}`);
++ });
++
++ await check('llms-full.txt likewise, and still lists the posts', async () => {
++ perPostLookups = 0;
++ const r = await t.get('/llms-full.txt');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 120));
++ assert.strictEqual(perPostLookups, 0, `per-post review lookups: ${perPostLookups}`);
++ assert.ok(r.text.includes('First Post') && r.text.includes('Second Post') && r.text.includes('Third Post'));
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/nginx-internal-guard.test.js b/test/nginx-internal-guard.test.js
nnew file mode 100644
iindex 0000000..a9199d5
---- /dev/null
++++ b/test/nginx-internal-guard.test.js
@@@ -0,0 +1,21 @@
++'use strict';
++// deploy/nginx/openvibe.blog.conf: Express routes case-insensitively, so the nginx guards that keep the
++// internal-only paths off the public vhost must match case-insensitively too — otherwise /Metrics and
++// /Internal/events fall through to `location /` and reach the Node app (answered 404/403 there today, but
++// the nginx guard is what should be protecting them). The corpus-built crawl artifacts are rate-limited.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const conf = fs.readFileSync(path.join(__dirname, '..', ...'deploy/nginx/openvibe.blog.conf'.split('/')), 'utf8');
++
++assert.match(conf, /location ~\* \^\/metrics\(\/\|\$\) \{ return 404; \}/, 'the /metrics guard is case-insensitive');
++assert.match(conf, /location ~\* \^\/internal\(\/\|\$\) \{ return 404; \}/, 'the /internal/ guard is case-insensitive');
++assert.ok(!conf.includes('location = /metrics'), 'no case-sensitive exact /metrics block');
++assert.ok(!conf.includes('location /internal/ {'), 'no case-sensitive /internal/ prefix block');
++
++assert.match(conf, /limit_req_zone \$binary_remote_addr zone=ovblog_crawl:10m rate=3r\/s;/);
++assert.match(conf, /location \/sitemap \{[\s\S]*?limit_req zone=ovblog_crawl[^;]*;[\s\S]*?proxy_pass http:\/\/127\.0\.0\.1:4810;/, 'the sitemap is rate-limited');
++assert.match(conf, /location = \/llms-full\.txt \{[\s\S]*?limit_req zone=ovblog_crawl[^;]*;/, 'llms-full.txt is rate-limited');
++
++console.log('nginx internal guard: /Metrics and /Internal/events blocked case-insensitively; crawl artifacts rate-limited');
ddiff --git a/test/pagination-bounds.test.js b/test/pagination-bounds.test.js
nnew file mode 100644
iindex 0000000..e04b705
---- /dev/null
++++ b/test/pagination-bounds.test.js
@@@ -0,0 +1,41 @@
++'use strict';
++/**
++ * Listings bound their page/offset (roadmap hardening): a pathological ?page= / ?offset= is clamped
++ * before it reaches LIMIT/OFFSET — a huge OFFSET makes Postgres scan the skipped rows, and a value
++ * past int4 is a query error answered as a 500. A page past the last still answers 404 (outOfRange).
++ *
++ * node test/pagination-bounds.test.js
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const HUGE = '99999999999999999999';
++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
++
++(async () => {
++ const t = await boot();
++ const carol = t.network.addUser('carol');
++ await t.get('/api/v1/blogs', { as: carol, json: {} });
++ const p = (await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: 'Only Post', body: LONG, visibility: 'public', tags: ['onlytag'] } })).json().post;
++ await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });
++
++ await check('a page far past the end answers 404, not a query error', async () => {
++ const r = await t.get(`/@carol?page=${HUGE}`);
++ assert.strictEqual(r.status, 404, `page=${HUGE} → ${r.status} ${r.text.slice(0, 120)}`);
++ });
++
++ await check('the same on a collection page', async () => {
++ const r = await t.get(`/@carol/tags/onlytag?page=${HUGE}`);
++ assert.strictEqual(r.status, 404, `tag page: ${r.status}`);
++ });
++
++ await check('a huge API offset is clamped, not sent to LIMIT/OFFSET', async () => {
++ const r = await t.get(`/api/v1/blogs/carol/posts?offset=${HUGE}`);
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.strictEqual(r.json().total, 1);
++ assert.deepStrictEqual(r.json().posts, []);
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-list-visibility.test.js b/test/security-list-visibility.test.js
nnew file mode 100644
iindex 0000000..e40e0d5
---- /dev/null
++++ b/test/security-list-visibility.test.js
@@@ -0,0 +1,94 @@
++'use strict';
++/**
++ * A blog listing must never name a post the same viewer's post page refuses (roadmap WS-R task 5, the
++ * list-vs-read class). listPublished's `restricted` flag used to be "is a member at all" (owner/editor/
++ * author), while the read check (memberCanSee) admits only an editor+ or the post's own author — so an
++ * author-level member saw every private and members-only post's title, author and — with no summary —
++ * a body-prefix excerpt on /@blog, the API and tag pages, while the post page itself answered 404/403.
++ * The list now takes access.listingScope: editor+ (and staff) see every restricted post, an
++ * author-level member only their own, matching canReadPost.
++ *
++ * node test/security-list-visibility.test.js
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
++// Body-only sentinels (words absent from the titles): a listing with no summary renders the body prefix,
++// so the leak the review found surfaces these even when a title check alone would not.
++const PRIV_MARKER = 'private-board-minutes-body-marker';
++const MEM_MARKER = 'members-only-post-body-marker';
++
++(async () => {
++ // No entitlement service: the members-only post is readable only by the blog's members (and staff).
++ const t = await boot({ entitlementCheck: async () => false });
++ const carol = t.network.addUser('carol');
++ const dave = t.network.addUser('dave');
++ const erin = t.network.addUser('erin');
++ const boss = t.network.addUser('boss', { role: 'admin' });
++ await t.get('/api/v1/blogs', { as: carol, json: {} });
++
++ const make = async (as, title, visibility, { secret = '', ...extra } = {}) => {
++ const p = (await t.get('/api/v1/blogs/carol/posts', { as, json: { title, body: `${secret} ${title} ${LONG}`, visibility, ...extra } })).json().post;
++ const r = await t.get(`/api/v1/posts/${p.id}/publish`, { as, json: {} });
++ assert.strictEqual(r.status, 200, r.text);
++ return p;
++ };
++
++ // Carol's restricted posts carry no summary, so a leak would surface the body-only secret; a public
++ // post is the control.
++ const pub = await make(carol, 'Open Post', 'public', { tags: ['pubtag'], secret: 'public-control' });
++ const priv = await make(carol, 'Board Minutes', 'private', { tags: ['boardtag'], secret: PRIV_MARKER });
++ const mem = await make(carol, 'Backstage Notes', 'members', { entitlement_key: 'vip:carol', tags: ['boardtag'], secret: MEM_MARKER });
++ assert.ok(pub.id && priv.id && mem.id);
++
++ const addMember = (subject, role) => t.get(`/api/v1/blogs/carol/members/${subject}`, { as: carol, method: 'PUT', json: { role } });
++ await addMember(dave.subject, 'author');
++ await addMember(erin.subject, 'editor');
++
++ const RESTRICTED = ['Board Minutes', 'Backstage Notes'];
++ const leaked = (text) => RESTRICTED.some((s) => text.includes(s)) || text.includes(PRIV_MARKER) || text.includes(MEM_MARKER);
++
++ await check('an author-level member is refused the post, and no list names it or excerpts its body', async () => {
++ assert.strictEqual((await t.get(`/@carol/${priv.slug}`, { as: dave })).status, 404, 'the private post is refused');
++ assert.strictEqual((await t.get(`/@carol/${mem.slug}`, { as: dave })).status, 403, 'the members post is refused');
++ for (const p of ['/@carol', '/@carol/tags/boardtag', '/api/v1/blogs/carol/posts']) {
++ const r = await t.get(p, { as: dave });
++ assert.ok(!leaked(r.text), `${p}: names a restricted post or excerpts its body`);
++ }
++ assert.ok((await t.get('/@carol', { as: dave })).text.includes('Open Post'), 'the public post is still listed');
++ });
++
++ await check('the owner, an editor and staff see every restricted post in the list (control)', async () => {
++ for (const [who, as] of [['owner', carol], ['editor', erin], ['staff', boss]]) {
++ const r = await t.get('/@carol', { as });
++ assert.ok(RESTRICTED.every((s) => r.text.includes(s)), `${who} should see the restricted posts`);
++ }
++ });
++
++ await check('an author-level member sees their own restricted post, and no other author\'s', async () => {
++ const own = await make(dave, 'Dave Private', 'private');
++ const seen = (await t.get('/@carol', { as: dave })).text;
++ assert.ok(seen.includes('Dave Private'), 'their own private post is listed to them');
++ assert.ok(!leaked(seen), 'another author\'s restricted posts are not');
++
++ const frank = t.network.addUser('frank');
++ await addMember(frank.subject, 'author');
++ const other = await t.get('/@carol', { as: frank });
++ assert.ok(!other.text.includes('Dave Private'), 'a peer author does not see Dave\'s private post');
++ assert.strictEqual((await t.get(`/@carol/${own.slug}`, { as: frank })).status, 404);
++ });
++
++ await check('a signed-in non-member and an anonymous reader see only the public post', async () => {
++ const reader = t.network.addUser('reader');
++ for (const [who, as] of [['reader', reader], ['anonymous', undefined]]) {
++ for (const p of ['/@carol', '/api/v1/blogs/carol/posts']) {
++ const r = await t.get(p, { as });
++ assert.ok(!leaked(r.text), `${p} (${who})`);
++ }
++ }
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });