openvibers/openvibe.events Commits

Events: a product home for openvibe.events (browsers get the page, API clients the text index) and its vhost. (#16)

OP OpenVibers committed 7 Oct 2026 · c910482e0f · 11 files, +474 −491

OpenVibe.Events gets a product home for openvibe.events (its product domain; the API stays at events.openvibe.network), ready for the cutover from the OpenVibe.Sites placeholder.

- server/home.js (new): the OpenVibe Frame (openvibe-shared/shell) around showcase sections for developers: hero "Events your services can count on." (publish once; stored, signed, retried, replayable; Live, Media, Chat, Games, Network and OpenRe.Stream publish to it in production), "What you get" (publish once with idempotent event ids, signed webhooks with retry, dead letters and replay, pull with a cursor, realtime SSE for browsers, developer apps with sandbox kept apart), "Publish, subscribe, pull" curl samples from the README, and "Receive a webhook safely" steps (secret shown once, v2 signature over timestamp and raw body within five minutes, 2xx once stored, handle repeats once). Every claim restates the README and worker.js.
- server/app.js: GET / negotiates: a browser (text/html) gets the home with its own CSP and Cache-Control private, max-age=300 (Cloudflare caches by URL and ignores Vary: Accept, so the HTML must never be a shared copy); curl and API clients get the text/plain index unchanged, byte for byte. Vary: Accept on both. /shared is openvibe-shared serve handler. Discovery routes mounted.
- server/discovery.js (new): robots.txt (allow the home; disallow /api/ and /realtime/; names the sitemap), sitemap.xml (the home only), llms.txt (README Purpose, Publishing, Subscriptions and delivery, Developer apps, Realtime).
- server/config.js, .env.example: siteUrl from EVENTS_SITE_URL (default https://openvibe.events in production).
- deploy/nginx/openvibe.events.conf (new): openvibe.events (www and :80 to the apex, acme), its own certificate, proxies only /, /shared/, /robots.txt, /sitemap.xml, /llms.txt to 4300; /metrics and /internal/ 404; everything else 301 to events.openvibe.network. Installed when openvibe.events is cut over (the Sites vhost of the same name is replaced).
- README (domain line, Deploy), STATUS.json updated; openvibe-shared v2.10.1.
- test/home.test.js (new): negotiation, headers, one h1, canonical, the stylesheet served, the unchanged text index, discovery files, the vhost shape.

Tests: ov test test/home.test.js (8/8) plus observability, pull-health, actor-limits, apps, security-idor, security-private, security-secrets, archive, readme-examples: all passed.

Changes

ddiff --git a/.env.example b/.env.example
iindex 58d5cbe..897e1ff 100644
---- a/.env.example
++++ b/.env.example
@@@ -10,6 +10,8 @@ PORT=4300
HOST=127.0.0.1
NODE_ENV=development
#BASE_URL=https://events.openvibe.network
++# Product domain (openvibe.events): canonical origin of the home page and its robots.txt/sitemap.xml/llms.txt; defaults to https://openvibe.events in production, to BASE_URL otherwise.
++#EVENTS_SITE_URL=https://openvibe.events
# Identity — OpenVibe.Network signs service tokens (client_credentials, audience
# openvibe.events) and user JWTs. The public key is fetched from
ddiff --git a/README.md b/README.md
iindex 02c8632..87e8f86 100644
---- a/README.md
++++ b/README.md
@@@ -3,7 +3,7 @@
> Durable events, subscriptions, delivery, retry, dead letters and replay for the network.
**Status:** alpha (roadmap Wave 3), deployed on `openvibe-ovh` (unit `openvibe-events`, 127.0.0.1:4300, public at `https://events.openvibe.network`). Live (`live.stream.*`), Media, Chat, Games, Network and OpenRe publish to it in production, and every consumer webhook requires signature v2. Media's completion events reach Live through Events (`/internal/media-events`; Live keeps the older `/internal/media-webhook` during the transition), and the shared notification bell can follow `network.notification.created` over the realtime stream with a Network ticket.
--**Domain:** `events.openvibe.network` (the realtime gateway is served here too: ADR-005 put Realtime inside Events, and `realtime.openvibe.network` has no runtime of its own)
++**Domain:** `events.openvibe.network` (the realtime gateway is served here too: ADR-005 put Realtime inside Events, and `realtime.openvibe.network` has no runtime of its own). The product domain `openvibe.events` serves only the home page and its `robots.txt`/`sitemap.xml`/`llms.txt`, and redirects everything else here.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §6 and §6.3.
**License:** AGPL-3.0 (same as every OpenVibe service).
@@@ -346,7 +346,9 @@ fast-forward `/opt/openvibe.events`, install on a lockfile change, restart, wait
The unit is `openvibe-events.service` on `127.0.0.1:4300`, the env file `/etc/openvibe/events.env`. The database is
`ov_events` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh events` writes its settings); the
release migrates it at boot. nginx serves `events.openvibe.network` from
--[deploy/nginx/events.openvibe.network.conf](deploy/nginx/events.openvibe.network.conf). ovhost treats open
++[deploy/nginx/events.openvibe.network.conf](deploy/nginx/events.openvibe.network.conf); the product domain `openvibe.events` gets
++[deploy/nginx/openvibe.events.conf](deploy/nginx/openvibe.events.conf) instead, installed when openvibe.events is cut over from
++its OpenVibe.Sites placeholder (the Sites vhost of the same name is replaced; nothing else changes on `events.openvibe.network`). ovhost treats open
realtime connections as a report-only drain, so `--wait-idle` waits for them.
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
restart; afterwards `sudo ovhost rollback events --to <sha>`. Migrations only add tables and columns.
ddiff --git a/STATUS.json b/STATUS.json
iindex d655583..5dae50e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -20,7 +20,7 @@
"capabilitiesRegistered": true,
"deployed": true,
"deployment": "Deployed with `sudo ovhost deploy events`: /opt/openvibe.events, unit openvibe-events, 127.0.0.1:4300; public at https://events.openvibe.network (realtime SSE, health, token-guarded /api/v1/events, /api/v1/subscriptions, /api/v1/checkpoints; /api/v1/deliveries host-local); real traffic since 2026-09-23 (Live, Media, Chat, Games, Network and OpenRe publish; on 2026-09-23 8 subscriptions, held by Search, Tips, VIP, News and Reviews); restore drill passed 2026-09-23",
-- "updated": "2026-10-05",
++ "updated": "2026-10-07",
"features": [
"durable event store with the canonical envelope",
"subscriptions with signed webhook delivery (v1 and v2), retry, dead letters and replay",
ddiff --git a/deploy/nginx/openvibe.events.conf b/deploy/nginx/openvibe.events.conf
nnew file mode 100644
iindex 0000000..0f9825b
---- /dev/null
++++ b/deploy/nginx/openvibe.events.conf
@@@ -0,0 +1,115 @@
++## ═══════════════════════════════════════════════════════════════
++## openvibe.events — Nginx Configuration (REFERENCE)
++##
++## Deployed to /etc/nginx/sites-available/openvibe.events.conf — keep them in sync,
++## and reload nginx after changing it.
++##
++## The product domain (plan expansion II): openvibe.events serves the product home page and its
++## discovery files. The API, the realtime gateway and health stay at events.openvibe.network (the
++## vhost of the same name, deploy/nginx/events.openvibe.network.conf), and every other path here
++## redirects there. Installed when openvibe.events is cut over from its OpenVibe.Sites placeholder:
++## the Sites vhost of the same name is replaced; nothing else changes on events.openvibe.network.
++##
++## The Node service (port 4300, systemd unit openvibe-events) answers here:
++## / the product home (server/home.js) for a browser, the text/plain API index otherwise
++## /shared/ the OpenVibe Frame's pinned browser files (openvibe-shared/serve)
++## /robots.txt, /sitemap.xml, /llms.txt
++## discovery for the product domain (server/discovery.js)
++## /metrics host-local scrapers only (http://127.0.0.1:4300/metrics, never proxied)
++## /internal/ host-local service endpoints, refused here
++## ═══════════════════════════════════════════════════════════════
++
++# ── HTTP → HTTPS redirect ──────────────────────────────────
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.events www.openvibe.events;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.events$request_uri; }
++}
++
++# ── www → apex ─────────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.events;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.events/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.events/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ return 301 https://openvibe.events$request_uri;
++}
++
++# ── HTTPS server ───────────────────────────────────────────
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.events;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.events/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.events/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/openvibe.events.access.log;
++ error_log /var/log/nginx/openvibe.events.error.log;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header Referrer-Policy strict-origin-when-cross-origin always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ # Metrics are for host-local scrapers only (http://127.0.0.1:4300/metrics).
++ location = /metrics { return 404; }
++
++ # Internal service endpoints (Live's /internal/media-events, ...) live on the API host.
++ location /internal/ { return 404; }
++
++ # ── The product surface: the home, its Frame files and its discovery files ──
++ # Client address from $remote_addr only on every proxied location (never appended to, never
++ # passed through), exactly as on events.openvibe.network.
++ location = / {
++ proxy_pass http://127.0.0.1:4300;
++ 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 ^~ /shared/ {
++ proxy_pass http://127.0.0.1:4300;
++ 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 {
++ proxy_pass http://127.0.0.1:4300;
++ 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 = /sitemap.xml {
++ proxy_pass http://127.0.0.1:4300;
++ 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 = /llms.txt {
++ proxy_pass http://127.0.0.1:4300;
++ 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;
++ }
++
++ # ── Everything else is the API host: the JSON API, realtime and health live there ──
++ location / { return 301 https://events.openvibe.network$request_uri; }
++}
ddiff --git a/package-lock.json b/package-lock.json
iindex e0f14ab..1dbbc27 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,7 +14,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -89,65 +89,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
-- "node_modules/base64-js": {
-- "version": "1.5.1",
-- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
-- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
-- "node_modules/better-sqlite3": {
-- "version": "11.10.0",
-- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
-- "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
-- "hasInstallScript": true,
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "bindings": "^1.5.0",
-- "prebuild-install": "^7.1.1"
-- }
-- },
-- "node_modules/bindings": {
-- "version": "1.5.0",
-- "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
-- "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "file-uri-to-path": "1.0.0"
-- }
-- },
-- "node_modules/bl": {
-- "version": "4.1.0",
-- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
-- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "buffer": "^5.5.0",
-- "inherits": "^2.0.4",
-- "readable-stream": "^3.4.0"
-- }
-- },
"node_modules/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@@ -172,32 +113,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
-- "node_modules/buffer": {
-- "version": "5.7.1",
-- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
-- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "base64-js": "^1.3.1",
-- "ieee754": "^1.1.13"
-- }
-- },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@@ -242,14 +157,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
-- "node_modules/chownr": {
-- "version": "1.1.4",
-- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
-- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
-- "license": "ISC",
-- "optional": true,
-- "peer": true
-- },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@@ -304,34 +211,6 @@
"ms": "2.0.0"
}
},
-- "node_modules/decompress-response": {
-- "version": "6.0.0",
-- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
-- "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "mimic-response": "^3.1.0"
-- },
-- "engines": {
-- "node": ">=10"
-- },
-- "funding": {
-- "url": "https://github.com/sponsors/sindresorhus"
-- }
-- },
-- "node_modules/deep-extend": {
-- "version": "0.6.0",
-- "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
-- "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "engines": {
-- "node": ">=4.0.0"
-- }
-- },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@@ -360,17 +239,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
-- "node_modules/detect-libc": {
-- "version": "2.1.2",
-- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
-- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
-- "license": "Apache-2.0",
-- "optional": true,
-- "peer": true,
-- "engines": {
-- "node": ">=8"
-- }
-- },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@@ -421,17 +289,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/end-of-stream": {
-- "version": "1.4.5",
-- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
-- "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "once": "^1.4.0"
-- }
-- },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@@ -477,17 +334,6 @@
"node": ">= 0.6"
}
},
-- "node_modules/expand-template": {
-- "version": "2.0.3",
-- "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
-- "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
-- "license": "(MIT OR WTFPL)",
-- "optional": true,
-- "peer": true,
-- "engines": {
-- "node": ">=6"
-- }
-- },
"node_modules/express": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
@@@ -556,14 +402,6 @@
],
"license": "BSD-3-Clause"
},
-- "node_modules/file-uri-to-path": {
-- "version": "1.0.0",
-- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
-- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@@ -600,14 +438,6 @@
"node": ">= 0.6"
}
},
-- "node_modules/fs-constants": {
-- "version": "1.0.0",
-- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
-- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@@ -654,14 +484,6 @@
"node": ">= 0.4"
}
},
-- "node_modules/github-from-package": {
-- "version": "0.0.0",
-- "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
-- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@@ -730,42 +552,12 @@
"node": ">=0.10.0"
}
},
-- "node_modules/ieee754": {
-- "version": "1.2.1",
-- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
-- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "BSD-3-Clause",
-- "optional": true,
-- "peer": true
-- },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
-- "node_modules/ini": {
-- "version": "1.3.8",
-- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
-- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
-- "license": "ISC",
-- "optional": true,
-- "peer": true
-- },
"node_modules/iovalkey": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
@@@ -996,53 +788,12 @@
"node": ">= 0.6"
}
},
-- "node_modules/mimic-response": {
-- "version": "3.1.0",
-- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
-- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "engines": {
-- "node": ">=10"
-- },
-- "funding": {
-- "url": "https://github.com/sponsors/sindresorhus"
-- }
-- },
-- "node_modules/minimist": {
-- "version": "1.2.8",
-- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
-- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "funding": {
-- "url": "https://github.com/sponsors/ljharb"
-- }
-- },
-- "node_modules/mkdirp-classic": {
-- "version": "0.5.3",
-- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
-- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
-- "node_modules/napi-build-utils": {
-- "version": "2.0.0",
-- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
-- "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@@ -1052,20 +803,6 @@
"node": ">= 0.6"
}
},
-- "node_modules/node-abi": {
-- "version": "3.96.0",
-- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
-- "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "semver": "^7.3.5"
-- },
-- "engines": {
-- "node": ">=10"
-- }
-- },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@@ -1090,17 +827,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/once": {
-- "version": "1.4.0",
-- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
-- "license": "ISC",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "wrappy": "1"
-- }
-- },
"node_modules/openvibe-contracts": {
"version": "0.94.2",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.2",
@@@ -1147,9 +873,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.5.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
-- "integrity": "sha512-fHuUS6SXM7ezatfAyqLm1V8B6puV9YXSnz6aG6XQbWx28qghRCCHeUF3lr4nxiCGdn9SyikYtIyLGL5r1ycp9Q==",
++ "version": "2.10.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
++ "integrity": "sha512-6fUOw22bSvxg0K33BefAyAkNRkETe9euLNT0CHs2mLIvzYR1q38KiHRUQIWj8FESbVANF0pXMojhBz5TeqpMtg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
@@@ -1301,35 +1027,6 @@
"node": ">=0.10.0"
}
},
-- "node_modules/prebuild-install": {
-- "version": "7.1.3",
-- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
-- "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
-- "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "detect-libc": "^2.0.0",
-- "expand-template": "^2.0.3",
-- "github-from-package": "0.0.0",
-- "minimist": "^1.2.3",
-- "mkdirp-classic": "^0.5.3",
-- "napi-build-utils": "^2.0.0",
-- "node-abi": "^3.3.0",
-- "pump": "^3.0.0",
-- "rc": "^1.2.7",
-- "simple-get": "^4.0.0",
-- "tar-fs": "^2.0.0",
-- "tunnel-agent": "^0.6.0"
-- },
-- "bin": {
-- "prebuild-install": "bin.js"
-- },
-- "engines": {
-- "node": ">=10"
-- }
-- },
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@@ -1347,18 +1044,6 @@
"url": "https://opencollective.com/express"
}
},
-- "node_modules/pump": {
-- "version": "3.0.4",
-- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
-- "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "end-of-stream": "^1.1.0",
-- "once": "^1.3.1"
-- }
-- },
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
@@@ -1399,39 +1084,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/rc": {
-- "version": "1.2.8",
-- "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
-- "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
-- "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "deep-extend": "^0.6.0",
-- "ini": "~1.3.0",
-- "minimist": "^1.2.0",
-- "strip-json-comments": "~2.0.1"
-- },
-- "bin": {
-- "rc": "cli.js"
-- }
-- },
-- "node_modules/readable-stream": {
-- "version": "3.6.2",
-- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
-- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "inherits": "^2.0.3",
-- "string_decoder": "^1.1.1",
-- "util-deprecate": "^1.0.1"
-- },
-- "engines": {
-- "node": ">= 6"
-- }
-- },
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
@@@ -1623,55 +1275,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
-- "node_modules/simple-concat": {
-- "version": "1.0.1",
-- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
-- "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
-- "node_modules/simple-get": {
-- "version": "4.0.1",
-- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
-- "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
-- "funding": [
-- {
-- "type": "github",
-- "url": "https://github.com/sponsors/feross"
-- },
-- {
-- "type": "patreon",
-- "url": "https://www.patreon.com/feross"
-- },
-- {
-- "type": "consulting",
-- "url": "https://feross.org/support"
-- }
-- ],
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "decompress-response": "^6.0.0",
-- "once": "^1.3.1",
-- "simple-concat": "^1.0.0"
-- }
-- },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@@ -1696,60 +1299,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/string_decoder": {
-- "version": "1.3.0",
-- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
-- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "safe-buffer": "~5.2.0"
-- }
-- },
-- "node_modules/strip-json-comments": {
-- "version": "2.0.1",
-- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
-- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "engines": {
-- "node": ">=0.10.0"
-- }
-- },
-- "node_modules/tar-fs": {
-- "version": "2.1.5",
-- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
-- "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "chownr": "^1.1.1",
-- "mkdirp-classic": "^0.5.2",
-- "pump": "^3.0.0",
-- "tar-stream": "^2.1.4"
-- }
-- },
-- "node_modules/tar-stream": {
-- "version": "2.2.0",
-- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
-- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "bl": "^4.0.3",
-- "end-of-stream": "^1.4.1",
-- "fs-constants": "^1.0.0",
-- "inherits": "^2.0.3",
-- "readable-stream": "^3.1.1"
-- },
-- "engines": {
-- "node": ">=6"
-- }
-- },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@@ -1759,20 +1308,6 @@
"node": ">=0.6"
}
},
-- "node_modules/tunnel-agent": {
-- "version": "0.6.0",
-- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
-- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
-- "license": "Apache-2.0",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "safe-buffer": "^5.0.1"
-- },
-- "engines": {
-- "node": "*"
-- }
-- },
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@@ -1795,14 +1330,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/util-deprecate": {
-- "version": "1.0.2",
-- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@@ -1821,14 +1348,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/wrappy": {
-- "version": "1.0.2",
-- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-- "license": "ISC",
-- "optional": true,
-- "peer": true
-- },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
ddiff --git a/package.json b/package.json
iindex 055f2ce..2ad0958 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/server/app.js b/server/app.js
iindex 3cdccc7..4633b4d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -13,6 +13,9 @@ const { policiesRouter } = require('./api/policies');
const { placementRouter } = require('./api/placement');
const { mountLimits } = require('./limits');
const { createLimits } = require('./actor-limits');
++const { renderHome, HOME_CSP } = require('./home');
++const { createDiscoveryRoutes } = require('./discovery');
++const ovServe = require('openvibe-shared/serve');
const pkg = require('../package.json');
function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, clock, log = console, valkey = null, policies = null, planner = null, carriers = null }) {
@@@ -33,6 +36,11 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
next();
});
++ // The product home's own browser files (the OpenVibe Frame, openvibe-shared/serve) under
++ // content-addressed /shared/* URLs, from this repo's pinned openvibe-shared. Mounted before the
++ // routers and outside the per-actor limiter: it is static and unauthenticated.
++ app.use('/shared', ovServe.handler());
++
// Realtime first: it answers CORS preflights and must not go through the JSON body parser.
app.options('/realtime/stream', realtime.cors);
app.get('/realtime/stream', realtime.cors, realtime.handler);
@@@ -83,7 +91,22 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
if (policies) app.use(policiesRouter({ policies, auth, limits }));
if (planner) app.use(placementRouter({ planner, policies, auth, limits, store }));
-- app.get('/', (_req, res) => {
++ // Discovery for the product domain (openvibe.events): robots.txt, sitemap.xml, llms.txt.
++ app.use(createDiscoveryRoutes({ config }));
++
++ // GET / on the product domain: the home page for a browser (server/home.js, openvibe.events), the
++ // API index for curl and API clients (`*/*`, no Accept, or any non-HTML Accept). Vary: Accept so a
++ // shared cache never serves one to the other. The home carries its own CSP (HOME_CSP); the only
++ // global security header is X-Content-Type-Options above, kept on every route.
++ app.get('/', (req, res) => {
++ res.vary('Accept');
++ if (req.accepts(['text/plain', 'text/html']) === 'text/html') {
++ return res.type('html')
++ .set('Content-Security-Policy', HOME_CSP)
++ // private: Cloudflare caches by URL and ignores Vary: Accept, so a shared copy could reach an API client.
++ .set('Cache-Control', 'private, max-age=300')
++ .send(renderHome({ siteUrl: config.siteUrl, apiUrl: config.baseUrl }));
++ }
res.type('text/plain').send([
'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
'',
ddiff --git a/server/config.js b/server/config.js
iindex b1225d2..f876d85 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -73,12 +73,16 @@ function load(env = process.env) {
const port = int(env.PORT, 4300);
const maxInflight = int(env.EVENTS_MAX_INFLIGHT, 20);
const prefixes = sourcePrefixes(env);
++ const baseUrl = (env.BASE_URL || (isProduction ? 'https://events.openvibe.network' : `http://localhost:${port}`)).replace(/\/$/, '');
return {
port,
host: env.HOST || '127.0.0.1',
nodeEnv,
isProduction,
-- baseUrl: (env.BASE_URL || (isProduction ? 'https://events.openvibe.network' : `http://localhost:${port}`)).replace(/\/$/, ''),
++ baseUrl,
++ // The product domain (openvibe.events, plan expansion II): the canonical origin of the home page
++ // and its robots.txt/sitemap.xml/llms.txt. The API stays at baseUrl. Local development follows baseUrl.
++ siteUrl: (env.EVENTS_SITE_URL || (isProduction ? 'https://openvibe.events' : baseUrl)).replace(/\/$/, ''),
// Identity: OpenVibe.Network issues service tokens and user JWTs (RS256).
networkUrl: (env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
ddiff --git a/server/discovery.js b/server/discovery.js
nnew file mode 100644
iindex 0000000..a3a55e6
---- /dev/null
++++ b/server/discovery.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * Crawl and machine-readability artifacts for openvibe.events, the product domain, built from
++ * openvibe-shared/seo the same way every other OpenVibe site builds them:
++ *
++ * GET /robots.txt welcomes search and AI crawlers to the home page, keeps the API and the
++ * realtime gateway out of the index, and always names the sitemap
++ * GET /sitemap.xml the product home only (the API and realtime live at events.openvibe.network)
++ * GET /llms.txt what OpenVibe.Events does today, in plain language: the README's Purpose,
++ * Publishing, Subscriptions and delivery, Developer apps and Realtime sections
++ *
++ * Read-only and viewer-independent: built from the config (the product site URL and the API origin).
++ */
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++const seo = require('openvibe-shared/seo');
++const { DESCRIPTION } = require('./home');
++
++const SOURCE = 'https://github.com/OpenVibers/OpenVibe.Events';
++const GUIDE = `${SOURCE}#readme`;
++
++/** The README's Purpose, Publishing, Subscriptions and delivery, Developer apps and Realtime in plain sentences. */
++function details({ siteUrl, apiUrl }) {
++ return [
++ 'OpenVibe.Events is the durable event backbone of the OpenVibe network. Every authoritative service writes its domain mutation and its outbox event in one transaction, a relay publishes them here, consumers keep inbox/idempotency receipts, and browsers get authorized, resumable projections over the realtime stream; an event exists if and only if the change it describes committed.',
++ '',
++ `Publishing: POST ${apiUrl}/api/v1/events takes one event envelope or up to a hundred at a time, stored atomically and given one global order. A service may only publish the event types its own namespace owns, a repeat of an event id is answered 200 with duplicate: true and is never stored twice, and a producer can redact what it published: the named events become tombstones that every read path serves from then on.`,
++ '',
++ `Subscriptions and delivery: a subscription is a topic pattern such as media.vod.* and an endpoint (http on 127.0.0.1 or an OpenVibe host). Each delivery is an HMAC-signed POST (X-OpenVibe-Signature-V2, over the timestamp and the raw body, inside a five-minute window) and answers 2xx once the event is safely stored; anything else is retried with backoff and, after the last attempt, waits in a dead-letter queue that operators replay. Consumers can also pull in order instead, with an opaque cursor that crosses the retention tiers without a gap.`,
++ '',
++ 'Developer apps: an app signs in through OpenVibe.Network (client credentials, audience openvibe.events) and publishes, reads and subscribes on the same routes under the events.app.* capabilities, scoped to its own project and environment. Sandbox events never reach production, app webhook endpoints must be public HTTPS addresses, and per-project quotas are enforced here.',
++ '',
++ `Realtime: browsers follow topics over server-sent events at ${apiUrl}/realtime/stream with a one-use ticket from OpenVibe.Network; a reconnect resumes from its last cursor, or is told about the gap when the position is too old to replay. Subject-visible events reach only the person they are about, and internal events never reach a browser.`,
++ '',
++ `The guide is ${GUIDE}. The API and the realtime gateway are at ${apiUrl}; the product home is ${siteUrl}/.`,
++ ].join('\n');
++}
++
++function createDiscoveryRoutes({ config }) {
++ const router = express.Router();
++ const abs = (p) => seo.absolute(p, config.siteUrl);
++
++ router.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders())
++ .send(seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/api/', '/realtime/'] })));
++
++ router.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders())
++ .send(seo.sitemapXml([{ loc: abs('/') }])));
++
++ router.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders())
++ .send(seo.llmsTxt({
++ name: 'OpenVibe.Events',
++ summary: DESCRIPTION,
++ details: details({ siteUrl: config.siteUrl, apiUrl: config.baseUrl }),
++ sections: [
++ { title: 'Start here', links: [
++ { title: 'OpenVibe.Events home', url: abs('/') },
++ { title: 'Guide', url: GUIDE, note: 'the README: publishing, subscriptions, developer apps and realtime' },
++ { title: 'Source code', url: SOURCE },
++ ] },
++ { title: 'Machine-readable', links: [
++ { title: 'Sitemap', url: abs('/sitemap.xml') },
++ { title: 'robots.txt', url: abs('/robots.txt') },
++ { title: 'API and realtime', url: config.baseUrl, note: 'the JSON API and the realtime gateway' },
++ ] },
++ ],
++ })));
++
++ return router;
++}
++
++module.exports = { createDiscoveryRoutes };
ddiff --git a/server/home.js b/server/home.js
nnew file mode 100644
iindex 0000000..0e057e2
---- /dev/null
++++ b/server/home.js
@@@ -0,0 +1,121 @@
++'use strict';
++
++/**
++ * The product home of OpenVibe.Events (openvibe.events, plan expansion II: the product domain; the API stays at
++ * events.openvibe.network). The OpenVibe Frame (openvibe-shared/shell) around openvibe-shared/showcase sections
++ * for developers. Every claim restates the README (Publishing, Subscriptions and delivery, Developer apps,
++ * Realtime) and the code; nothing here reads data.
++ *
++ * renderHome({ siteUrl, apiUrl }) the whole document (siteUrl: the canonical product origin; apiUrl: the API origin)
++ * HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
++ */
++const shell = require('openvibe-shared/shell');
++const showcase = require('openvibe-shared/showcase');
++const ovServe = require('openvibe-shared/serve');
++const appIcon = require('openvibe-shared/app-icon');
++
++const SITE_NAME = 'OpenVibe.Events';
++const NETWORK_URL = 'https://openvibe.network';
++const SOURCE = 'https://github.com/OpenVibers/OpenVibe.Events';
++const DESCRIPTION = 'Durable events for the OpenVibe network: publish once, and every subscriber gets it signed, retried and replayable. '
++ + 'Webhooks, pull with a cursor, dead letters, replay and realtime streams for browsers.';
++
++const HOME_CSP = [
++ "default-src 'self'",
++ "script-src 'self' 'unsafe-inline' https://openvibe.network",
++ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",
++ "font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
++ "img-src 'self' data: https:",
++ "connect-src 'self' https://openvibe.network https://events.openvibe.network",
++ "frame-src 'self' https://openvibe.network",
++ "frame-ancestors 'none'",
++ "object-src 'none'",
++ "base-uri 'self'",
++ "form-action 'self' https://openvibe.network",
++].join('; ');
++
++function sections({ apiUrl }) {
++ const publish = [
++ `curl -X POST ${apiUrl}/api/v1/events \\`,
++ ' -H "Authorization: Bearer $TOKEN" -H \'Content-Type: application/json\' \\',
++ ' -d \'{ "event_id": "evt_01JAB2C3D4E5F6G7H8J9K0MNPQ", "event_type": "app.<project_key>.order.created",',
++ ' "version": 1, "source": "app-<app id>", "actor": { "type": "app", "id": "app_<ULID>" },',
++ ' "timestamp": "2026-10-07T12:00:00Z", "subject": { "type": "order", "id": "42" },',
++ ' "payload": { "total": 3 } }\'',
++ ].join('\n');
++ const subscribe = [
++ `curl -X POST ${apiUrl}/api/v1/subscriptions \\`,
++ ' -H "Authorization: Bearer $TOKEN" -H \'Content-Type: application/json\' \\',
++ ' -d \'{ "topic_pattern": "app.<project_key>.*", "endpoint": "https://hooks.example.com/openvibe" }\'',
++ '# → { "id": "sub_…", "secret": "whsec_…" } the secret is shown once',
++ ].join('\n');
++ const pull = [
++ `curl "${apiUrl}/api/v1/events?topic=app.<project_key>.*&limit=100" -H "Authorization: Bearer $TOKEN"`,
++ '# then the same with &after=<next_cursor> from the previous page',
++ ].join('\n');
++ return showcase.hero({
++ eyebrow: 'OpenVibe.Events · alpha',
++ title: 'Events your services', accent: 'can count on.',
++ lede: 'The event backbone of the OpenVibe network. Publish an event once and every subscriber gets it: stored durably, signed, '
++ + 'retried with backoff, and replayable after a failure. Live, Media, Chat, Games, Network and OpenRe.Stream publish to it in production.',
++ actions: [{ label: 'Read the guide', href: `${SOURCE}#readme`, primary: true }, { label: 'See an example', href: '#examples' }],
++ note: 'Alpha. The API is at events.openvibe.network; developer apps sign in through OpenVibe.Network.',
++ }) + showcase.features({
++ title: 'What you get',
++ items: [
++ { icon: 'ov:upload', title: 'Publish once', text: 'One event or up to a hundred at a time, stored atomically and numbered in order. Sending the same event id twice never stores it twice.' },
++ { icon: 'ov:bell', title: 'Signed webhooks', text: 'Subscribe with a topic pattern such as media.vod.*. Every delivery is HMAC-signed with its timestamp and retried with backoff.' },
++ { icon: 'ov:history', title: 'Dead letters and replay', text: 'Deliveries that keep failing wait in a dead-letter queue. Replay them, or catch a new subscription up from a point in its history.' },
++ { icon: 'ov:download', title: 'Pull with a cursor', text: 'Read events in order with an opaque cursor when you would rather pull than receive webhooks.' },
++ { icon: 'ov:live', title: 'Realtime for browsers', text: 'Server-sent events, authorized per topic with a one-use ticket from OpenVibe.Network, and resumable after a reconnect.' },
++ { icon: 'ov:code', title: 'Developer apps', text: 'An app publishes under its own project\'s names and keeps sandbox and production apart: sandbox events never reach production.' },
++ ],
++ }) + showcase.code({
++ id: 'examples',
++ title: 'Publish, subscribe, pull',
++ lede: 'With an app token from OpenVibe.Network (client credentials, audience openvibe.events).',
++ samples: [
++ { label: 'Publish', lang: 'bash', code: publish },
++ { label: 'Subscribe', lang: 'bash', code: subscribe },
++ { label: 'Pull', lang: 'bash', code: pull },
++ ],
++ }) + showcase.steps({
++ title: 'Receive a webhook safely',
++ items: [
++ { title: 'Keep the secret', text: 'The subscription\'s secret is shown once, when you create it or rotate it.' },
++ { title: 'Check the signature', text: 'Verify X-OpenVibe-Signature-V2 over the timestamp and the raw body, and refuse anything older than five minutes.' },
++ { title: 'Answer quickly', text: 'Reply 2xx once the event is safely stored; anything else is retried, then dead-lettered.' },
++ { title: 'Expect repeats', text: 'A retry can bring the same event again: keep its event id and handle it once.' },
++ ],
++ });
++}
++
++function renderHome({ siteUrl, apiUrl }) {
++ const nav = {
++ service: 'events',
++ apiBase: NETWORK_URL,
++ links: [{ label: 'Guide', href: `${SOURCE}#readme` }],
++ history: { type: 'page', title: SITE_NAME },
++ };
++ const footer = { service: 'events', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
++ return shell.page({
++ name: SITE_NAME, service: 'events', lang: 'en',
++ title: `${SITE_NAME}: durable events, signed webhooks and replay`,
++ siteName: SITE_NAME,
++ description: DESCRIPTION,
++ summary: DESCRIPTION,
++ canonical: `${siteUrl}/`,
++ robots: 'index, follow',
++ navbar: nav, footer, home: '/', navLinks: [{ label: 'Guide', href: `${SOURCE}#readme` }],
++ head: [
++ appIcon.headTags({ site: 'events' }),
++ `<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
++ ].join('\n'),
++ body: `<div id="navbar-mount"></div>
++<main id="main" class="page">
++${sections({ apiUrl })}
++</main>`,
++ });
++}
++
++module.exports = { renderHome, HOME_CSP, DESCRIPTION, sections };
ddiff --git a/test/home.test.js b/test/home.test.js
nnew file mode 100644
iindex 0000000..39bd1a3
---- /dev/null
++++ b/test/home.test.js
@@@ -0,0 +1,126 @@
++'use strict';
++// The product home (plan expansion II): openvibe.events renders server/home.js for a browser and keeps
++// the text/plain API index for curl and API clients, serves the pinned OpenVibe Frame at /shared, and
++// answers robots.txt, sitemap.xml and llms.txt. A parse-only check of the new nginx vhost too.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const nodeHttp = require('http');
++const { boot, suite } = require('./helpers');
++const { HOME_CSP } = require('../server/home');
++
++const SITE = 'https://openvibe.events';
++
++// GET / without an Accept a browser would send: the API index, byte for byte, as it was before the home.
++const INDEX = [
++ 'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
++ '',
++ 'POST /api/v1/events publish (service token, events.event.publish; app token, events.app.publish)',
++ 'GET /api/v1/events pull with a cursor (events.event.read; app token, events.app.read)',
++ ' /api/v1/subscriptions webhook subscriptions (events.subscription.manage; app token, events.app.subscribe)',
++ ' /api/v1/deliveries DLQ inspect and replay (events.delivery.admin)',
++ 'GET /realtime/stream?topics=... server-sent events for browsers',
++ 'GET /api/health, /api/ready, /release.json, /limits.json',
++ '',
++ 'Source: https://github.com/OpenVibers/OpenVibe.Events',
++ '',
++].join('\n');
++
++const t = suite('home');
++let h;
++
++/** A raw GET with exactly the headers given: node:http sends no Accept unless the caller sets one. */
++function raw(base, p, headers = {}) {
++ return new Promise((resolve, reject) => {
++ const url = new URL(base + p);
++ nodeHttp.get({ hostname: url.hostname, port: url.port, path: url.pathname + url.search, headers }, (res) => {
++ let body = '';
++ res.setEncoding('utf8');
++ res.on('data', (c) => { body += c; });
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
++ }).on('error', reject);
++ });
++}
++
++t('boot with the product domain configured', async () => { h = await boot({ env: { EVENTS_SITE_URL: SITE } }); });
++
++t('GET / as a browser: the home page, its CSP and its cache policy', async () => {
++ const r = await raw(h.base, '/', { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers['content-type'], /^text\/html/);
++ assert.strictEqual(r.headers['content-security-policy'], HOME_CSP);
++ assert.strictEqual(r.headers['cache-control'], 'private, max-age=300');
++ assert.strictEqual(r.headers.vary, 'Accept');
++ assert.strictEqual((r.body.match(/<h1\b/g) || []).length, 1, 'exactly one h1');
++ assert.ok(r.body.includes('Events your services'), 'the hero text');
++ assert.ok(r.body.includes(`<link rel="canonical" href="${SITE}/">`), 'the canonical product origin');
++});
++
++t('the home links the pinned Frame stylesheet, and /shared serves it', async () => {
++ const r = await raw(h.base, '/', { Accept: 'text/html' });
++ const href = /<link rel="stylesheet" href="([^"]+)"/.exec(r.body);
++ assert.ok(href && href[1].startsWith('/shared/showcase.css?v='), `showcase.css link, got ${href && href[1]}`);
++ const sheet = await raw(h.base, href[1]);
++ assert.strictEqual(sheet.status, 200);
++ assert.match(sheet.headers['content-type'], /^text\/css/);
++ assert.ok(sheet.body.includes('.ov-'), 'the showcase stylesheet');
++});
++
++t('GET / for curl and API clients: the text/plain index, byte for byte', async () => {
++ // `*/*`, no Accept at all, and a JSON client: all three get the API index.
++ for (const headers of [{ Accept: '*/*' }, {}, { Accept: 'application/json' }]) {
++ const r = await raw(h.base, '/', headers);
++ assert.strictEqual(r.status, 200, JSON.stringify(headers));
++ assert.match(r.headers['content-type'], /^text\/plain/, JSON.stringify(headers));
++ assert.strictEqual(r.headers.vary, 'Accept', JSON.stringify(headers));
++ assert.strictEqual(r.body, INDEX, JSON.stringify(headers));
++ }
++});
++
++t('robots.txt welcomes the home and keeps the API out, and names the sitemap', async () => {
++ const r = await raw(h.base, '/robots.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers['content-type'], /^text\/plain/);
++ assert.ok(r.body.includes('Allow: /'));
++ assert.ok(r.body.includes('Disallow: /api/'));
++ assert.ok(r.body.includes('Disallow: /realtime/'));
++ assert.ok(r.body.includes(`Sitemap: ${SITE}/sitemap.xml`));
++});
++
++t('sitemap.xml lists the product home only', async () => {
++ const r = await raw(h.base, '/sitemap.xml');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers['content-type'], /xml/);
++ assert.deepStrictEqual([...r.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]), [`${SITE}/`]);
++});
++
++t('llms.txt is a plain-text map that starts with the site name', async () => {
++ const r = await raw(h.base, '/llms.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers['content-type'], /^text\/plain/);
++ assert.ok(r.body.startsWith('# OpenVibe.Events'), r.body.slice(0, 40));
++ assert.ok(r.body.includes('> Durable events for the OpenVibe network'), 'the home summary');
++ assert.ok(r.body.includes('https://github.com/OpenVibers/OpenVibe.Events#readme'), 'the guide');
++});
++
++t('deploy/nginx/openvibe.events.conf proxies the product surface and nothing else', () => {
++ const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.events.conf'), 'utf8');
++ assert.match(conf, /server_name openvibe\.events;/);
++ assert.match(conf, /server_name www\.openvibe\.events;/);
++ assert.ok(conf.includes('/etc/letsencrypt/live/openvibe.events/fullchain.pem'));
++ assert.ok(conf.includes('/etc/letsencrypt/live/openvibe.events/privkey.pem'));
++ assert.ok(!conf.includes('$proxy_add_x_forwarded_for'), 'the client address is never appended to');
++ for (const loc of ['location = / {', 'location ^~ /shared/ {', 'location = /robots.txt {', 'location = /sitemap.xml {', 'location = /llms.txt {']) {
++ assert.ok(conf.includes(loc), `${loc} is proxied`);
++ }
++ const refusals = [conf.indexOf('location = /metrics { return 404; }'), conf.indexOf('location /internal/ { return 404; }')];
++ assert.ok(refusals.every((i) => i >= 0), 'metrics and /internal/ are refused');
++ const catchAll = conf.indexOf('location / { return 301 https://events.openvibe.network$request_uri; }');
++ assert.ok(catchAll >= 0, 'everything else goes to the API host');
++ assert.ok(Math.max(...refusals) < catchAll, 'the refusals come before the catch-all');
++ assert.ok(conf.includes('return 301 https://openvibe.events$request_uri;'), ':80 and www reach the apex');
++});
++
++t('stop', async () => { await h.stop(); });
++
++t.run();