openvibers/openvibe.events Commits
Events: openvibe.events serves the API and the realtime stream too (plan T7 layer 1) (#20)
Changes
@@@ -9,8 +9,12 @@PORT=4300HOST=127.0.0.1NODE_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.++# The API origin (plan T7): everything Events names about itself — Location headers, realtime URLs,++# limits.json links, llms.txt/sitemap, the home's examples — uses it; defaults to https://openvibe.events++# in production, to http://localhost:PORT otherwise. events.openvibe.network answers until every client++# moves, then redirects (308).++#BASE_URL=https://openvibe.events++# The product origin; defaults to BASE_URL (openvibe.events is the API and product origin, plan T7).#EVENTS_SITE_URL=https://openvibe.events# Identity — OpenVibe.Network signs service tokens (client_credentials, audience@@@ -2,8 +2,8 @@> 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). The product domain `openvibe.events` serves only the home page and its `robots.txt`/`sitemap.xml`/`llms.txt`, and redirects everything else here.++**Status:** alpha (roadmap Wave 3), deployed on `openvibe-ovh` (unit `openvibe-events`, 127.0.0.1:4300, public at `https://openvibe.events`). 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:** `openvibe.events` is the API and product origin (plan T7): the JSON API, the realtime gateway (ADR-005 put Realtime inside Events; `realtime.openvibe.network` has no runtime of its own), the home page and its `robots.txt`/`sitemap.xml`/`llms.txt`. `events.openvibe.network` answers the same surface until every client moves, then redirects (308).**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).@@@ -20,7 +20,7 @@ npm run dev # http://127.0.0.1:4300npm test # every test/*.test.js, temp databases, no network needed```--Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/openvibe.events`, env `/etc/openvibe/events.env`, unit [deploy/systemd/openvibe-events.service](deploy/systemd/openvibe-events.service), store PostgreSQL (`ov_events` on the host's data role), nginx [deploy/nginx/events.openvibe.network.conf](deploy/nginx/events.openvibe.network.conf) (public vhost exposes `/realtime/stream`, health, and the token-guarded `/api/v1/events`, `/api/v1/subscriptions` and `/api/v1/checkpoints` for developer apps; `/api/v1/deliveries` stays host-local; services on the host call `127.0.0.1:4300`).++Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/openvibe.events`, env `/etc/openvibe/events.env`, unit [deploy/systemd/openvibe-events.service](deploy/systemd/openvibe-events.service), store PostgreSQL (`ov_events` on the host's data role), nginx [deploy/nginx/openvibe.events.conf](deploy/nginx/openvibe.events.conf) (the public vhost, on the API and product origin: it exposes the home and its discovery files, `/realtime/stream`, health, and the token-guarded `/api/v1/events`, `/api/v1/subscriptions` and `/api/v1/checkpoints` for developer apps; `/api/v1/deliveries` stays host-local; services on the host call `127.0.0.1:4300`).`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 200 only when every required check passes — `db` (a real query), `network_jwks` (the Network signing key has loaded; it retries every 30 s while Network boots) and `delivery_worker` (when `EVENTS_WORKER` is on) — and 503 otherwise, with `failed: [...]`. The optional `dlq` check fails once more than `EVENTS_DLQ_DEGRADED_AT` (default 100) deliveries are dead: the service stays ready (200) and reports `status: "degraded"`, `degraded: ["dlq"]`. Each check carries `status`, `required`, `latency_ms`, `checked_at` and, for `dlq`, `detail: { depth, threshold }`; `latest_seq`, `deliveries`, `worker` and `realtime_connections` are still in the body. **Shape change (Track O):** `checks` used to be booleans (`{ db, worker, key }`); they are now objects keyed `db`, `network_jwks`, `delivery_worker`, `dlq`.@@@ -180,7 +180,7 @@ Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`P**Names.** `project_key` is `p` followed by the project's ULID in lowercase: `prj_01JAB2C3D4E5F6G7H8J9K0MNPQ` → `p01jab2c3d4e5f6g7h8j9k0mnpq`. An app event's `source` is `app-` followed by the app's ULID in lowercase: `app:app_01JAB…` → `app-01jab…`. Both fit the existing `events.event-envelope@1` patterns, so the envelope contract did not change. `actor` is `{ "type": "app", "id": "app_<ULID>" }`, or the user in the token's `on_behalf_of`. First-party services can never publish `app.*` (the source names `app` and `app-*` are reserved and refused in `EVENTS_SOURCE_PREFIXES`).```bash--EVENTS=https://events.openvibe.network++EVENTS=https://openvibe.eventsPK=p$(echo "${PRJ#prj_}" | tr 'A-Z' 'a-z') # project_keySRC=app-$(echo "${APP#app_}" | tr 'A-Z' 'a-z') # sourcecurl -s -X POST "$EVENTS/api/v1/events" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{@@@ -284,7 +284,7 @@ void connect();```- Auth: a **realtime ticket** (`?ticket=`), the Network `ov_token` cookie or a Bearer user JWT (the `ov_token` cookie is scoped to its issuing host, and an EventSource cannot set an Authorization header, so a client on another site uses a ticket); a service token with `events.event.read`; or nobody (public events only, `REALTIME_ALLOW_ANONYMOUS`). An expired cookie degrades to anonymous; a bad Bearer is a 401.--- Realtime tickets (ADR-005 amendment 2): a page on any OpenVibe site cannot count on a cookie of events.openvibe.network (third-party there), and an EventSource cannot send a header. So it asks Network for a ticket (`POST https://openvibe.network/api/v1/realtime/ticket`, answering `network.realtime-ticket-result@1`) and opens `/realtime/stream?topics=network.notification.*&ticket=<ticket>` without credentials. The ticket is an RS256 JWT signed with Network's key (`identity.realtime-ticket-claims@1`): `iss <OV_NETWORK_ISSUER>/realtime`, `sub <usr_>`, `aud [openvibe.events]`, `typ` and `purpose` `realtime`, a lifetime of at most 300 s (Network mints 120 s) and `jti rtk_…`.++- Realtime tickets (ADR-005 amendment 2): a page on any OpenVibe site cannot count on a cookie of openvibe.events (third-party there), and an EventSource cannot send a header. So it asks Network for a ticket (`POST https://openvibe.network/api/v1/realtime/ticket`, answering `network.realtime-ticket-result@1`) and opens `/realtime/stream?topics=network.notification.*&ticket=<ticket>` without credentials. The ticket is an RS256 JWT signed with Network's key (`identity.realtime-ticket-claims@1`): `iss <OV_NETWORK_ISSUER>/realtime`, `sub <usr_>`, `aud [openvibe.events]`, `typ` and `purpose` `realtime`, a lifetime of at most 300 s (Network mints 120 s) and `jti rtk_…`.- Events accepts each ticket once, never as Bearer or cookie, and never logs it. The refusals are 401: `ticket.invalid`, `ticket.expired` and `ticket.used`.- A reconnect asks for a new ticket and resumes with `last_event_id` (the cursor from the last message's SSE `id`).- Conversely, a user JWT that carries `typ` or `purpose` (a ticket, a FedCM assertion) is not a session here.@@@ -356,10 +356,10 @@ Production deploys with `sudo ovhost deploy events` on the host (strategy `git-cfast-forward `/opt/openvibe.events`, install on a lockfile change, restart, wait for `/api/ready`).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); 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++release migrates it at boot. nginx serves `openvibe.events`, the API and product origin, from++[deploy/nginx/openvibe.events.conf](deploy/nginx/openvibe.events.conf); `events.openvibe.network` answers the same++surface from [deploy/nginx/events.openvibe.network.conf](deploy/nginx/events.openvibe.network.conf) until every client++moves, then redirects (308). ovhost treats openrealtime 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 therestart; afterwards `sudo ovhost rollback events --to <sha>`. Migrations only add tables and columns.@@@ -6,7 +6,7 @@"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4300,-- "domain": "events.openvibe.network",++ "domain": "openvibe.events","contracts": "openvibe-contracts v0.110.0","capabilities": ["events.event.publish",@@@ -19,7 +19,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",++ "deployment": "Deployed with `sudo ovhost deploy events`: /opt/openvibe.events, unit openvibe-events, 127.0.0.1:4300; public at https://openvibe.events (plan T7: the API and product origin; realtime SSE, health, token-guarded /api/v1/events, /api/v1/subscriptions, /api/v1/checkpoints; /api/v1/deliveries host-local; events.openvibe.network answers the same surface until every client moves, then redirects 308); 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-07","features": ["durable event store with the canonical envelope",@@@ -4,21 +4,33 @@## 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.++## openvibe.events is the API and product origin (plan T7): the same Node service (port 4300,++## systemd unit openvibe-events) answers the product home and its discovery files, the++## token-guarded JSON API, the realtime gateway and health, all here. events.openvibe.network++## keeps answering the same surface unchanged (deploy/nginx/events.openvibe.network.conf) until++## every client has moved; a later change turns it into a redirect (308) to this host.##--## The Node service (port 4300, systemd unit openvibe-events) answers here:++## The Node service (port 4300) 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)++## /realtime/stream browser Server-Sent Events (long-lived; must not be buffered)++## /api/health, /api/ready, /release.json, /limits.json++## /api/v1/events, /api/v1/subscriptions, /api/v1/checkpoints++## public, token-guarded: developer apps (events.app.* capabilities, ADR-014)++## and off-host services. Every request needs a Network token.++## /api/v1/deliveries operators only (events.delivery.admin): host-local, refused here.++## Services on this host call 127.0.0.1:4300 directly.## /metrics host-local scrapers only (http://127.0.0.1:4300/metrics, never proxied)## /internal/ host-local service endpoints, refused here## ═══════════════════════════════════════════════════════════════++# Distinct zone names: events.openvibe.network.conf declares its own and both files load in one config.++limit_req_zone $binary_remote_addr zone=ovevents_apex_connect:10m rate=2r/s;++limit_conn_zone $binary_remote_addr zone=ovevents_apex_streams:10m;++limit_req_zone $binary_remote_addr zone=ovevents_apex_api:10m rate=20r/s;++# ── HTTP → HTTPS redirect ──────────────────────────────────server {listen 80;@@@ -57,19 +69,79 @@ server {access_log /var/log/nginx/openvibe.events.access.log;error_log /var/log/nginx/openvibe.events.error.log;++ client_max_body_size 2m;++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;++ # ── Realtime (SSE): no buffering, no compression, long reads ──++ # The service sends a heartbeat comment every 25 s, well inside these timeouts and++ # Cloudflare's 100 s idle limit. CORS is answered by the service (https://*.openvibe.* only).++ location = /realtime/stream {++ access_log /var/log/nginx/openvibe.events.access.log ov_events_noquery; # never the ?ticket= (log_format_events_noquery.conf)++ limit_req zone=ovevents_apex_connect burst=20 nodelay;++ limit_conn ovevents_apex_streams 20;++ proxy_pass http://127.0.0.1:4300;++ 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;++ proxy_set_header Last-Event-ID $http_last_event_id;++ proxy_buffering off;++ proxy_cache off;++ gzip off;++ chunked_transfer_encoding on;++ proxy_read_timeout 1h;++ proxy_send_timeout 1h;++ send_timeout 1h;++ }++++ # The client address comes from $remote_addr only on every proxied location: never appended to,++ # never passed through. X-Forwarded-For on every API location: the service treats a request without it from++ # 127.0.0.1 as a local caller (GET /metrics answers only those).++ location = /api/health { 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 = /api/ready { 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 = /release.json { 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 = /limits.json { 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; }++# 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.++ # Internal service endpoints (Live's /internal/media-events, ...) are host-local.location /internal/ { return 404; }++ # ── Token-guarded API for developer apps (see header) ──++ # Per-project quotas are enforced by the service; this only caps per-IP request rates.++ location ~ ^/api/v1/(events|subscriptions|checkpoints)(/|$) {++ limit_req zone=ovevents_apex_api burst=40 nodelay;++ proxy_pass http://127.0.0.1:4300;++ 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;++ }++++ # ── Everything else under /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:4300;++ # proxy_http_version 1.1;++ # proxy_set_header Host $host;++ # proxy_set_header X-Real-IP $remote_addr; # client address from $remote_addr only (realip),++ # proxy_set_header X-Forwarded-For $remote_addr; # never appended to or passed through++ # proxy_set_header CF-Connecting-IP $remote_addr;++ # proxy_set_header X-Forwarded-Proto $scheme;++ }++# ── 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;@@@ -110,6 +182,6 @@ server {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; }++ # ── Everything else: no route here ──++ location / { return 404; }}@@@ -21,7 +21,7 @@ reads span both tables. `EVENTS_REPLAY_RETENTION_DAYS` defaults to 365; `0` is ttakes milliseconds and holds no lock on `events`.2. `/etc/openvibe/events.env`: nothing to add for the default (365 days of replay). To keep today's behaviour, set`EVENTS_REPLAY_RETENTION_DAYS=0` and restart.--3. Check: `curl -s https://events.openvibe.network/limits.json` lists `replay_retention_days`; after the first prune++3. Check: `curl -s https://openvibe.events/limits.json` lists `replay_retention_days`; after the first prune(start + up to `EVENTS_PRUNE_INTERVAL_MS`) the journal (`ov access run openvibe-ovh journal openvibe-events.service 200`)shows `[retention] pruned N hot events (N moved to replay), …` when anything was due, and`node scripts/events-archive.js status` (on the host, with the service env) lists the replay rows per month.@@@ -77,16 +77,18 @@ 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(/\/$/, '');++ // openvibe.events is the API origin and the product origin (plan T7); events.openvibe.network answers++ // until every client moves, then redirects (308). Everything Events names about itself uses this.++ const baseUrl = (env.BASE_URL || (isProduction ? 'https://openvibe.events' : `http://localhost:${port}`)).replace(/\/$/, '');return {port,host: env.HOST || '127.0.0.1',nodeEnv,isProduction,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(/\/$/, ''),++ // The product origin is the API origin (plan T7): the home page and its robots.txt/sitemap.xml/++ // llms.txt are served from baseUrl. EVENTS_SITE_URL overrides it for a deployment that serves them apart.++ siteUrl: (env.EVENTS_SITE_URL || baseUrl).replace(/\/$/, ''),// Identity: OpenVibe.Network issues service tokens and user JWTs (RS256).networkUrl: (env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),@@@ -5,7 +5,7 @@** 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 /sitemap.xml the product home only (openvibe.events is the API origin too, plan T7)* GET /llms.txt what OpenVibe.Events does today, in plain language: the README's Purpose,* Publishing, Subscriptions and delivery, Developer apps and Realtime sections*@@@ -1,8 +1,8 @@'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++ * The product home of OpenVibe.Events (openvibe.events, plan T7: the API origin is the product origin too).++ * 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.*@@@ -26,7 +26,7 @@ const HOME_CSP = ["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",++ "connect-src 'self' https://openvibe.network https://openvibe.events","frame-src 'self' https://openvibe.network","frame-ancestors 'none'","object-src 'none'",@@@ -59,7 +59,7 @@ function sections({ apiUrl }) {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.',++ note: `Alpha. The API is at ${apiUrl}; developer apps sign in through OpenVibe.Network.`,}) + showcase.features({title: 'What you get',items: [@@@ -1,12 +1,14 @@'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.++// The product home (plan T7: openvibe.events is the API and product origin): it 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, answers robots.txt, sitemap.xml and llms.txt, and names its own origin everywhere.++// A parse-only check of the 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 { load } = require('../server/config');const { HOME_CSP } = require('../server/home');const SITE = 'https://openvibe.events';@@@ -42,7 +44,15 @@ function raw(base, p, headers = {}) {});}--t('boot with the product domain configured', async () => { h = await boot({ env: { EVENTS_SITE_URL: SITE } }); });++t('in production the API origin and the product origin are the same host', () => {++ const production = load({ NODE_ENV: 'production' });++ assert.strictEqual(production.baseUrl, SITE, 'the API origin defaults to openvibe.events');++ assert.strictEqual(production.siteUrl, SITE, 'the product origin is the same origin');++});++++t('boot with openvibe.events as both the API and the product origin', async () => {++ h = await boot({ env: { BASE_URL: SITE } }); // siteUrl follows baseUrl++});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' });@@@ -54,6 +64,9 @@ t('GET / as a browser: the home page, its CSP and its cache policy', async () =>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');++ assert.ok(r.body.includes(`The API is at ${SITE}`), 'the hero note names the API origin');++ assert.ok(r.body.includes(`${SITE}/api/v1/events`), 'the API examples use the API origin');++ assert.ok(!r.body.includes('events.openvibe.network'), 'the home names no other origin');});t('the home links the pinned Frame stylesheet, and /shared serves it', async () => {@@@ -101,23 +114,28 @@ t('llms.txt is a plain-text map that starts with the site name', async () => {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');++ assert.ok(r.body.includes(`The API and the realtime gateway are at ${SITE}`), 'the discovery names the API origin');});--t('deploy/nginx/openvibe.events.conf proxies the product surface and nothing else', () => {++t('deploy/nginx/openvibe.events.conf is the API and product origin, with no redirect to the old host', () => {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 {']) {++ // The product surface (plan expansion II) and everything the API host proxies (plan T7).++ for (const loc of ['location = / {', 'location ^~ /shared/ {', 'location = /robots.txt {', 'location = /sitemap.xml {', 'location = /llms.txt {',++ 'location = /realtime/stream {', 'location = /api/health', 'location = /api/ready', 'location = /release.json', 'location = /limits.json']) {assert.ok(conf.includes(loc), `${loc} is proxied`);}++ assert.ok(conf.includes('location ~ ^/api/v1/(events|subscriptions|checkpoints)(/|$)'), 'the token-guarded API block');++ assert.ok(conf.includes('location /api/ {'), 'the /api/ refusal block');++ assert.ok(conf.includes('proxy_buffering off;'), 'SSE is not buffered');++ assert.ok(conf.includes('proxy_read_timeout 1h;'), 'SSE keeps its long read timeout');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('events.openvibe.network$request_uri'), 'no catch-all redirect to the old API host');assert.ok(conf.includes('return 301 https://openvibe.events$request_uri;'), ':80 and www reach the apex');});
@@ -9,8 +9,12 @@
@@ -9,8 +9,12 @@
PORT=4300
PORT=4300
HOST=127.0.0.1
HOST=127.0.0.1
NODE_ENV=development
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.
+# The API origin (plan T7): everything Events names about itself — Location headers, realtime URLs,
+# limits.json links, llms.txt/sitemap, the home's examples — uses it; defaults to https://openvibe.events
+# in production, to http://localhost:PORT otherwise. events.openvibe.network answers until every client
+# moves, then redirects (308).
+#BASE_URL=https://openvibe.events
+# The product origin; defaults to BASE_URL (openvibe.events is the API and product origin, plan T7).
#EVENTS_SITE_URL=https://openvibe.events
#EVENTS_SITE_URL=https://openvibe.events
# Identity — OpenVibe.Network signs service tokens (client_credentials, audience
# Identity — OpenVibe.Network signs service tokens (client_credentials, audience
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
> Durable events, subscriptions, delivery, retry, dead letters and replay for the network.
> 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). The product domain `openvibe.events` serves only the home page and its `robots.txt`/`sitemap.xml`/`llms.txt`, and redirects everything else here.
+**Status:** alpha (roadmap Wave 3), deployed on `openvibe-ovh` (unit `openvibe-events`, 127.0.0.1:4300, public at `https://openvibe.events`). 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:** `openvibe.events` is the API and product origin (plan T7): the JSON API, the realtime gateway (ADR-005 put Realtime inside Events; `realtime.openvibe.network` has no runtime of its own), the home page and its `robots.txt`/`sitemap.xml`/`llms.txt`. `events.openvibe.network` answers the same surface until every client moves, then redirects (308).
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §6 and §6.3.
**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).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -20,7 +20,7 @@ npm run dev # http://127.0.0.1:4300
@@ -20,7 +20,7 @@ npm run dev # http://127.0.0.1:4300
npm test # every test/*.test.js, temp databases, no network needed
npm test # every test/*.test.js, temp databases, no network needed
```
```
-Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/openvibe.events`, env `/etc/openvibe/events.env`, unit [deploy/systemd/openvibe-events.service](deploy/systemd/openvibe-events.service), store PostgreSQL (`ov_events` on the host's data role), nginx [deploy/nginx/events.openvibe.network.conf](deploy/nginx/events.openvibe.network.conf) (public vhost exposes `/realtime/stream`, health, and the token-guarded `/api/v1/events`, `/api/v1/subscriptions` and `/api/v1/checkpoints` for developer apps; `/api/v1/deliveries` stays host-local; services on the host call `127.0.0.1:4300`).
+Node 22 in production (`fnm exec --using=22.22.1 npm test`). Production: `/opt/openvibe.events`, env `/etc/openvibe/events.env`, unit [deploy/systemd/openvibe-events.service](deploy/systemd/openvibe-events.service), store PostgreSQL (`ov_events` on the host's data role), nginx [deploy/nginx/openvibe.events.conf](deploy/nginx/openvibe.events.conf) (the public vhost, on the API and product origin: it exposes the home and its discovery files, `/realtime/stream`, health, and the token-guarded `/api/v1/events`, `/api/v1/subscriptions` and `/api/v1/checkpoints` for developer apps; `/api/v1/deliveries` stays host-local; services on the host call `127.0.0.1:4300`).
`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 200 only when every required check passes — `db` (a real query), `network_jwks` (the Network signing key has loaded; it retries every 30 s while Network boots) and `delivery_worker` (when `EVENTS_WORKER` is on) — and 503 otherwise, with `failed: [...]`. The optional `dlq` check fails once more than `EVENTS_DLQ_DEGRADED_AT` (default 100) deliveries are dead: the service stays ready (200) and reports `status: "degraded"`, `degraded: ["dlq"]`. Each check carries `status`, `required`, `latency_ms`, `checked_at` and, for `dlq`, `detail: { depth, threshold }`; `latest_seq`, `deliveries`, `worker` and `realtime_connections` are still in the body. **Shape change (Track O):** `checks` used to be booleans (`{ db, worker, key }`); they are now objects keyed `db`, `network_jwks`, `delivery_worker`, `dlq`.
`GET /api/health` is liveness. `GET /api/ready` (openvibe-shared/ready) is 200 only when every required check passes — `db` (a real query), `network_jwks` (the Network signing key has loaded; it retries every 30 s while Network boots) and `delivery_worker` (when `EVENTS_WORKER` is on) — and 503 otherwise, with `failed: [...]`. The optional `dlq` check fails once more than `EVENTS_DLQ_DEGRADED_AT` (default 100) deliveries are dead: the service stays ready (200) and reports `status: "degraded"`, `degraded: ["dlq"]`. Each check carries `status`, `required`, `latency_ms`, `checked_at` and, for `dlq`, `detail: { depth, threshold }`; `latest_seq`, `deliveries`, `worker` and `realtime_connections` are still in the body. **Shape change (Track O):** `checks` used to be booleans (`{ db, worker, key }`); they are now objects keyed `db`, `network_jwks`, `delivery_worker`, `dlq`.
@@ -180,7 +180,7 @@ Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`P
@@ -180,7 +180,7 @@ Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`P
**Names.** `project_key` is `p` followed by the project's ULID in lowercase: `prj_01JAB2C3D4E5F6G7H8J9K0MNPQ` → `p01jab2c3d4e5f6g7h8j9k0mnpq`. An app event's `source` is `app-` followed by the app's ULID in lowercase: `app:app_01JAB…` → `app-01jab…`. Both fit the existing `events.event-envelope@1` patterns, so the envelope contract did not change. `actor` is `{ "type": "app", "id": "app_<ULID>" }`, or the user in the token's `on_behalf_of`. First-party services can never publish `app.*` (the source names `app` and `app-*` are reserved and refused in `EVENTS_SOURCE_PREFIXES`).
**Names.** `project_key` is `p` followed by the project's ULID in lowercase: `prj_01JAB2C3D4E5F6G7H8J9K0MNPQ` → `p01jab2c3d4e5f6g7h8j9k0mnpq`. An app event's `source` is `app-` followed by the app's ULID in lowercase: `app:app_01JAB…` → `app-01jab…`. Both fit the existing `events.event-envelope@1` patterns, so the envelope contract did not change. `actor` is `{ "type": "app", "id": "app_<ULID>" }`, or the user in the token's `on_behalf_of`. First-party services can never publish `app.*` (the source names `app` and `app-*` are reserved and refused in `EVENTS_SOURCE_PREFIXES`).
```bash
```bash
-EVENTS=https://events.openvibe.network
+EVENTS=https://openvibe.events
PK=p$(echo "${PRJ#prj_}" | tr 'A-Z' 'a-z') # project_key
PK=p$(echo "${PRJ#prj_}" | tr 'A-Z' 'a-z') # project_key
SRC=app-$(echo "${APP#app_}" | tr 'A-Z' 'a-z') # source
SRC=app-$(echo "${APP#app_}" | tr 'A-Z' 'a-z') # source
curl -s -X POST "$EVENTS/api/v1/events" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{
curl -s -X POST "$EVENTS/api/v1/events" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{
@@ -284,7 +284,7 @@ void connect();
@@ -284,7 +284,7 @@ void connect();
```
```
- Auth: a **realtime ticket** (`?ticket=`), the Network `ov_token` cookie or a Bearer user JWT (the `ov_token` cookie is scoped to its issuing host, and an EventSource cannot set an Authorization header, so a client on another site uses a ticket); a service token with `events.event.read`; or nobody (public events only, `REALTIME_ALLOW_ANONYMOUS`). An expired cookie degrades to anonymous; a bad Bearer is a 401.
- Auth: a **realtime ticket** (`?ticket=`), the Network `ov_token` cookie or a Bearer user JWT (the `ov_token` cookie is scoped to its issuing host, and an EventSource cannot set an Authorization header, so a client on another site uses a ticket); a service token with `events.event.read`; or nobody (public events only, `REALTIME_ALLOW_ANONYMOUS`). An expired cookie degrades to anonymous; a bad Bearer is a 401.
-- Realtime tickets (ADR-005 amendment 2): a page on any OpenVibe site cannot count on a cookie of events.openvibe.network (third-party there), and an EventSource cannot send a header. So it asks Network for a ticket (`POST https://openvibe.network/api/v1/realtime/ticket`, answering `network.realtime-ticket-result@1`) and opens `/realtime/stream?topics=network.notification.*&ticket=<ticket>` without credentials. The ticket is an RS256 JWT signed with Network's key (`identity.realtime-ticket-claims@1`): `iss <OV_NETWORK_ISSUER>/realtime`, `sub <usr_>`, `aud [openvibe.events]`, `typ` and `purpose` `realtime`, a lifetime of at most 300 s (Network mints 120 s) and `jti rtk_…`.
+- Realtime tickets (ADR-005 amendment 2): a page on any OpenVibe site cannot count on a cookie of openvibe.events (third-party there), and an EventSource cannot send a header. So it asks Network for a ticket (`POST https://openvibe.network/api/v1/realtime/ticket`, answering `network.realtime-ticket-result@1`) and opens `/realtime/stream?topics=network.notification.*&ticket=<ticket>` without credentials. The ticket is an RS256 JWT signed with Network's key (`identity.realtime-ticket-claims@1`): `iss <OV_NETWORK_ISSUER>/realtime`, `sub <usr_>`, `aud [openvibe.events]`, `typ` and `purpose` `realtime`, a lifetime of at most 300 s (Network mints 120 s) and `jti rtk_…`.
- Events accepts each ticket once, never as Bearer or cookie, and never logs it. The refusals are 401: `ticket.invalid`, `ticket.expired` and `ticket.used`.
- Events accepts each ticket once, never as Bearer or cookie, and never logs it. The refusals are 401: `ticket.invalid`, `ticket.expired` and `ticket.used`.
- A reconnect asks for a new ticket and resumes with `last_event_id` (the cursor from the last message's SSE `id`).
- A reconnect asks for a new ticket and resumes with `last_event_id` (the cursor from the last message's SSE `id`).
- Conversely, a user JWT that carries `typ` or `purpose` (a ticket, a FedCM assertion) is not a session here.
- Conversely, a user JWT that carries `typ` or `purpose` (a ticket, a FedCM assertion) is not a session here.
@@ -356,10 +356,10 @@ Production deploys with `sudo ovhost deploy events` on the host (strategy `git-c
@@ -356,10 +356,10 @@ Production deploys with `sudo ovhost deploy events` on the host (strategy `git-c
fast-forward `/opt/openvibe.events`, install on a lockfile change, restart, wait for `/api/ready`).
fast-forward `/opt/openvibe.events`, install on a lockfile change, restart, wait for `/api/ready`).
The unit is `openvibe-events.service` on `127.0.0.1:4300`, the env file `/etc/openvibe/events.env`. The database is
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
`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); 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
+release migrates it at boot. nginx serves `openvibe.events`, the API and product origin, from
+[deploy/nginx/openvibe.events.conf](deploy/nginx/openvibe.events.conf); `events.openvibe.network` answers the same
+surface from [deploy/nginx/events.openvibe.network.conf](deploy/nginx/events.openvibe.network.conf) until every client
+moves, then redirects (308). ovhost treats open
realtime connections as a report-only drain, so `--wait-idle` waits for them.
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
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.
restart; afterwards `sudo ovhost rollback events --to <sha>`. Migrations only add tables and columns.
@@ -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, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4300,
"port": 4300,
- "domain": "events.openvibe.network",
+ "domain": "openvibe.events",
"contracts": "openvibe-contracts v0.110.0",
"contracts": "openvibe-contracts v0.110.0",
"capabilities": [
"capabilities": [
"events.event.publish",
"events.event.publish",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"deployed": 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",
+ "deployment": "Deployed with `sudo ovhost deploy events`: /opt/openvibe.events, unit openvibe-events, 127.0.0.1:4300; public at https://openvibe.events (plan T7: the API and product origin; realtime SSE, health, token-guarded /api/v1/events, /api/v1/subscriptions, /api/v1/checkpoints; /api/v1/deliveries host-local; events.openvibe.network answers the same surface until every client moves, then redirects 308); 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-07",
"updated": "2026-10-07",
"features": [
"features": [
"durable event store with the canonical envelope",
"durable event store with the canonical envelope",
@@ -4,21 +4,33 @@
@@ -4,21 +4,33 @@
## Deployed to /etc/nginx/sites-available/openvibe.events.conf — keep them in sync,
## Deployed to /etc/nginx/sites-available/openvibe.events.conf — keep them in sync,
## and reload nginx after changing it.
## 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.
+## openvibe.events is the API and product origin (plan T7): the same Node service (port 4300,
+## systemd unit openvibe-events) answers the product home and its discovery files, the
+## token-guarded JSON API, the realtime gateway and health, all here. events.openvibe.network
+## keeps answering the same surface unchanged (deploy/nginx/events.openvibe.network.conf) until
+## every client has moved; a later change turns it into a redirect (308) to this host.
##
##
-## The Node service (port 4300, systemd unit openvibe-events) answers here:
+## The Node service (port 4300) answers here:
## / the product home (server/home.js) for a browser, the text/plain API index otherwise
## / 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)
## /shared/ the OpenVibe Frame's pinned browser files (openvibe-shared/serve)
## /robots.txt, /sitemap.xml, /llms.txt
## /robots.txt, /sitemap.xml, /llms.txt
## discovery for the product domain (server/discovery.js)
## discovery for the product domain (server/discovery.js)
+## /realtime/stream browser Server-Sent Events (long-lived; must not be buffered)
+## /api/health, /api/ready, /release.json, /limits.json
+## /api/v1/events, /api/v1/subscriptions, /api/v1/checkpoints
+## public, token-guarded: developer apps (events.app.* capabilities, ADR-014)
+## and off-host services. Every request needs a Network token.
+## /api/v1/deliveries operators only (events.delivery.admin): host-local, refused here.
+## Services on this host call 127.0.0.1:4300 directly.
## /metrics host-local scrapers only (http://127.0.0.1:4300/metrics, never proxied)
## /metrics host-local scrapers only (http://127.0.0.1:4300/metrics, never proxied)
## /internal/ host-local service endpoints, refused here
## /internal/ host-local service endpoints, refused here
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
+# Distinct zone names: events.openvibe.network.conf declares its own and both files load in one config.
+limit_req_zone $binary_remote_addr zone=ovevents_apex_connect:10m rate=2r/s;
+limit_conn_zone $binary_remote_addr zone=ovevents_apex_streams:10m;
+limit_req_zone $binary_remote_addr zone=ovevents_apex_api:10m rate=20r/s;
+
# ── HTTP → HTTPS redirect ──────────────────────────────────
# ── HTTP → HTTPS redirect ──────────────────────────────────
server {
server {
listen 80;
listen 80;
@@ -57,19 +69,79 @@ server {
@@ -57,19 +69,79 @@ server {
access_log /var/log/nginx/openvibe.events.access.log;
access_log /var/log/nginx/openvibe.events.access.log;
error_log /var/log/nginx/openvibe.events.error.log;
error_log /var/log/nginx/openvibe.events.error.log;
+ client_max_body_size 2m;
+
add_header X-Content-Type-Options nosniff always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+ # ── Realtime (SSE): no buffering, no compression, long reads ──
+ # The service sends a heartbeat comment every 25 s, well inside these timeouts and
+ # Cloudflare's 100 s idle limit. CORS is answered by the service (https://*.openvibe.* only).
+ location = /realtime/stream {
+ access_log /var/log/nginx/openvibe.events.access.log ov_events_noquery; # never the ?ticket= (log_format_events_noquery.conf)
+ limit_req zone=ovevents_apex_connect burst=20 nodelay;
+ limit_conn ovevents_apex_streams 20;
+ proxy_pass http://127.0.0.1:4300;
+ 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;
+ proxy_set_header Last-Event-ID $http_last_event_id;
+ proxy_buffering off;
+ proxy_cache off;
+ gzip off;
+ chunked_transfer_encoding on;
+ proxy_read_timeout 1h;
+ proxy_send_timeout 1h;
+ send_timeout 1h;
+ }
+
+ # The client address comes from $remote_addr only on every proxied location: never appended to,
+ # never passed through. X-Forwarded-For on every API location: the service treats a request without it from
+ # 127.0.0.1 as a local caller (GET /metrics answers only those).
+ location = /api/health { 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 = /api/ready { 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 = /release.json { 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 = /limits.json { 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; }
+
# Metrics are for host-local scrapers only (http://127.0.0.1:4300/metrics).
# Metrics are for host-local scrapers only (http://127.0.0.1:4300/metrics).
location = /metrics { return 404; }
location = /metrics { return 404; }
- # Internal service endpoints (Live's /internal/media-events, ...) live on the API host.
+ # Internal service endpoints (Live's /internal/media-events, ...) are host-local.
location /internal/ { return 404; }
location /internal/ { return 404; }
+ # ── Token-guarded API for developer apps (see header) ──
+ # Per-project quotas are enforced by the service; this only caps per-IP request rates.
+ location ~ ^/api/v1/(events|subscriptions|checkpoints)(/|$) {
+ limit_req zone=ovevents_apex_api burst=40 nodelay;
+ proxy_pass http://127.0.0.1:4300;
+ 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;
+ }
+
+ # ── Everything else under /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:4300;
+ # proxy_http_version 1.1;
+ # proxy_set_header Host $host;
+ # proxy_set_header X-Real-IP $remote_addr; # client address from $remote_addr only (realip),
+ # proxy_set_header X-Forwarded-For $remote_addr; # never appended to or passed through
+ # proxy_set_header CF-Connecting-IP $remote_addr;
+ # proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
# ── The product surface: the home, its Frame files and its discovery files ──
# ── 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 = / {
location = / {
proxy_pass http://127.0.0.1:4300;
proxy_pass http://127.0.0.1:4300;
proxy_set_header Host $host;
proxy_set_header Host $host;
@@ -110,6 +182,6 @@ server {
@@ -110,6 +182,6 @@ server {
proxy_set_header CF-Connecting-IP $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; }
+ # ── Everything else: no route here ──
+ location / { return 404; }
}
}
@@ -21,7 +21,7 @@ reads span both tables. `EVENTS_REPLAY_RETENTION_DAYS` defaults to 365; `0` is t
@@ -21,7 +21,7 @@ reads span both tables. `EVENTS_REPLAY_RETENTION_DAYS` defaults to 365; `0` is t
takes milliseconds and holds no lock on `events`.
takes milliseconds and holds no lock on `events`.
2. `/etc/openvibe/events.env`: nothing to add for the default (365 days of replay). To keep today's behaviour, set
2. `/etc/openvibe/events.env`: nothing to add for the default (365 days of replay). To keep today's behaviour, set
`EVENTS_REPLAY_RETENTION_DAYS=0` and restart.
`EVENTS_REPLAY_RETENTION_DAYS=0` and restart.
-3. Check: `curl -s https://events.openvibe.network/limits.json` lists `replay_retention_days`; after the first prune
+3. Check: `curl -s https://openvibe.events/limits.json` lists `replay_retention_days`; after the first prune
(start + up to `EVENTS_PRUNE_INTERVAL_MS`) the journal (`ov access run openvibe-ovh journal openvibe-events.service 200`)
(start + up to `EVENTS_PRUNE_INTERVAL_MS`) the journal (`ov access run openvibe-ovh journal openvibe-events.service 200`)
shows `[retention] pruned N hot events (N moved to replay), …` when anything was due, and
shows `[retention] pruned N hot events (N moved to replay), …` when anything was due, and
`node scripts/events-archive.js status` (on the host, with the service env) lists the replay rows per month.
`node scripts/events-archive.js status` (on the host, with the service env) lists the replay rows per month.
@@ -77,16 +77,18 @@ function load(env = process.env) {
@@ -77,16 +77,18 @@ function load(env = process.env) {
const port = int(env.PORT, 4300);
const port = int(env.PORT, 4300);
const maxInflight = int(env.EVENTS_MAX_INFLIGHT, 20);
const maxInflight = int(env.EVENTS_MAX_INFLIGHT, 20);
const prefixes = sourcePrefixes(env);
const prefixes = sourcePrefixes(env);
- const baseUrl = (env.BASE_URL || (isProduction ? 'https://events.openvibe.network' : `http://localhost:${port}`)).replace(/\/$/, '');
+ // openvibe.events is the API origin and the product origin (plan T7); events.openvibe.network answers
+ // until every client moves, then redirects (308). Everything Events names about itself uses this.
+ const baseUrl = (env.BASE_URL || (isProduction ? 'https://openvibe.events' : `http://localhost:${port}`)).replace(/\/$/, '');
return {
return {
port,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
nodeEnv,
nodeEnv,
isProduction,
isProduction,
baseUrl,
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(/\/$/, ''),
+ // The product origin is the API origin (plan T7): the home page and its robots.txt/sitemap.xml/
+ // llms.txt are served from baseUrl. EVENTS_SITE_URL overrides it for a deployment that serves them apart.
+ siteUrl: (env.EVENTS_SITE_URL || baseUrl).replace(/\/$/, ''),
// Identity: OpenVibe.Network issues service tokens and user JWTs (RS256).
// Identity: OpenVibe.Network issues service tokens and user JWTs (RS256).
networkUrl: (env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
networkUrl: (env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
*
*
* GET /robots.txt welcomes search and AI crawlers to the home page, keeps the API and the
* 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
* 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 /sitemap.xml the product home only (openvibe.events is the API origin too, plan T7)
* GET /llms.txt what OpenVibe.Events does today, in plain language: the README's Purpose,
* GET /llms.txt what OpenVibe.Events does today, in plain language: the README's Purpose,
* Publishing, Subscriptions and delivery, Developer apps and Realtime sections
* Publishing, Subscriptions and delivery, Developer apps and Realtime sections
*
*
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'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
+ * The product home of OpenVibe.Events (openvibe.events, plan T7: the API origin is the product origin too).
+ * The OpenVibe Frame (openvibe-shared/shell) around openvibe-shared/showcase sections
* for developers. Every claim restates the README (Publishing, Subscriptions and delivery, Developer apps,
* for developers. Every claim restates the README (Publishing, Subscriptions and delivery, Developer apps,
* Realtime) and the code; nothing here reads data.
* Realtime) and the code; nothing here reads data.
*
*
@@ -26,7 +26,7 @@ const HOME_CSP = [
@@ -26,7 +26,7 @@ const HOME_CSP = [
"style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",
"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",
"font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
"img-src 'self' data: https:",
"img-src 'self' data: https:",
- "connect-src 'self' https://openvibe.network https://events.openvibe.network",
+ "connect-src 'self' https://openvibe.network https://openvibe.events",
"frame-src 'self' https://openvibe.network",
"frame-src 'self' https://openvibe.network",
"frame-ancestors 'none'",
"frame-ancestors 'none'",
"object-src 'none'",
"object-src 'none'",
@@ -59,7 +59,7 @@ function sections({ apiUrl }) {
@@ -59,7 +59,7 @@ function sections({ apiUrl }) {
lede: 'The event backbone of the OpenVibe network. Publish an event once and every subscriber gets it: stored durably, signed, '
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.',
+ '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' }],
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.',
+ note: `Alpha. The API is at ${apiUrl}; developer apps sign in through OpenVibe.Network.`,
}) + showcase.features({
}) + showcase.features({
title: 'What you get',
title: 'What you get',
items: [
items: [
@@ -1,12 +1,14 @@
@@ -1,12 +1,14 @@
'use strict';
'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.
+// The product home (plan T7: openvibe.events is the API and product origin): it 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, answers robots.txt, sitemap.xml and llms.txt, and names its own origin everywhere.
+// A parse-only check of the nginx vhost too.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const nodeHttp = require('http');
const nodeHttp = require('http');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
+const { load } = require('../server/config');
const { HOME_CSP } = require('../server/home');
const { HOME_CSP } = require('../server/home');
const SITE = 'https://openvibe.events';
const SITE = 'https://openvibe.events';
@@ -42,7 +44,15 @@ function raw(base, p, headers = {}) {
@@ -42,7 +44,15 @@ function raw(base, p, headers = {}) {
});
});
}
}
-t('boot with the product domain configured', async () => { h = await boot({ env: { EVENTS_SITE_URL: SITE } }); });
+t('in production the API origin and the product origin are the same host', () => {
+ const production = load({ NODE_ENV: 'production' });
+ assert.strictEqual(production.baseUrl, SITE, 'the API origin defaults to openvibe.events');
+ assert.strictEqual(production.siteUrl, SITE, 'the product origin is the same origin');
+});
+
+t('boot with openvibe.events as both the API and the product origin', async () => {
+ h = await boot({ env: { BASE_URL: SITE } }); // siteUrl follows baseUrl
+});
t('GET / as a browser: the home page, its CSP and its cache policy', async () => {
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' });
const r = await raw(h.base, '/', { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' });
@@ -54,6 +64,9 @@ t('GET / as a browser: the home page, its CSP and its cache policy', async () =>
@@ -54,6 +64,9 @@ t('GET / as a browser: the home page, its CSP and its cache policy', async () =>
assert.strictEqual((r.body.match(/<h1\b/g) || []).length, 1, 'exactly one h1');
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('Events your services'), 'the hero text');
assert.ok(r.body.includes(`<link rel="canonical" href="${SITE}/">`), 'the canonical product origin');
assert.ok(r.body.includes(`<link rel="canonical" href="${SITE}/">`), 'the canonical product origin');
+ assert.ok(r.body.includes(`The API is at ${SITE}`), 'the hero note names the API origin');
+ assert.ok(r.body.includes(`${SITE}/api/v1/events`), 'the API examples use the API origin');
+ assert.ok(!r.body.includes('events.openvibe.network'), 'the home names no other origin');
});
});
t('the home links the pinned Frame stylesheet, and /shared serves it', async () => {
t('the home links the pinned Frame stylesheet, and /shared serves it', async () => {
@@ -101,23 +114,28 @@ t('llms.txt is a plain-text map that starts with the site name', async () => {
@@ -101,23 +114,28 @@ t('llms.txt is a plain-text map that starts with the site name', async () => {
assert.ok(r.body.startsWith('# OpenVibe.Events'), r.body.slice(0, 40));
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('> Durable events for the OpenVibe network'), 'the home summary');
assert.ok(r.body.includes('https://github.com/OpenVibers/OpenVibe.Events#readme'), 'the guide');
assert.ok(r.body.includes('https://github.com/OpenVibers/OpenVibe.Events#readme'), 'the guide');
+ assert.ok(r.body.includes(`The API and the realtime gateway are at ${SITE}`), 'the discovery names the API origin');
});
});
-t('deploy/nginx/openvibe.events.conf proxies the product surface and nothing else', () => {
+t('deploy/nginx/openvibe.events.conf is the API and product origin, with no redirect to the old host', () => {
const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.events.conf'), 'utf8');
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 openvibe\.events;/);
assert.match(conf, /server_name www\.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/fullchain.pem'));
assert.ok(conf.includes('/etc/letsencrypt/live/openvibe.events/privkey.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');
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 {']) {
+ // The product surface (plan expansion II) and everything the API host proxies (plan T7).
+ for (const loc of ['location = / {', 'location ^~ /shared/ {', 'location = /robots.txt {', 'location = /sitemap.xml {', 'location = /llms.txt {',
+ 'location = /realtime/stream {', 'location = /api/health', 'location = /api/ready', 'location = /release.json', 'location = /limits.json']) {
assert.ok(conf.includes(loc), `${loc} is proxied`);
assert.ok(conf.includes(loc), `${loc} is proxied`);
}
}
+ assert.ok(conf.includes('location ~ ^/api/v1/(events|subscriptions|checkpoints)(/|$)'), 'the token-guarded API block');
+ assert.ok(conf.includes('location /api/ {'), 'the /api/ refusal block');
+ assert.ok(conf.includes('proxy_buffering off;'), 'SSE is not buffered');
+ assert.ok(conf.includes('proxy_read_timeout 1h;'), 'SSE keeps its long read timeout');
const refusals = [conf.indexOf('location = /metrics { return 404; }'), conf.indexOf('location /internal/ { return 404; }')];
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');
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('events.openvibe.network$request_uri'), 'no catch-all redirect to the old API host');
assert.ok(conf.includes('return 301 https://openvibe.events$request_uri;'), ':80 and www reach the apex');
assert.ok(conf.includes('return 301 https://openvibe.events$request_uri;'), ':80 and www reach the apex');
});
});