openvibers/openvibe.news Commits
Serve /.well-known/security.txt (RFC 9116) (#130)
Changes
@@@ -28,6 +28,9 @@ const SITE_SUMMARY = 'Source-backed news: AI briefs and editors\' stories where// What /llms.txt and /llms-full.txt both say about how the publication works.const SITE_DETAILS = 'OpenVibe.News is not an automatic headline generator. Each story page lists its sources (headline, outlet, author and date as the source published them, with links), a timeline, editor-assigned perspective groupings and its full correction history. Full article text from sources is never republished; at most a short summary where the source’s terms allow it. AI-assisted drafts are never published or indexed before a person reviews them. Retracted stories stay at their URL with a retraction notice and are noindex. Append .json to any story URL for the same content as data, including every claim’s source numbers and the Sources item ids.';++// The date /.well-known/security.txt must be refreshed by: one year on, a person re-reads and re-commits it.++const SECURITY_EXPIRES = '2027-10-29T00:00:00.000Z';++function createDiscoveryRoutes(ctx) {const { config, store, publication, topics, stories, clusters } = ctx;const homeData = ctx.home || createHome({ store });@@@ -58,6 +61,21 @@ function createDiscoveryRoutes(ctx) {res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(body);});++ // /.well-known/security.txt (RFC 9116, SEO-30). Contact, Expires and Canonical are required; Canonical is++ // this site's own, from the config, never a constant. Policy is listed only while++ // https://openvibe.network/security answers 200 (checked 2026-10-29). Expires is the date the file must be++ // refreshed by: one year on, a person re-reads and re-commits it.++ router.get('/.well-known/security.txt', (_req, res) => {++ res.type('text/plain; charset=utf-8').set('Cache-Control', 'public, max-age=86400').send([++ 'Contact: mailto:[email protected]',++ `Expires: ${SECURITY_EXPIRES}`,++ 'Preferred-Languages: en',++ `Canonical: ${abs('/.well-known/security.txt')}`,++ 'Policy: https://openvibe.network/security',++ '',++ ].join('\n'));++ });++router.get('/llms.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({name: 'OpenVibe.News',@@@ -30,8 +30,7 @@ const { boot, check, done } = require('./helpers/boot');assert.ok([200, 404].includes(m.status));});-- await check('robots.txt: sitemap, automated-consumer policy, editor/API disallowed; llms.txt; sitemap index', async () => {-- const robots = (await t.get('/robots.txt')).text;++ await check('robots.txt: sitemap, automated-consumer policy, editor/API disallowed; llms.txt; sitemap index', async () => { const robots = (await t.get('/robots.txt')).text;assert.match(robots, /Sitemap: https:\/\/openvibe\.news\/sitemap\.xml/);assert.match(robots, /automated-consumer policy/);for (const p of ['/edit', '/clusters/', '/api/', '/internal/']) assert.ok(robots.includes(`Disallow: ${p}`), p);@@@ -58,6 +57,25 @@ const { boot, check, done } = require('./helpers/boot');assert.match(home.text, /"@type":"WebPage"/, 'and its WebPage JSON-LD twin');});++ await check('security.txt: RFC 9116, canonical to this site, valid for a year', async () => {++ const r = await t.get('/.well-known/security.txt');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('content-type'), 'text/plain; charset=utf-8');++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=86400');++ const fields = new Map();++ for (const line of r.text.split('\n')) if (line.trim()) {++ const m = line.match(/^([A-Za-z-]+): (.+)$/);++ assert.ok(m, `every security.txt line is "Name: value" (got "${line}")`);++ fields.set(m[1], m[2]);++ }++ for (const key of ['Contact', 'Expires', 'Preferred-Languages', 'Canonical', 'Policy']) assert.ok(fields.has(key), `security.txt names ${key}`);++ assert.strictEqual(fields.get('Contact'), 'mailto:[email protected]');++ assert.ok(Date.parse(fields.get('Expires')) > Date.now(), 'Expires is in the future');++ const canonical = new URL(fields.get('Canonical'));++ assert.strictEqual(canonical.host, 'openvibe.news', 'Canonical is this site\'s own host');++ assert.strictEqual(canonical.pathname, '/.well-known/security.txt');++ });++await check('legal pages, 404s and API problems', async () => {assert.strictEqual((await t.get('/terms')).status, 200);const nf = await t.get('/stories/nothing-here');
@@ -28,6 +28,9 @@ const SITE_SUMMARY = 'Source-backed news: AI briefs and editors\' stories where
@@ -28,6 +28,9 @@ const SITE_SUMMARY = 'Source-backed news: AI briefs and editors\' stories where
// What /llms.txt and /llms-full.txt both say about how the publication works.
// What /llms.txt and /llms-full.txt both say about how the publication works.
const SITE_DETAILS = 'OpenVibe.News is not an automatic headline generator. Each story page lists its sources (headline, outlet, author and date as the source published them, with links), a timeline, editor-assigned perspective groupings and its full correction history. Full article text from sources is never republished; at most a short summary where the source’s terms allow it. AI-assisted drafts are never published or indexed before a person reviews them. Retracted stories stay at their URL with a retraction notice and are noindex. Append .json to any story URL for the same content as data, including every claim’s source numbers and the Sources item ids.';
const SITE_DETAILS = 'OpenVibe.News is not an automatic headline generator. Each story page lists its sources (headline, outlet, author and date as the source published them, with links), a timeline, editor-assigned perspective groupings and its full correction history. Full article text from sources is never republished; at most a short summary where the source’s terms allow it. AI-assisted drafts are never published or indexed before a person reviews them. Retracted stories stay at their URL with a retraction notice and are noindex. Append .json to any story URL for the same content as data, including every claim’s source numbers and the Sources item ids.';
+// The date /.well-known/security.txt must be refreshed by: one year on, a person re-reads and re-commits it.
+const SECURITY_EXPIRES = '2027-10-29T00:00:00.000Z';
+
function createDiscoveryRoutes(ctx) {
function createDiscoveryRoutes(ctx) {
const { config, store, publication, topics, stories, clusters } = ctx;
const { config, store, publication, topics, stories, clusters } = ctx;
const homeData = ctx.home || createHome({ store });
const homeData = ctx.home || createHome({ store });
@@ -58,6 +61,21 @@ function createDiscoveryRoutes(ctx) {
@@ -58,6 +61,21 @@ function createDiscoveryRoutes(ctx) {
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(body);
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(body);
});
});
+ // /.well-known/security.txt (RFC 9116, SEO-30). Contact, Expires and Canonical are required; Canonical is
+ // this site's own, from the config, never a constant. Policy is listed only while
+ // https://openvibe.network/security answers 200 (checked 2026-10-29). Expires is the date the file must be
+ // refreshed by: one year on, a person re-reads and re-commits it.
+ router.get('/.well-known/security.txt', (_req, res) => {
+ res.type('text/plain; charset=utf-8').set('Cache-Control', 'public, max-age=86400').send([
+ 'Contact: mailto:[email protected]',
+ `Expires: ${SECURITY_EXPIRES}`,
+ 'Preferred-Languages: en',
+ `Canonical: ${abs('/.well-known/security.txt')}`,
+ 'Policy: https://openvibe.network/security',
+ '',
+ ].join('\n'));
+ });
+
router.get('/llms.txt', (_req, res) => {
router.get('/llms.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
name: 'OpenVibe.News',
name: 'OpenVibe.News',
@@ -30,8 +30,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -30,8 +30,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok([200, 404].includes(m.status));
assert.ok([200, 404].includes(m.status));
});
});
- await check('robots.txt: sitemap, automated-consumer policy, editor/API disallowed; llms.txt; sitemap index', async () => {
- const robots = (await t.get('/robots.txt')).text;
+ await check('robots.txt: sitemap, automated-consumer policy, editor/API disallowed; llms.txt; sitemap index', async () => { const robots = (await t.get('/robots.txt')).text;
assert.match(robots, /Sitemap: https:\/\/openvibe\.news\/sitemap\.xml/);
assert.match(robots, /Sitemap: https:\/\/openvibe\.news\/sitemap\.xml/);
assert.match(robots, /automated-consumer policy/);
assert.match(robots, /automated-consumer policy/);
for (const p of ['/edit', '/clusters/', '/api/', '/internal/']) assert.ok(robots.includes(`Disallow: ${p}`), p);
for (const p of ['/edit', '/clusters/', '/api/', '/internal/']) assert.ok(robots.includes(`Disallow: ${p}`), p);
@@ -58,6 +57,25 @@ const { boot, check, done } = require('./helpers/boot');
@@ -58,6 +57,25 @@ const { boot, check, done } = require('./helpers/boot');
assert.match(home.text, /"@type":"WebPage"/, 'and its WebPage JSON-LD twin');
assert.match(home.text, /"@type":"WebPage"/, 'and its WebPage JSON-LD twin');
});
});
+ await check('security.txt: RFC 9116, canonical to this site, valid for a year', async () => {
+ const r = await t.get('/.well-known/security.txt');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('content-type'), 'text/plain; charset=utf-8');
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=86400');
+ const fields = new Map();
+ for (const line of r.text.split('\n')) if (line.trim()) {
+ const m = line.match(/^([A-Za-z-]+): (.+)$/);
+ assert.ok(m, `every security.txt line is "Name: value" (got "${line}")`);
+ fields.set(m[1], m[2]);
+ }
+ for (const key of ['Contact', 'Expires', 'Preferred-Languages', 'Canonical', 'Policy']) assert.ok(fields.has(key), `security.txt names ${key}`);
+ assert.strictEqual(fields.get('Contact'), 'mailto:[email protected]');
+ assert.ok(Date.parse(fields.get('Expires')) > Date.now(), 'Expires is in the future');
+ const canonical = new URL(fields.get('Canonical'));
+ assert.strictEqual(canonical.host, 'openvibe.news', 'Canonical is this site\'s own host');
+ assert.strictEqual(canonical.pathname, '/.well-known/security.txt');
+ });
+
await check('legal pages, 404s and API problems', async () => {
await check('legal pages, 404s and API problems', async () => {
assert.strictEqual((await t.get('/terms')).status, 200);
assert.strictEqual((await t.get('/terms')).status, 200);
const nf = await t.get('/stories/nothing-here');
const nf = await t.get('/stories/nothing-here');