openvibers/openvibe.sources Commits
W14: sources.openvibe.network answers an honest internal-service page, never the admin placeholder
Changes
@@@ -7,8 +7,9 @@**Status:** alpha (roadmap Wave 14). Deployed internally, not launched: it runs on the production host(127.0.0.1:4720 only, since 2026-09-23) with the six seeded sources **disabled**, so it has made 0 fetchruns and holds 0 items.--**Domain:** `sources.openvibe.network` (health only on the public vhost; the API is host-local). The--vhost is not installed yet: the name currently falls through to the admin.openvibe.network placeholder.++**Domain:** `sources.openvibe.network` (a short "internal service" page and health on the public++vhost; the API is loopback-only). The vhost is not installed yet: until it is, the name falls through++to the admin.openvibe.network placeholder. Install steps: [Public host](#public-host).**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — roadmap §4.2 B, §15.12, §29, anti-goals 13, 26, 27.**License:** AGPL-3.0.@@@ -188,6 +189,8 @@ the terms.- parsers against fixtures (`test/adapters.test.js`); API, registry rules, paging, manual items,removal (`test/api.test.js`); scheduler, backoff, relay to Events (`test/scheduler-events.test.js`);proposals and seeds (`test/proposals.test.js`)++- the public host shows only an honest "internal service" page, and the vhost keeps the API++ loopback-only (`test/public-host.test.js`)Not yet demonstrated: a run against a real source from the deployed service (every seed is disableduntil a person verifies its terms), and a consuming product using real items. News and Reviews run@@@ -200,11 +203,40 @@ readiness, row counts; see OpenVibe.Host `docs/restore-drills.md`).## Launch rule--The domain keeps its placeholder page on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites)--until the plan's launch rule holds (owning runtime with health/readiness, identity and service--principals, real persistence and workflows, capabilities and events registered in--OpenVibe.Contracts, a security review, acceptance tests). Sources has no public pages by design;--its public vhost answers health and a `Disallow: /` robots.txt.++Sources has no public pages by design, so it never "launches" as a site. The domain is not in++[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites); this repository's own++vhost serves it once installed.++++## Public host++++`sources.openvibe.network` resolves through Cloudflare, but until this vhost is installed nginx++answers it with its default site (the admin placeholder). The vhost++([deploy/nginx/sources.openvibe.network.conf](deploy/nginx/sources.openvibe.network.conf)) uses++the Network wildcard certificate and answers only:++++| Path | Answer |++|---|---|++| `/` | a short page: an internal ingestion service, nothing to browse (HTML for browsers, the text route index otherwise; noindex, no-store) |++| `/api/health`, `/api/ready`, `/release.json` | proxied |++| `/robots.txt` | `Disallow: /` |++| `/metrics` | 404 (Prometheus scrapes `127.0.0.1:4720/metrics` directly) |++| `/api/v1/*` | loopback only (`allow 127.0.0.1; allow ::1; deny all`); services call `127.0.0.1:4720` |++| anything else | a JSON 404 that says what the host is |++++Client-address headers come from `$remote_addr` only. Install on the host, after the service runs++a release with the new `/` page (`ovhost deploy sources`):++++```bash++sudo install -m 0644 /opt/openvibe.sources/deploy/nginx/sources.openvibe.network.conf /etc/nginx/sites-available/sources.openvibe.network.conf++sudo ln -sf /etc/nginx/sites-available/sources.openvibe.network.conf /etc/nginx/sites-enabled/sources.openvibe.network.conf++sudo nginx -t && sudo systemctl reload nginx++# check++curl -sS -H 'Accept: text/html' https://sources.openvibe.network/ | grep -o '<title>[^<]*' # OpenVibe.Sources++curl -sS https://sources.openvibe.network/api/health++curl -sS -o /dev/null -w '%{http_code}\n' https://sources.openvibe.network/api/v1/sources # 403++curl -sS -o /dev/null -w '%{http_code}\n' https://sources.openvibe.network/metrics # 404++curl -sS https://sources.openvibe.network/admin # JSON 404++```---@@@ -6,7 +6,7 @@"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","runtime": "node 22, express, better-sqlite3, fast-xml-parser","port": 4720,-- "domain": "sources.openvibe.network (no vhost installed: the name falls through to the admin.openvibe.network placeholder; the service is reachable on the host only)",++ "domain": "sources.openvibe.network (vhost in deploy/nginx, not installed: until it is, the name falls through to the admin.openvibe.network placeholder; once installed it answers an internal-service page, health and JSON 404s, API loopback-only; README \"Public host\")","contracts": "openvibe-contracts v0.13.0","capabilities": ["sources.source.read", "sources.item.read", "sources.source.manage"],"capabilitiesRegistered": true,@@@ -2,16 +2,18 @@## sources.openvibe.network — Nginx Configuration (REFERENCE)#### Deployed to /etc/nginx/sites-available/sources.openvibe.network.conf — keep them in sync,--## and reload nginx after changing it.++## and reload nginx after changing it. Install: README "Public host".##--## The Node service (port 4720, systemd unit openvibe-sources) serves:--## /api/health, /api/ready--## /metrics Prometheus text, answered only to direct loopback callers (never proxied)--## /api/v1/* service-to-service API (every route needs a service token). Services on this--## host call 127.0.0.1:4720 directly, so the public vhost refuses it; open the--## location below only when a service on another host needs it.--## Nothing here is a public page: Sources holds raw third-party material, which products--## publish (or not) under their own rules.++## Sources is an internal ingestion service with no public pages: Sources holds raw third-party++## material, which products publish (or not) under their own rules. The public host answers only:++## / a short page saying what the service is (HTML for browsers, text otherwise)++## /api/health, /api/ready, /release.json++## /robots.txt disallow everything++## and keeps the rest off the public edge:++## /api/v1/* service-to-service API (every route needs a service token): loopback only.++## Services on this host call 127.0.0.1:4720 directly.++## /metrics 404 here; Prometheus text for direct loopback callers of 127.0.0.1:4720++## anything else a JSON 404 that says why, so the name never falls through to another site## ═══════════════════════════════════════════════════════════════# ── HTTP → HTTPS redirect ──────────────────────────────────@@@ -46,29 +48,27 @@ server {add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;add_header X-Robots-Tag "noindex, nofollow" always;-- location = /api/health { proxy_pass http://127.0.0.1:4720; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }-- location = /api/ready { proxy_pass http://127.0.0.1:4720; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }++ # Client address only from $remote_addr (realip has already replaced it with the visitor's++ # address for Cloudflare's ranges); never append to or pass through a client-sent header.++ # Inherited by every location below that proxies (none sets its own).++ proxy_http_version 1.1;++ proxy_set_header Connection "";++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ location = /api/health { proxy_pass http://127.0.0.1:4720; }++ location = /api/ready { proxy_pass http://127.0.0.1:4720; }++ location = /release.json { proxy_pass http://127.0.0.1:4720; }# Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).location = /metrics { return 404; }-- # ── Service API: host-local only (see header) ───────────-- location /api/ {-- return 403;-- # To expose it (token-guarded) to off-host services instead:-- # proxy_pass http://127.0.0.1:4720;-- # proxy_http_version 1.1;-- # proxy_set_header Host $host;-- # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;-- # proxy_set_header X-Forwarded-Proto $scheme;-- }--location = / {++ limit_except GET { deny all; }proxy_pass http://127.0.0.1:4720;-- proxy_set_header Host $host;-- proxy_set_header X-Real-IP $remote_addr;-- proxy_set_header X-Forwarded-For $remote_addr;-- proxy_set_header CF-Connecting-IP $remote_addr;}location = /robots.txt {@@@ -76,7 +76,17 @@ server {return 200 "User-agent: *\nDisallow: /\n";}++ # ── Service API: loopback only (see header) ─────────────++ location ^~ /api/v1/ {++ allow 127.0.0.1;++ allow ::1;++ deny all;++ proxy_pass http://127.0.0.1:4720;++ }++++ # ── Anything else: an honest JSON 404, never another site ──location / {-- return 404;++ default_type application/problem+json;++ return 404 '{"type":"about:blank","title":"Not Found","status":404,"code":"sources.not_found","detail":"sources.openvibe.network is an internal ingestion service of the OpenVibe network with no public pages or API. Its API is for OpenVibe services on the host. About: https://sources.openvibe.network/ and https://github.com/OpenVibers/OpenVibe.Sources"}';}}@@@ -10,6 +10,58 @@ const { sourcesRouter } = require('./api/sources');const { itemsRouter } = require('./api/items');const pkg = require('../package.json');++const TEXT_INDEX = [++ 'OpenVibe.Sources: the source registry and ingestion workers for the network\'s publication products.',++ 'Every item carries its source, canonical URL, retrieval time, content hash and terms; a failed fetch is',++ 'recorded as a failure and never replaced with invented content.',++ '',++ 'This is an internal service. Its API is for OpenVibe services on the production host (service tokens);',++ 'sources.openvibe.network answers only this page, /api/health, /api/ready and /release.json.',++ '',++ 'GET /api/v1/sources, /api/v1/sources/:key, /api/v1/sources/:key/runs, /api/v1/runs, /api/v1/health (sources.source.read)',++ 'GET /api/v1/items?source=&category=&after=, /api/v1/items/:id (sources.item.read)',++ 'POST/PATCH/DELETE /api/v1/sources[/:key], POST /api/v1/sources/:key/fetch, /items, DELETE /api/v1/items/:id (sources.source.manage)',++ 'GET /api/health, /api/ready, /release.json',++ '',++ 'Source: https://github.com/OpenVibers/OpenVibe.Sources',++ '',++].join('\n');++++const HOME_HTML = `<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<meta name="robots" content="noindex, nofollow">++<meta name="color-scheme" content="light dark">++<title>OpenVibe.Sources</title>++<style>++:root { --bg: #fff; --fg: #1a1a1a; --muted: #5c5c66; --accent: #2456d6; }++@media (prefers-color-scheme: dark) { :root { --bg: #111317; --fg: #e8e8ec; --muted: #a0a0ab; --accent: #7aa2ff; } }++body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }++main { max-width: 640px; margin: 0 auto; padding: 32px 16px; }++h1 { font-size: 1.4rem; margin: 0 0 12px; }++p { margin: 0 0 12px; }++.muted { color: var(--muted); font-size: .9rem; }++a { color: var(--accent); }++</style>++</head>++<body>++<main>++<h1>OpenVibe.Sources</h1>++<p>This is an internal service of the OpenVibe network, not a website. It keeps the list of outside sources++(feeds, sitemaps, APIs) the network's publication products may read, fetches them politely, and records++where every item came from, when it was fetched and under which terms. A failed fetch is recorded as a++failure, never replaced with invented content.</p>++<p>There is nothing to browse here. What reaches readers is published by the products themselves, under++their own rules.</p>++<p class="muted">Health: <a href="/api/health">/api/health</a> · Readiness: <a href="/api/ready">/api/ready</a> ·++Source code: <a href="https://github.com/OpenVibers/OpenVibe.Sources">OpenVibers/OpenVibe.Sources</a></p>++</main>++</body>++</html>++`;++function createApp({ config, db, registry, items, ingest, scheduler, auth, keys, outbox, relay, now, log = console }) {const app = express();app.disable('x-powered-by');@@@ -40,20 +92,17 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,app.use(sourcesRouter({ db, registry, ingest, auth }));app.use(itemsRouter({ db, registry, items, auth, relay, now }));-- app.get('/', (_req, res) => {-- res.type('text/plain').send([-- 'OpenVibe.Sources: the source registry and ingestion workers for the network\'s publication products.',-- 'Every item carries its source, canonical URL, retrieval time, content hash and terms; a failed fetch is',-- 'recorded as a failure and never replaced with invented content.',-- '',-- 'GET /api/v1/sources, /api/v1/sources/:key, /api/v1/sources/:key/runs, /api/v1/runs, /api/v1/health (sources.source.read)',-- 'GET /api/v1/items?source=&category=&after=, /api/v1/items/:id (sources.item.read)',-- 'POST/PATCH/DELETE /api/v1/sources[/:key], POST /api/v1/sources/:key/fetch, /items, DELETE /api/v1/items/:id (sources.source.manage)',-- 'GET /api/health, /api/ready, /release.json',-- '',-- 'Source: https://github.com/OpenVibers/OpenVibe.Sources',-- '',-- ].join('\n'));++ // The public host (sources.openvibe.network) shows only this page, health, readiness and++ // /release.json: Sources is internal. Browsers get a short honest HTML page, other clients the++ // text route index. Never cached, never indexed.++ app.get('/', (req, res) => {++ res.setHeader('X-Robots-Tag', 'noindex, nofollow');++ res.setHeader('Vary', 'Accept');++ if (/\btext\/html\b/.test(String(req.get('accept') || ''))) {++ res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'");++ return res.type('html').send(HOME_HTML);++ }++ return res.type('text/plain').send(TEXT_INDEX);});app.use((req, res) => http.sendProblem(res, 404, 'sources.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++/**++ * What sources.openvibe.network shows the public: a short honest page at / (HTML for browsers,++ * the text route index otherwise), never cached or indexed, with no JavaScript. The nginx vhost++ * (deploy/nginx/sources.openvibe.network.conf) keeps /api/v1/ loopback-only and answers every++ * other path with a JSON 404.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, request, suite } = require('./helpers');++++const t = suite('public-host');++let svc;++++t('boot', async () => { svc = await boot(); });++++t('browsers get a short honest HTML page: internal service, nothing to browse, noindex, no-store, no script', async () => {++ const r = await request(svc.base, 'GET', '/', { headers: { Accept: 'text/html,application/xhtml+xml' } });++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /text\/html/);++ assert.match(r.text, /internal service/);++ assert.match(r.text, /nothing to browse/);++ assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++ assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);++ assert.ok(!/<script/i.test(r.text));++ assert.ok(!/admin/i.test(r.text));++});++++t('other clients get the text route index', async () => {++ const r = await request(svc.base, 'GET', '/');++ assert.match(r.headers.get('content-type'), /text\/plain/);++ assert.match(r.text, /internal service/);++ assert.match(r.text, /GET {2}\/api\/v1\/sources/);++});++++t('the vhost: wildcard certificate, client address from $remote_addr, /metrics 404, loopback-only API, JSON 404', () => {++ const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'sources.openvibe.network.conf'), 'utf8');++ assert.match(conf, /ssl_certificate\s+\/etc\/letsencrypt\/live\/openvibe\.network\/fullchain\.pem;/);++ for (const h of ['X-Real-IP', 'X-Forwarded-For', 'CF-Connecting-IP']) assert.match(conf, new RegExp(`proxy_set_header ${h} \\$remote_addr;`));++ assert.ok(!/proxy_add_x_forwarded_for|\$http_cf_connecting_ip|\$http_x_forwarded_for/.test(conf), 'never a client-sent address');++ assert.match(conf, /location = \/metrics \{ return 404; \}/);++ assert.match(conf, /location \^~ \/api\/v1\/ \{\s*allow 127\.0\.0\.1;\s*allow ::1;\s*deny all;/);++ assert.match(conf, /location \/ \{\s*default_type application\/problem\+json;\s*return 404 '\{/);++ const body = /return 404 '(\{.*\})';/.exec(conf)[1];++ assert.strictEqual(JSON.parse(body).status, 404);++});++++t('shutdown', async () => { await svc.stop(); });++++t.run();
@@ -7,8 +7,9 @@
@@ -7,8 +7,9 @@
**Status:** alpha (roadmap Wave 14). Deployed internally, not launched: it runs on the production host
**Status:** alpha (roadmap Wave 14). Deployed internally, not launched: it runs on the production host
(127.0.0.1:4720 only, since 2026-09-23) with the six seeded sources **disabled**, so it has made 0 fetch
(127.0.0.1:4720 only, since 2026-09-23) with the six seeded sources **disabled**, so it has made 0 fetch
runs and holds 0 items.
runs and holds 0 items.
-**Domain:** `sources.openvibe.network` (health only on the public vhost; the API is host-local). The
-vhost is not installed yet: the name currently falls through to the admin.openvibe.network placeholder.
+**Domain:** `sources.openvibe.network` (a short "internal service" page and health on the public
+vhost; the API is loopback-only). The vhost is not installed yet: until it is, the name falls through
+to the admin.openvibe.network placeholder. Install steps: [Public host](#public-host).
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — roadmap §4.2 B, §15.12, §29, anti-goals 13, 26, 27.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 — roadmap §4.2 B, §15.12, §29, anti-goals 13, 26, 27.
**License:** AGPL-3.0.
**License:** AGPL-3.0.
@@ -188,6 +189,8 @@ the terms.
@@ -188,6 +189,8 @@ the terms.
- parsers against fixtures (`test/adapters.test.js`); API, registry rules, paging, manual items,
- parsers against fixtures (`test/adapters.test.js`); API, registry rules, paging, manual items,
removal (`test/api.test.js`); scheduler, backoff, relay to Events (`test/scheduler-events.test.js`);
removal (`test/api.test.js`); scheduler, backoff, relay to Events (`test/scheduler-events.test.js`);
proposals and seeds (`test/proposals.test.js`)
proposals and seeds (`test/proposals.test.js`)
+- the public host shows only an honest "internal service" page, and the vhost keeps the API
+ loopback-only (`test/public-host.test.js`)
Not yet demonstrated: a run against a real source from the deployed service (every seed is disabled
Not yet demonstrated: a run against a real source from the deployed service (every seed is disabled
until a person verifies its terms), and a consuming product using real items. News and Reviews run
until a person verifies its terms), and a consuming product using real items. News and Reviews run
@@ -200,11 +203,40 @@ readiness, row counts; see OpenVibe.Host `docs/restore-drills.md`).
@@ -200,11 +203,40 @@ readiness, row counts; see OpenVibe.Host `docs/restore-drills.md`).
## Launch rule
## Launch rule
-The domain keeps its placeholder page on [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites)
-until the plan's launch rule holds (owning runtime with health/readiness, identity and service
-principals, real persistence and workflows, capabilities and events registered in
-OpenVibe.Contracts, a security review, acceptance tests). Sources has no public pages by design;
-its public vhost answers health and a `Disallow: /` robots.txt.
+Sources has no public pages by design, so it never "launches" as a site. The domain is not in
+[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites); this repository's own
+vhost serves it once installed.
+
+## Public host
+
+`sources.openvibe.network` resolves through Cloudflare, but until this vhost is installed nginx
+answers it with its default site (the admin placeholder). The vhost
+([deploy/nginx/sources.openvibe.network.conf](deploy/nginx/sources.openvibe.network.conf)) uses
+the Network wildcard certificate and answers only:
+
+| Path | Answer |
+|---|---|
+| `/` | a short page: an internal ingestion service, nothing to browse (HTML for browsers, the text route index otherwise; noindex, no-store) |
+| `/api/health`, `/api/ready`, `/release.json` | proxied |
+| `/robots.txt` | `Disallow: /` |
+| `/metrics` | 404 (Prometheus scrapes `127.0.0.1:4720/metrics` directly) |
+| `/api/v1/*` | loopback only (`allow 127.0.0.1; allow ::1; deny all`); services call `127.0.0.1:4720` |
+| anything else | a JSON 404 that says what the host is |
+
+Client-address headers come from `$remote_addr` only. Install on the host, after the service runs
+a release with the new `/` page (`ovhost deploy sources`):
+
+```bash
+sudo install -m 0644 /opt/openvibe.sources/deploy/nginx/sources.openvibe.network.conf /etc/nginx/sites-available/sources.openvibe.network.conf
+sudo ln -sf /etc/nginx/sites-available/sources.openvibe.network.conf /etc/nginx/sites-enabled/sources.openvibe.network.conf
+sudo nginx -t && sudo systemctl reload nginx
+# check
+curl -sS -H 'Accept: text/html' https://sources.openvibe.network/ | grep -o '<title>[^<]*' # OpenVibe.Sources
+curl -sS https://sources.openvibe.network/api/health
+curl -sS -o /dev/null -w '%{http_code}\n' https://sources.openvibe.network/api/v1/sources # 403
+curl -sS -o /dev/null -w '%{http_code}\n' https://sources.openvibe.network/metrics # 404
+curl -sS https://sources.openvibe.network/admin # JSON 404
+```
---
---
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"runtime": "node 22, express, better-sqlite3, fast-xml-parser",
"runtime": "node 22, express, better-sqlite3, fast-xml-parser",
"port": 4720,
"port": 4720,
- "domain": "sources.openvibe.network (no vhost installed: the name falls through to the admin.openvibe.network placeholder; the service is reachable on the host only)",
+ "domain": "sources.openvibe.network (vhost in deploy/nginx, not installed: until it is, the name falls through to the admin.openvibe.network placeholder; once installed it answers an internal-service page, health and JSON 404s, API loopback-only; README \"Public host\")",
"contracts": "openvibe-contracts v0.13.0",
"contracts": "openvibe-contracts v0.13.0",
"capabilities": ["sources.source.read", "sources.item.read", "sources.source.manage"],
"capabilities": ["sources.source.read", "sources.item.read", "sources.source.manage"],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
@@ -2,16 +2,18 @@
@@ -2,16 +2,18 @@
## sources.openvibe.network — Nginx Configuration (REFERENCE)
## sources.openvibe.network — Nginx Configuration (REFERENCE)
##
##
## Deployed to /etc/nginx/sites-available/sources.openvibe.network.conf — keep them in sync,
## Deployed to /etc/nginx/sites-available/sources.openvibe.network.conf — keep them in sync,
-## and reload nginx after changing it.
+## and reload nginx after changing it. Install: README "Public host".
##
##
-## The Node service (port 4720, systemd unit openvibe-sources) serves:
-## /api/health, /api/ready
-## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
-## /api/v1/* service-to-service API (every route needs a service token). Services on this
-## host call 127.0.0.1:4720 directly, so the public vhost refuses it; open the
-## location below only when a service on another host needs it.
-## Nothing here is a public page: Sources holds raw third-party material, which products
-## publish (or not) under their own rules.
+## Sources is an internal ingestion service with no public pages: Sources holds raw third-party
+## material, which products publish (or not) under their own rules. The public host answers only:
+## / a short page saying what the service is (HTML for browsers, text otherwise)
+## /api/health, /api/ready, /release.json
+## /robots.txt disallow everything
+## and keeps the rest off the public edge:
+## /api/v1/* service-to-service API (every route needs a service token): loopback only.
+## Services on this host call 127.0.0.1:4720 directly.
+## /metrics 404 here; Prometheus text for direct loopback callers of 127.0.0.1:4720
+## anything else a JSON 404 that says why, so the name never falls through to another site
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
# ── HTTP → HTTPS redirect ──────────────────────────────────
# ── HTTP → HTTPS redirect ──────────────────────────────────
@@ -46,29 +48,27 @@ server {
@@ -46,29 +48,27 @@ server {
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header X-Robots-Tag "noindex, nofollow" always;
- location = /api/health { proxy_pass http://127.0.0.1:4720; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
- location = /api/ready { proxy_pass http://127.0.0.1:4720; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
+ # Client address only from $remote_addr (realip has already replaced it with the visitor's
+ # address for Cloudflare's ranges); never append to or pass through a client-sent header.
+ # Inherited by every location below that proxies (none sets its own).
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ location = /api/health { proxy_pass http://127.0.0.1:4720; }
+ location = /api/ready { proxy_pass http://127.0.0.1:4720; }
+ location = /release.json { proxy_pass http://127.0.0.1:4720; }
# Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).
# Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).
location = /metrics { return 404; }
location = /metrics { return 404; }
- # ── Service API: host-local only (see header) ───────────
- location /api/ {
- return 403;
- # To expose it (token-guarded) to off-host services instead:
- # proxy_pass http://127.0.0.1:4720;
- # proxy_http_version 1.1;
- # proxy_set_header Host $host;
- # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- # proxy_set_header X-Forwarded-Proto $scheme;
- }
-
location = / {
location = / {
+ limit_except GET { deny all; }
proxy_pass http://127.0.0.1:4720;
proxy_pass http://127.0.0.1:4720;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $remote_addr;
- proxy_set_header CF-Connecting-IP $remote_addr;
}
}
location = /robots.txt {
location = /robots.txt {
@@ -76,7 +76,17 @@ server {
@@ -76,7 +76,17 @@ server {
return 200 "User-agent: *\nDisallow: /\n";
return 200 "User-agent: *\nDisallow: /\n";
}
}
+ # ── Service API: loopback only (see header) ─────────────
+ location ^~ /api/v1/ {
+ allow 127.0.0.1;
+ allow ::1;
+ deny all;
+ proxy_pass http://127.0.0.1:4720;
+ }
+
+ # ── Anything else: an honest JSON 404, never another site ──
location / {
location / {
- return 404;
+ default_type application/problem+json;
+ return 404 '{"type":"about:blank","title":"Not Found","status":404,"code":"sources.not_found","detail":"sources.openvibe.network is an internal ingestion service of the OpenVibe network with no public pages or API. Its API is for OpenVibe services on the host. About: https://sources.openvibe.network/ and https://github.com/OpenVibers/OpenVibe.Sources"}';
}
}
}
}
@@ -10,6 +10,58 @@ const { sourcesRouter } = require('./api/sources');
@@ -10,6 +10,58 @@ const { sourcesRouter } = require('./api/sources');
const { itemsRouter } = require('./api/items');
const { itemsRouter } = require('./api/items');
const pkg = require('../package.json');
const pkg = require('../package.json');
+const TEXT_INDEX = [
+ 'OpenVibe.Sources: the source registry and ingestion workers for the network\'s publication products.',
+ 'Every item carries its source, canonical URL, retrieval time, content hash and terms; a failed fetch is',
+ 'recorded as a failure and never replaced with invented content.',
+ '',
+ 'This is an internal service. Its API is for OpenVibe services on the production host (service tokens);',
+ 'sources.openvibe.network answers only this page, /api/health, /api/ready and /release.json.',
+ '',
+ 'GET /api/v1/sources, /api/v1/sources/:key, /api/v1/sources/:key/runs, /api/v1/runs, /api/v1/health (sources.source.read)',
+ 'GET /api/v1/items?source=&category=&after=, /api/v1/items/:id (sources.item.read)',
+ 'POST/PATCH/DELETE /api/v1/sources[/:key], POST /api/v1/sources/:key/fetch, /items, DELETE /api/v1/items/:id (sources.source.manage)',
+ 'GET /api/health, /api/ready, /release.json',
+ '',
+ 'Source: https://github.com/OpenVibers/OpenVibe.Sources',
+ '',
+].join('\n');
+
+const HOME_HTML = `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex, nofollow">
+<meta name="color-scheme" content="light dark">
+<title>OpenVibe.Sources</title>
+<style>
+:root { --bg: #fff; --fg: #1a1a1a; --muted: #5c5c66; --accent: #2456d6; }
+@media (prefers-color-scheme: dark) { :root { --bg: #111317; --fg: #e8e8ec; --muted: #a0a0ab; --accent: #7aa2ff; } }
+body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
+main { max-width: 640px; margin: 0 auto; padding: 32px 16px; }
+h1 { font-size: 1.4rem; margin: 0 0 12px; }
+p { margin: 0 0 12px; }
+.muted { color: var(--muted); font-size: .9rem; }
+a { color: var(--accent); }
+</style>
+</head>
+<body>
+<main>
+<h1>OpenVibe.Sources</h1>
+<p>This is an internal service of the OpenVibe network, not a website. It keeps the list of outside sources
+(feeds, sitemaps, APIs) the network's publication products may read, fetches them politely, and records
+where every item came from, when it was fetched and under which terms. A failed fetch is recorded as a
+failure, never replaced with invented content.</p>
+<p>There is nothing to browse here. What reaches readers is published by the products themselves, under
+their own rules.</p>
+<p class="muted">Health: <a href="/api/health">/api/health</a> · Readiness: <a href="/api/ready">/api/ready</a> ·
+Source code: <a href="https://github.com/OpenVibers/OpenVibe.Sources">OpenVibers/OpenVibe.Sources</a></p>
+</main>
+</body>
+</html>
+`;
+
function createApp({ config, db, registry, items, ingest, scheduler, auth, keys, outbox, relay, now, log = console }) {
function createApp({ config, db, registry, items, ingest, scheduler, auth, keys, outbox, relay, now, log = console }) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -40,20 +92,17 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
@@ -40,20 +92,17 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, keys,
app.use(sourcesRouter({ db, registry, ingest, auth }));
app.use(sourcesRouter({ db, registry, ingest, auth }));
app.use(itemsRouter({ db, registry, items, auth, relay, now }));
app.use(itemsRouter({ db, registry, items, auth, relay, now }));
- app.get('/', (_req, res) => {
- res.type('text/plain').send([
- 'OpenVibe.Sources: the source registry and ingestion workers for the network\'s publication products.',
- 'Every item carries its source, canonical URL, retrieval time, content hash and terms; a failed fetch is',
- 'recorded as a failure and never replaced with invented content.',
- '',
- 'GET /api/v1/sources, /api/v1/sources/:key, /api/v1/sources/:key/runs, /api/v1/runs, /api/v1/health (sources.source.read)',
- 'GET /api/v1/items?source=&category=&after=, /api/v1/items/:id (sources.item.read)',
- 'POST/PATCH/DELETE /api/v1/sources[/:key], POST /api/v1/sources/:key/fetch, /items, DELETE /api/v1/items/:id (sources.source.manage)',
- 'GET /api/health, /api/ready, /release.json',
- '',
- 'Source: https://github.com/OpenVibers/OpenVibe.Sources',
- '',
- ].join('\n'));
+ // The public host (sources.openvibe.network) shows only this page, health, readiness and
+ // /release.json: Sources is internal. Browsers get a short honest HTML page, other clients the
+ // text route index. Never cached, never indexed.
+ app.get('/', (req, res) => {
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
+ res.setHeader('Vary', 'Accept');
+ if (/\btext\/html\b/.test(String(req.get('accept') || ''))) {
+ res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'");
+ return res.type('html').send(HOME_HTML);
+ }
+ return res.type('text/plain').send(TEXT_INDEX);
});
});
app.use((req, res) => http.sendProblem(res, 404, 'sources.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
app.use((req, res) => http.sendProblem(res, 404, 'sources.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+/**
+ * What sources.openvibe.network shows the public: a short honest page at / (HTML for browsers,
+ * the text route index otherwise), never cached or indexed, with no JavaScript. The nginx vhost
+ * (deploy/nginx/sources.openvibe.network.conf) keeps /api/v1/ loopback-only and answers every
+ * other path with a JSON 404.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, request, suite } = require('./helpers');
+
+const t = suite('public-host');
+let svc;
+
+t('boot', async () => { svc = await boot(); });
+
+t('browsers get a short honest HTML page: internal service, nothing to browse, noindex, no-store, no script', async () => {
+ const r = await request(svc.base, 'GET', '/', { headers: { Accept: 'text/html,application/xhtml+xml' } });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /text\/html/);
+ assert.match(r.text, /internal service/);
+ assert.match(r.text, /nothing to browse/);
+ assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);
+ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+ assert.match(r.headers.get('content-security-policy'), /default-src 'none'/);
+ assert.ok(!/<script/i.test(r.text));
+ assert.ok(!/admin/i.test(r.text));
+});
+
+t('other clients get the text route index', async () => {
+ const r = await request(svc.base, 'GET', '/');
+ assert.match(r.headers.get('content-type'), /text\/plain/);
+ assert.match(r.text, /internal service/);
+ assert.match(r.text, /GET {2}\/api\/v1\/sources/);
+});
+
+t('the vhost: wildcard certificate, client address from $remote_addr, /metrics 404, loopback-only API, JSON 404', () => {
+ const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'sources.openvibe.network.conf'), 'utf8');
+ assert.match(conf, /ssl_certificate\s+\/etc\/letsencrypt\/live\/openvibe\.network\/fullchain\.pem;/);
+ for (const h of ['X-Real-IP', 'X-Forwarded-For', 'CF-Connecting-IP']) assert.match(conf, new RegExp(`proxy_set_header ${h} \\$remote_addr;`));
+ assert.ok(!/proxy_add_x_forwarded_for|\$http_cf_connecting_ip|\$http_x_forwarded_for/.test(conf), 'never a client-sent address');
+ assert.match(conf, /location = \/metrics \{ return 404; \}/);
+ assert.match(conf, /location \^~ \/api\/v1\/ \{\s*allow 127\.0\.0\.1;\s*allow ::1;\s*deny all;/);
+ assert.match(conf, /location \/ \{\s*default_type application\/problem\+json;\s*return 404 '\{/);
+ const body = /return 404 '(\{.*\})';/.exec(conf)[1];
+ assert.strictEqual(JSON.parse(body).status, 404);
+});
+
+t('shutdown', async () => { await svc.stop(); });
+
+t.run();