openvibers/openvibe.blog Commits
Blog: listings show only posts the viewer may open, batched sitemap reviews, bounded paging, case-insensitive nginx guards (#20)
Changes
@@@ -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;@@@ -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 };@@@ -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); }@@@ -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() });}@@@ -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))) };}));@@@ -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);@@@ -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) });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });
@@ -13,6 +13,7 @@
@@ -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_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_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_write:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=ovblog_crawl:10m rate=3r/s;
server {
server {
listen 80;
listen 80;
@@ -100,13 +101,28 @@ server {
@@ -100,13 +101,28 @@ server {
}
}
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4810/metrics) ──
# ── 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) ──
# ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
# Events posts to the service's loopback port directly; never proxied from outside (ADR-033).
# 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 / {
location / {
proxy_pass http://127.0.0.1:4810;
proxy_pass http://127.0.0.1:4810;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
@@ -55,6 +55,22 @@ async function memberCanSee(store, viewer, blog, post) {
@@ -55,6 +55,22 @@ async function memberCanSee(store, viewer, blog, post) {
return post.author_subject === viewer.subject;
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.
* The entitlement seam for members-only (VIP) posts.
* decide({ subject, blog, post }) → { allow, reason } never throws: an error is a "no"
* 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) {
@@ -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 };
@@ -190,15 +190,21 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -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 where = ["p.state = 'published'"];
const args = [];
const args = [];
if (restricted) where.push("p.visibility IN ('public','members','private')");
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 (blogId) { where.push('p.blog_id = ?'); args.push(blogId); }
if (seriesId) { where.push('p.series_id = ?'); args.push(seriesId); }
if (seriesId) { where.push('p.series_id = ?'); args.push(seriesId); }
if (authorSubject) { where.push('p.author_subject = ?'); args.push(authorSubject); }
if (authorSubject) { where.push('p.author_subject = ?'); args.push(authorSubject); }
@@ -44,8 +44,12 @@ function createPublication({ store, config, outbox, indexnow }) {
@@ -44,8 +44,12 @@ function createPublication({ store, config, outbox, indexnow }) {
return rev ? await store.reviews.latest(post.id, rev.number) : null;
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 rec = authorshipOf(rev);
const facts = {
const facts = {
state: state || post.state,
state: state || post.state,
@@ -54,7 +58,7 @@ function createPublication({ store, config, outbox, indexnow }) {
@@ -54,7 +58,7 @@ function createPublication({ store, config, outbox, indexnow }) {
text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',
text: rev ? `${rev.fields.title || ''}\n${ssr.markdownToText(rev.content)}` : '',
noindex: Boolean(post.noindex),
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() });
return seo.evaluate(facts, { policy: POLICY, now: store.now() });
}
}
@@ -178,13 +178,14 @@ function createApi(ctx) {
@@ -178,13 +178,14 @@ function createApi(ctx) {
router.get('/blogs/:handle/posts', run(async (req) => {
router.get('/blogs/:handle/posts', run(async (req) => {
const blog = await mustBlog(req);
const blog = await mustBlog(req);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
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.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 (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');
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 }))) };
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))) };
return { total, posts: await Promise.all(rows.map(async (p) => await postDto(p))) };
}));
}));
@@ -31,17 +31,17 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
@@ -31,17 +31,17 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
ORDER BY p.published_at DESC LIMIT 50000`).all();
ORDER BY p.published_at DESC LIMIT 50000`).all();
async function postEntries() {
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 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();
const blogById = new Map();
for (const id of new Set(rows.map((p) => p.blog_id))) blogById.set(id, await blogs.get(id));
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);
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);
const xml = (res, body) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);
@@ -63,11 +63,12 @@ function createPublicRoutes(ctx) {
@@ -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 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) {
function pageNumber(req) {
const n = parseInt(req.query.page, 10);
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) {
async function activeBlog(req) {
@@ -103,9 +104,9 @@ function createPublicRoutes(ctx) {
@@ -103,9 +104,9 @@ function createPublicRoutes(ctx) {
async function blogFront(req, res, blog, { home = false } = {}) {
async function blogFront(req, res, blog, { home = false } = {}) {
const page = pageNumber(req);
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 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}`) });
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);
if (pager.outOfRange && total) return notFound(req, res);
const items = await reading.listItems(rows);
const items = await reading.listItems(rows);
@@ -113,7 +114,7 @@ function createPublicRoutes(ctx) {
@@ -113,7 +114,7 @@ function createPublicRoutes(ctx) {
const feeds = await reading.feedsOf(blog);
const feeds = await reading.feedsOf(blog);
// Series and categories are named when a draft or restricted post uses them: list only those
// 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.
// 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 prune = async (nodes) => {
const out = [];
const out = [];
for (const n of nodes) {
for (const n of nodes) {
@@ -146,7 +147,7 @@ function createPublicRoutes(ctx) {
@@ -146,7 +147,7 @@ function createPublicRoutes(ctx) {
series: seriesShown, categories: await prune(await reading.categoriesTree(blog)),
series: seriesShown, categories: await prune(await reading.categoriesTree(blog)),
canWrite: await access.canWrite(store, req.viewer, blog, 'create'),
canWrite: await access.canWrite(store, req.viewer, blog, 'create'),
}) + (home && pager.page === 1 ? frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) : ''),
}) + (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 })));
router.get('/', wrap(async (req, res) => await blogFront(req, res, await blogs.official(), { home: true })));
@@ -199,8 +200,8 @@ function createPublicRoutes(ctx) {
@@ -199,8 +200,8 @@ function createPublicRoutes(ctx) {
async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty, hideEmpty = false }) {
async function collection(req, res, { blog, heading, intro, crumbs, path, filter, order, empty, hideEmpty = false }) {
const page = pageNumber(req);
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.
// 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);
if (hideEmpty && !total) return notFound(req, res);
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
@@ -214,7 +215,7 @@ function createPublicRoutes(ctx) {
@@ -214,7 +215,7 @@ function createPublicRoutes(ctx) {
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
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 })))],
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 }),
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) });
const blogCrumb = (blog) => ({ name: blog.title, url: publication.blogPath(blog) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -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); });