openvibers/openvibe.events Commits
Developer apps (Wave 20, ADR-014): events.app.publish|read|subscribe with app tokens on the existing routes — app.<project_key>.* types only (project_key = p + lowercased project ULID, source app-<lowercased app ULID>), reads scoped to the own project and env plus public first-party events, subscriptions to public https endpoints only (SSRF guard at subscribe and delivery, pinned lookup, no redirects); sandbox tokens accepted on these routes only, events stored with project_id and env and never crossing environments; per-project publish-rate, retained-bytes and subscription quotas; network.app.revoked / network.grant.changed disable an app's subscriptions and old tokens; public nginx locations for the app routes; openvibe-contracts v0.28.0
Changes
@@@ -37,7 +37,8 @@ EVENTS_RECEIPT_RETENTION_DAYS=90#EVENTS_MAX_HOPS=8# Which source may publish which event_type prefixes. JSON; replaces the default, which is# { "<source>": ["<source>."] } for live media network community chat openre billing tips vip--# ai games tools codes host. Every prefix must start with "<source>.".++# ai games tools codes host sources search wiki blog news reviews deals coupons trade. Every prefix++# must start with "<source>."; "app" and "app-*" are reserved for developer apps.#EVENTS_SOURCE_PREFIXES={"live":["live."],"media":["media."]}# Delivery worker@@@ -54,6 +55,18 @@ EVENTS_RECEIPT_RETENTION_DAYS=90# and its subdomains; IP literals only when listed exactly.#EVENTS_ENDPOINT_HOSTS=127.0.0.1,*.openvibe.*++# Developer apps (events.app.publish|read|subscribe, ADR-014). App subscriptions must use public++# https endpoints (no allow-list; checked at subscribe and delivery time). Quotas are per project++# and environment; 0 turns a limit off. EVENTS_APPS=off disables the app paths.++#EVENTS_APPS=on++#EVENTS_APP_PUBLISH_PER_MINUTE=120++#EVENTS_APP_RETAINED_BYTES=52428800++#EVENTS_APP_MAX_SUBSCRIPTIONS=20++#EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE=30++#EVENTS_APP_SANDBOX_RETAINED_BYTES=5242880++#EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS=5++#EVENTS_APP_SANDBOX_RETENTION_DAYS=7++# Realtime (SSE) gateway#REALTIME_MAX_CONNECTIONS=2000#REALTIME_MAX_TOPICS=20@@@ -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 `/var/lib/openvibe-events/events.db`, nginx [deploy/nginx/events.openvibe.network.conf](deploy/nginx/events.openvibe.network.conf) (public vhost exposes `/realtime/stream` and health only; 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 `/var/lib/openvibe-events/events.db`, 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`).`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`.@@@ -37,7 +37,9 @@ Services call with an OpenVibe.Network client-credentials token (`POST /oauth/to| `events.event.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints`, realtime as a service || `events.delivery.admin` | `GET /api/v1/deliveries`, `POST /api/v1/deliveries/replay` |--These ids are not in `openvibe-contracts` yet; their manifests are proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (with the service manifest). Until the contracts release defines them, `server/auth.js` grants them with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` as soon as contracts know the id.++These four are `internal` in `openvibe-contracts` (never granted to developer apps). Developer apps use the three `public` capabilities in [Developer apps](#developer-apps) instead, on the same routes. `server/auth.js` grants with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` for every id the installed contracts know (v0.28.0 knows all seven).++++Sandbox tokens (`env: sandbox`, developer apps only) are accepted only on the developer-app routes; every other route answers `401 token.sandbox_refused`. App tokens are never judged on a first-party capability: an app token on an operator route is a `403`.Errors are RFC 9457 problem+json (`openvibe-contracts` `http.problem`) with a stable `code`.@@@ -67,6 +69,51 @@ Operators: `GET /api/v1/deliveries?status=dead` is the dead-letter queue; `POSTPull consumers: `GET /api/v1/events?topic=media.vod.*&after_seq=<cursor>&limit=100` returns `{ events: [{ seq, event }], next_after_seq, latest_seq }`, plus `gap: { from_seq, to_seq }` when the cursor is older than retention. `PUT /api/v1/checkpoints { topic, cursor }` stores a consumer's cursor here if it has nowhere better.++## Developer apps++++Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`POST /oauth/token`, `grant_type=client_credentials`, `audience=openvibe.events`) with `sub: app:app_<ULID>`, `project_id: prj_<ULID>`, `env: sandbox|production`. It uses the same routes as services, with these capabilities (all `public`, openvibe-contracts ≥ 0.28.0):++++| Capability | Routes | Scope |++|---|---|---|++| `events.app.publish` | `POST /api/v1/events` | event types `app.<project_key>.<name>[.<more>]` only |++| `events.app.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints` | own project's events in the token's env, plus first-party `public` events |++| `events.app.subscribe` | `/api/v1/subscriptions…` (the app's own) | same as read; public https endpoints only |++++**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++PK=p$(echo "${PRJ#prj_}" | tr 'A-Z' 'a-z') # project_key++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 '{++ "event_id": "evt_01JAB2C3D4E5F6G7H8J9K0MNPQ", "event_type": "app.'$PK'.order.created", "version": 1,++ "source": "'$SRC'", "actor": { "type": "app", "id": "'$APP'" }, "timestamp": "2026-09-23T12:00:00Z",++ "subject": { "type": "order", "id": "42" }, "payload": { "total": 3 } }'++curl -s "$EVENTS/api/v1/events?topic=app.$PK.*&after_seq=0" -H "Authorization: Bearer $TOKEN"++curl -s -X POST "$EVENTS/api/v1/subscriptions" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \++ -d '{ "topic_pattern": "app.'$PK'.*", "endpoint": "https://hooks.example.com/openvibe" }' # → secret, shown once++```++++**Topic scope.** Every app pattern must start with a literal segment; an `app.*` pattern must name the app's own `project_key` (`app.<project_key>.*`); `*`, `*.created`, `app.*` and another project's key are `403 events.topic_not_allowed`. A first-party pattern (`live.*`) is allowed and yields only that namespace's `public` events.++++**Sandbox.** Events store each app event's `project_id` and `env`. Apps see and receive only events of their own environment: sandbox events never reach production apps or production subscriptions, and production events never reach sandbox ones. First-party readers and subscribers never see sandbox events, and see production app events only through a pattern that starts with `app.`. Realtime (SSE) never streams app events. Sandbox events are pruned after `EVENTS_APP_SANDBOX_RETENTION_DAYS` (7).++++**Webhook endpoints (SSRF guard, [server/egress.js](server/egress.js)).** An app subscription's endpoint must be `https`, without credentials, and its hostname must resolve only to public unicast addresses (loopback, RFC 1918, link-local and cloud metadata, CGNAT, multicast, documentation and benchmarking ranges, unique-local IPv6, and v4-in-v6 forms of any of those are refused). This is checked when the subscription is created and again on every delivery attempt, inside the connection's own DNS lookup, so the socket goes to the address that was checked. A refused address is a permanent failure (dead at once; replayable). Redirects are never followed (a `3xx` is a failed attempt). Deliveries are signed exactly like first-party ones (`X-OpenVibe-Signature`).++++**Quotas** (per project and environment, enforced here, `429 events.quota_exceeded` with `quota` = `publish_rate` (plus `Retry-After: 60`), `retained_bytes` or `subscriptions`):++++| | production | sandbox |++|---|---|---|++| events per minute | `EVENTS_APP_PUBLISH_PER_MINUTE` = 120 | `EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE` = 30 |++| retained bytes (payload + actor + type + subject id of stored events) | `EVENTS_APP_RETAINED_BYTES` = 50 MiB | `EVENTS_APP_SANDBOX_RETAINED_BYTES` = 5 MiB |++| subscriptions | `EVENTS_APP_MAX_SUBSCRIPTIONS` = 20 | `EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS` = 5 |++++Quotas recorded in Network (`dev_quotas`) are not read yet: that needs `network.project.read`, which is still planned. Until then these defaults apply to every project.++++**Revocation.** When Network's event relay delivers `network.app.revoked` (sent for every app of an archived project too), Events disables that app's subscriptions in the same transaction and refuses its tokens issued before the revocation (`401 token.revoked`). `network.grant.changed` that withdraws `events.app.subscribe` disables the app's subscriptions and refuses creating or re-enabling one with a token issued before the change. Without the relay, revoked apps' tokens still end within their 5-minute lifetime, but existing subscriptions keep delivering.++++`EVENTS_APPS=off` turns the developer-app paths off (app tokens are then judged like any other token and refused).++## Client library`require('openvibe-events')` (package `main` is [lib/client.js](lib/client.js); services can depend on this repo by tarball):@@@ -111,7 +158,8 @@ es.addEventListener('gap', (m) => { /* events were missed: refetch state */ });## Owns--- `events`, `subscriptions`, `deliveries`, `consumer_checkpoints`, `idempotency_receipts` (SQLite today; the plan's PostgreSQL + Redis fanout is a later step)++- `events`, `subscriptions`, `deliveries`, `consumer_checkpoints`, `idempotency_receipts`, `app_revocations` (SQLite today; the plan's PostgreSQL + Redis fanout is a later step)++- developer-app event scope, sandbox separation and per-project Events quotas (ADR-014)- canonical event envelope (event_id, trace_id, type, version, source, actor, subject + revision, payload)- priority classes `critical|important|low`, loop guards, backpressure, DLQ and replay- Realtime: SSE topics, `Last-Event-ID`/cursor resume, gap detection (WS and presence are not built yet)@@@ -132,6 +180,7 @@ es.addEventListener('gap', (m) => { /* events were missed: refetch state */ });- kill a consumer mid-processing; replay creates exactly one effect (`test/outbox-inbox.test.js`)- browser reconnect resumes from a cursor or reports a gap (`test/realtime.test.js`)- a guessed private topic yields no data (`test/realtime.test.js`)++- a developer app cannot publish, read or subscribe outside its project, sandbox never meets production, app webhooks reach public addresses only, quotas hold (`test/apps.test.js`)## Bootstrap / extraction source@@@ -7,9 +7,17 @@"runtime": "node 22, express, better-sqlite3","port": 4300,"domain": "events.openvibe.network",-- "contracts": "openvibe-contracts v0.5.1",-- "capabilities": ["events.event.publish", "events.subscription.manage", "events.event.read", "events.delivery.admin"],-- "capabilitiesRegistered": false,++ "contracts": "openvibe-contracts v0.28.0",++ "capabilities": [++ "events.event.publish",++ "events.subscription.manage",++ "events.event.read",++ "events.delivery.admin",++ "events.app.publish",++ "events.app.read",++ "events.app.subscribe"++ ],++ "capabilitiesRegistered": true,"deployed": false,-- "updated": "2026-09-22"++ "updated": "2026-09-23"}@@@ -8,13 +8,16 @@## /realtime/stream browser Server-Sent Events (long-lived; must not be buffered)## /api/health, /api/ready, /release.json## /metrics Prometheus text, answered only to direct loopback callers (never proxied)--## /api/v1/* service-to-service API. Services on this host call 127.0.0.1:4300--## directly, so the public vhost refuses it; open the location below only--## when a service on another host needs it (it still requires a token).++## /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.## ═══════════════════════════════════════════════════════════════limit_req_zone $binary_remote_addr zone=ovevents_connect:10m rate=2r/s;limit_conn_zone $binary_remote_addr zone=ovevents_streams:10m;++limit_req_zone $binary_remote_addr zone=ovevents_api:10m rate=20r/s;# ── HTTP → HTTPS redirect ──────────────────────────────────server {@@@ -78,7 +81,20 @@ server {# Metrics are for host-local scrapers only (http://127.0.0.1:4300/metrics).location = /metrics { return 404; }-- # ── Service API: host-local only (see header) ───────────++ # ── 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_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 $proxy_add_x_forwarded_for;++ 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:@@@ -12,7 +12,7 @@"better-sqlite3": "^11.10.0","dotenv": "^16.4.7","express": "^4.21.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"},"engines": {@@@ -967,9 +967,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.6.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0",-- "integrity": "sha512-bRN/XG+mlvg0lnOc+9kVa5HgxBWTXy/xRfDn8Gnz5k41FT73zg6i463Le7yWkcLKhq6HI3NKlpBf3C/NRpSX9g==",++ "version": "0.28.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",++ "integrity": "sha512-cOOEmIhcjDmnWbo4jsz6kYOKT+Kc+aitYoc4w/EJOKg5uvJDt10vctjKB3IIl53UXeuDOhbckP53yBdBeewC1A==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -17,7 +17,7 @@"better-sqlite3": "^11.10.0","dotenv": "^16.4.7","express": "^4.21.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"},"engines": {@@@ -1,6 +1,7 @@'use strict';/**-- * POST /api/v1/events (service token, audience openvibe.events, capability events.event.publish)++ * POST /api/v1/events (service token, audience openvibe.events, capability events.event.publish;++ * or a developer app token with events.app.publish)** body: <envelope> -> 201 { event_id, seq, duplicate: false } | 200 on a repeat* body: { events: [...] } -> 201 { results: [{ event_id, seq, duplicate }] } (<= 100, atomic)@@@ -8,6 +9,11 @@* Every envelope must validate against events.event-envelope@1; `source` must be the calling* service (svc:live -> 'live'); `event_type` must start with a prefix that source owns. A* re-published event_id is answered with the stored seq and never stored twice.++ *++ * Developer apps (ADR-014, server/apps.js): `source` must be the app's `app-<lowercased ULID>`,++ * `event_type` must start with `app.<project_key>.`, and `actor` must be the app itself or the user++ * the token acts for. The event is stored with the token's project_id and env, under the project's++ * publish-rate and retained-bytes quotas (429 events.quota_exceeded).*/const express = require('express');const { validate, http } = require('openvibe-contracts');@@@ -37,6 +43,7 @@ function publishRouter({ config, store, auth, worker, realtime }) {if (Buffer.byteLength(JSON.stringify(env.payload)) > config.maxPayloadBytes) {return { status: 413, code: 'events.payload_too_large', detail: `payload is larger than ${config.maxPayloadBytes} bytes` };}++ if (principal.kind === 'app') return checkApp(env, principal);if (env.source !== principal.service) {return { status: 403, code: 'events.source_mismatch', detail: `source "${env.source}" is not the calling service "${principal.service}"` };}@@@ -48,7 +55,22 @@ function publishRouter({ config, store, auth, worker, realtime }) {return { env };}-- router.post('/api/v1/events', auth.requireCap(CAPS.publish, { requireService: true }), (req, res) => {++ function checkApp(env, app) {++ if (env.source !== app.source) {++ return { status: 403, code: 'events.source_mismatch', detail: `source must be your app's "${app.source}", not "${env.source}"` };++ }++ if (!env.event_type.startsWith(app.prefix)) {++ return { status: 403, code: 'events.type_not_allowed', detail: `an app may publish ${app.prefix}<name> only, not ${env.event_type}` };++ }++ const a = env.actor || {};++ const actorOk = (a.type === 'app' && a.id === app.appId) || (app.onBehalfOf && a.type === 'user' && a.id === app.onBehalfOf);++ if (!actorOk) {++ return { status: 403, code: 'events.actor_mismatch', detail: `actor must be { type: 'app', id: '${app.appId}' }${app.onBehalfOf ? ' or the user the token acts for' : ''}` };++ }++ return { env };++ }++++ router.post('/api/v1/events', auth.appOrService(CAPS.publish, CAPS.appPublish, { requireService: true }), (req, res) => {const ctx = req.ov;const body = req.body;const isBatch = body && typeof body === 'object' && !Array.isArray(body) && Array.isArray(body.events) && body.event_id === undefined;@@@ -76,9 +98,14 @@ function publishRouter({ config, store, auth, worker, realtime }) {let out;try {-- out = store.insertBatch(envelopes, { publisher: req.principal.sub, requestId: ctx.requestId });++ const p = req.principal;++ const project = p.kind === 'app' ? { projectId: p.projectId, env: p.env, ...config.apps.quotas[p.env] } : null;++ out = store.insertBatch(envelopes, { publisher: p.sub, requestId: ctx.requestId, project });} catch (err) {-- if (err instanceof StoreError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx, extra: err.extra });++ if (err instanceof StoreError) {++ if (err.status === 429 && err.extra && err.extra.retry_after) res.setHeader('Retry-After', String(err.extra.retry_after));++ return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx, extra: err.extra });++ }throw err;}if (out.inserted.length) {@@@ -10,6 +10,12 @@* GET /api/v1/checkpoints?topic=… / PUT /api/v1/checkpoints { topic, cursor }* a consumer's own stored cursor per topic pattern (consumer = calling principal)*++ * Developer apps (events.app.read) use the same routes with an app token: only their project's++ * events in the token's environment plus public first-party events (server/apps.js); every topic++ * pattern must start with a literal segment, and app.* patterns must name the app's project_key.++ * Checkpoints are per app. First-party readers never see sandbox events, and see app events only++ * through patterns that start with `app.`.++ ** Operators (events.delivery.admin):** GET /api/v1/deliveries?status=dead&subscription_id=&after_seq=&limit=@@@ -20,6 +26,23 @@ const { http } = require('openvibe-contracts');const topics = require('../topics');const { rowToEnvelope } = require('../store');const { CAPS } = require('../auth');++const apps = require('../apps');++++/** Why an app may not use these patterns (null for services and for allowed patterns). */++function scopeError(principal, patterns) {++ if (principal.kind !== 'app') return null;++ for (const p of patterns) {++ const err = apps.patternScopeError(p, principal);++ if (err) return `${p}: ${err}`;++ }++ return null;++}++++/** Row filter for a reader: an app's scope, or a first-party reader's (no sandbox; app events via app.*). */++function acceptFor(principal, patterns) {++ if (principal.kind === 'app') return (row) => apps.visibleToApp(row, principal);++ return (row) => patterns.some(p => apps.serviceMatches(p, row));++}const intParam = (v, d, min, max) => {if (v === undefined || v === '') return d;@@@ -29,7 +52,7 @@ const intParam = (v, d, min, max) => {function readRouter({ store, auth, worker }) {const router = express.Router();-- const canRead = auth.requireCap(CAPS.read);++ const canRead = auth.appOrService(CAPS.read, CAPS.appRead);const isAdmin = auth.requireCap(CAPS.admin);router.get('/api/v1/events', canRead, (req, res) => {@@@ -38,6 +61,8 @@ function readRouter({ store, auth, worker }) {if (!patterns.length || patterns.length > 20 || !patterns.every(topics.isValidPattern)) {return http.sendProblem(res, 400, 'events.bad_topic', { detail: 'topic must be 1..20 comma-separated patterns', ctx });}++ const scopeErr = scopeError(req.principal, patterns);++ if (scopeErr) return http.sendProblem(res, 403, 'events.topic_not_allowed', { detail: scopeErr, ctx });const after = intParam(req.query.after_seq, 0, 0, Number.MAX_SAFE_INTEGER);const limit = intParam(req.query.limit, 100, 1, 1000);if (Number.isNaN(after) || Number.isNaN(limit)) {@@@ -50,7 +75,7 @@ function readRouter({ store, auth, worker }) {out.gap = { from_seq: after + 1, to_seq: oldest - 1 };from = oldest - 1;}-- const { rows, cursor } = store.scan(from, { patterns, limit });++ const { rows, cursor } = store.scan(from, { patterns, limit, accept: acceptFor(req.principal, patterns) });out.events = rows.map(r => ({ seq: r.seq, event: rowToEnvelope(r) }));out.next_after_seq = cursor;out.latest_seq = store.lastSeq();@@@ -59,7 +84,8 @@ function readRouter({ store, auth, worker }) {router.get('/api/v1/events/:id', canRead, (req, res) => {const row = store.getEvent(String(req.params.id));-- if (!row) return http.sendProblem(res, 404, 'events.not_found', { detail: 'no such event (or pruned by retention)', ctx: req.ov });++ const visible = row && (req.principal.kind === 'app' ? apps.visibleToApp(row, req.principal) : (row.env || 'production') === 'production');++ if (!visible) return http.sendProblem(res, 404, 'events.not_found', { detail: 'no such event (or pruned by retention)', ctx: req.ov });return res.json({ seq: row.seq, event: rowToEnvelope(row) });});@@@ -68,6 +94,8 @@ function readRouter({ store, auth, worker }) {router.get('/api/v1/checkpoints', canRead, (req, res) => {const topic = String(req.query.topic || '');if (!topics.isValidPattern(topic)) return http.sendProblem(res, 400, 'events.bad_topic', { detail: 'topic is required', ctx: req.ov });++ const scopeErr = scopeError(req.principal, [topic]);++ if (scopeErr) return http.sendProblem(res, 403, 'events.topic_not_allowed', { detail: scopeErr, ctx: req.ov });const cp = store.getCheckpoint(consumerOf(req), topic);res.json({ consumer: consumerOf(req), topic, cursor: cp ? cp.cursor : 0, updated_at: cp ? cp.updated_at : null });});@@@ -77,6 +105,8 @@ function readRouter({ store, auth, worker }) {if (!topics.isValidPattern(b.topic) || !Number.isInteger(b.cursor) || b.cursor < 0) {return http.sendProblem(res, 400, 'events.bad_request', { detail: 'topic (pattern) and cursor (integer >= 0) are required', ctx: req.ov });}++ const scopeErr = scopeError(req.principal, [b.topic]);++ if (scopeErr) return http.sendProblem(res, 403, 'events.topic_not_allowed', { detail: scopeErr, ctx: req.ov });const cp = store.setCheckpoint(consumerOf(req), b.topic, b.cursor);res.json({ consumer: consumerOf(req), topic: b.topic, ...cp });});@@@ -9,6 +9,12 @@* POST /api/v1/subscriptions/:id/disable | /enable** The endpoint must be http(s) on the allow-list (127.0.0.1, *.openvibe.*): server/endpoints.js.++ *++ * Developer apps (events.app.subscribe) use the same routes with an app token. The consumer is the++ * app (app:app_<ULID>); the subscription records the token's project_id and env, and delivers only++ * what events.app.read would show that app (server/apps.js). Topic patterns follow the same scope++ * rule. The endpoint must be https and resolve only to public addresses (server/egress.js), checked++ * here and again on every delivery. At most EVENTS_APP_[SANDBOX_]MAX_SUBSCRIPTIONS per project and env.*/const crypto = require('crypto');const express = require('express');@@@ -17,6 +23,8 @@ const topics = require('../topics');const { checkEndpoint } = require('../endpoints');const { subscriptionView } = require('../store');const { CAPS } = require('../auth');++const apps = require('../apps');++const { checkAppEndpoint } = require('../egress');function checkRetryPolicy(p) {if (p == null) return { ok: true, value: null };@@@ -37,27 +45,54 @@ function checkRetryPolicy(p) {return { ok: true, value: out };}--function subscriptionsRouter({ config, store, auth }) {++function subscriptionsRouter({ config, store, auth, dnsLookup }) {const router = express.Router();-- const guard = auth.requireCap(CAPS.subscribe, { requireService: true });++ const guard = auth.appOrService(CAPS.subscribe, CAPS.appSubscribe, { requireService: true });++ const consumerOf = (req) => (req.principal.kind === 'app' ? req.principal.sub : req.principal.service);++++ /** The app's events.app.subscribe grant was withdrawn after this token was issued. */++ function subscribeRevoked(req, res) {++ const p = req.principal;++ if (p.kind !== 'app') return false;++ const at = store.revokedAt(p.sub, CAPS.appSubscribe);++ if (at && p.iat * 1000 <= at) {++ http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPS.appSubscribe} was revoked after this token was issued`, ctx: req.ov });++ return true;++ }++ return false;++ }function own(req, res) {const sub = store.getSubscription(String(req.params.id));-- if (!sub || sub.consumer !== req.principal.service) {++ if (!sub || sub.consumer !== consumerOf(req)) {http.sendProblem(res, 404, 'events.not_found', { detail: 'no such subscription', ctx: req.ov });return null;}return sub;}-- router.post('/api/v1/subscriptions', guard, (req, res) => {++ router.post('/api/v1/subscriptions', guard, async (req, res, next) => {++ try {++ await create(req, res);++ } catch (err) {++ next(err);++ }++ });++++ async function create(req, res) {const ctx = req.ov;const b = req.body && typeof req.body === 'object' ? req.body : {};const pattern = b.topic_pattern ?? b.topic;if (!topics.isValidPattern(pattern)) {return http.sendProblem(res, 422, 'events.bad_topic', { detail: 'topic_pattern must be dot-separated segments of [a-z0-9_] or *', ctx });}-- const ep = checkEndpoint(b.endpoint, config.endpointHosts);++ const app = req.principal.kind === 'app' ? req.principal : null;++ if (subscribeRevoked(req, res)) return undefined;++ if (app) {++ const err = apps.patternScopeError(pattern, app);++ if (err) return http.sendProblem(res, 403, 'events.topic_not_allowed', { detail: err, ctx });++ }++ const ep = app ? await checkAppEndpoint(b.endpoint, { lookup: dnsLookup }) : checkEndpoint(b.endpoint, config.endpointHosts);if (!ep.ok) return http.sendProblem(res, 422, 'events.endpoint_not_allowed', { detail: ep.reason, ctx });if (b.secret !== undefined && (typeof b.secret !== 'string' || b.secret.length < 32 || b.secret.length > 256)) {return http.sendProblem(res, 422, 'events.bad_request', { detail: 'secret must be a string of 32..256 characters', ctx });@@@ -65,7 +100,7 @@ function subscriptionsRouter({ config, store, auth }) {const rp = checkRetryPolicy(b.retry_policy);if (!rp.ok) return http.sendProblem(res, 422, 'events.bad_request', { detail: rp.reason, ctx });-- const consumer = req.principal.service;++ const consumer = consumerOf(req);const endpoint = ep.url.toString();const dup = store.listSubscriptions(consumer).find(s => s.topic_pattern === pattern && s.endpoint === endpoint);if (dup) {@@@ -74,6 +109,14 @@ function subscriptionsRouter({ config, store, auth }) {if (store.countSubscriptions(consumer) >= config.maxSubscriptionsPerConsumer) {return http.sendProblem(res, 409, 'events.subscription_limit', { detail: `at most ${config.maxSubscriptionsPerConsumer} subscriptions per consumer`, ctx });}++ if (app) {++ const max = config.apps.quotas[app.env].maxSubscriptions;++ if (max && store.countProjectSubscriptions(app.projectId, app.env) >= max) {++ return http.sendProblem(res, 429, 'events.quota_exceeded', {++ detail: `at most ${max} subscriptions per project in ${app.env}`, ctx, extra: { quota: 'subscriptions', limit: max },++ });++ }++ }const row = store.createSubscription({id: `sub_${ids.ulid()}`,consumer,@@@ -81,12 +124,14 @@ function subscriptionsRouter({ config, store, auth }) {endpoint,secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`,retryPolicy: rp.value && Object.keys(rp.value).length ? rp.value : null,++ projectId: app ? app.projectId : null,++ env: app ? app.env : 'production',});return res.status(201).json(subscriptionView(row, { withSecret: true }));-- });++ }router.get('/api/v1/subscriptions', guard, (req, res) => {-- res.json({ subscriptions: store.listSubscriptions(req.principal.service).map(s => subscriptionView(s)) });++ res.json({ subscriptions: store.listSubscriptions(consumerOf(req)).map(s => subscriptionView(s)) });});router.get('/api/v1/subscriptions/:id', guard, (req, res) => {@@@ -96,6 +141,7 @@ function subscriptionsRouter({ config, store, auth }) {for (const [action, enabled] of [['disable', false], ['enable', true]]) {router.post(`/api/v1/subscriptions/:id/${action}`, guard, (req, res) => {++ if (enabled && subscribeRevoked(req, res)) return;const sub = own(req, res);if (sub) res.json(subscriptionView(store.setSubscriptionEnabled(sub.id, enabled)));});@@@ -11,7 +11,7 @@ const { subscriptionsRouter } = require('./api/subscriptions');const { readRouter } = require('./api/read');const pkg = require('../package.json');--function createApp({ config, store, auth, keys, worker, realtime, metrics, log = console }) {++function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, log = console }) {const app = express();app.disable('x-powered-by');app.set('trust proxy', 'loopback');@@@ -66,16 +66,16 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, log =app.get('/api/ready', readiness.handler);app.use(publishRouter({ config, store, auth, worker, realtime }));-- app.use(subscriptionsRouter({ config, store, auth }));++ app.use(subscriptionsRouter({ config, store, auth, dnsLookup }));app.use(readRouter({ store, auth, worker }));app.get('/', (_req, res) => {res.type('text/plain').send(['OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.','',-- 'POST /api/v1/events publish (service token, events.event.publish)',-- 'GET /api/v1/events pull with a cursor (events.event.read)',-- ' /api/v1/subscriptions webhook subscriptions (events.subscription.manage)',++ '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',nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++/**++ * Developer apps (roadmap Wave 20, ADR-014; capabilities events.app.publish|read|subscribe).++ *++ * An app token from OpenVibe.Network has sub `app:app_<ULID>`, actor_type `app`, `project_id`++ * `prj_<ULID>`, `env` sandbox|production and ns [project_id]. Here it becomes an app principal:++ *++ * project_key 'p' + the project's ULID in lowercase prj_01JAB… -> p01jab…++ * source 'app-' + the app's ULID in lowercase app_01JAB… -> app-01jab…++ * prefix 'app.<project_key>.' the only event types it may publish++ *++ * Both derived names fit the existing events.event-envelope@1 patterns (a type segment is++ * [a-z0-9_]+, a source is [a-z][a-z0-9-]{1,39}), so the envelope contract is unchanged.++ *++ * Scope (what an app sees, pulls and gets delivered):++ * - its own project's events, in the SAME environment as the token (sandbox never meets production)++ * - first-party platform events with visibility `public` (always production)++ * First-party readers (services with events.event.read) never see sandbox events, and see app++ * events only through a pattern that starts with the literal segment `app`.++ */++const topics = require('./topics');++++const ULID = '[0-9A-HJKMNP-TV-Z]{26}';++const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);++const PROJECT_RE = new RegExp(`^prj_(${ULID})$`);++const ENVS = ['sandbox', 'production'];++++/** 'prj_01JAB…' -> 'p01jab…' (null for anything that is not a project id). */++function projectKey(projectId) {++ const m = PROJECT_RE.exec(String(projectId || ''));++ return m ? `p${m[1].toLowerCase()}` : null;++}++++/** 'app:app_01JAB…' (or 'app_01JAB…') -> 'app-01jab…'. */++function appSource(sub) {++ const s = String(sub || '');++ const m = APP_SUB_RE.exec(s.startsWith('app:') ? s : `app:${s}`);++ return m ? `app-${m[1].toLowerCase()}` : null;++}++++/**++ * The app principal for verified token claims, or { error } when the claims are not a usable app++ * token (no project, unknown env, project outside ns).++ */++function appPrincipal(claims) {++ const m = APP_SUB_RE.exec(String(claims && claims.sub));++ if (!m) return { error: 'not an app token' };++ if (claims.actor_type && claims.actor_type !== 'app') return { error: 'actor_type must be app' };++ const key = projectKey(claims.project_id);++ if (!key) return { error: 'app token without a project_id' };++ const env = claims.env === undefined ? 'production' : claims.env;++ if (!ENVS.includes(env)) return { error: `unknown env ${claims.env}` };++ if (Array.isArray(claims.ns) && claims.ns.length && !claims.ns.includes(claims.project_id)) {++ return { error: 'project_id is not in the token namespaces' };++ }++ return {++ kind: 'app',++ sub: claims.sub,++ appId: `app_${m[1]}`,++ projectId: claims.project_id,++ projectKey: key,++ source: `app-${m[1].toLowerCase()}`,++ prefix: `app.${key}.`,++ env,++ onBehalfOf: typeof claims.on_behalf_of === 'string' ? claims.on_behalf_of : null,++ };++}++++/**++ * May an app use this topic pattern (pull, checkpoint, subscription)? The first segment must be++ * literal; an `app` pattern must name the app's own project_key as its second segment.++ * Returns null when allowed, or the reason.++ */++function patternScopeError(pattern, app) {++ if (!topics.isValidPattern(pattern)) return 'topic patterns are dot-separated segments of [a-z0-9_] or *';++ const [first, second] = pattern.split('.');++ if (first === '*') return 'an app topic pattern must start with a literal segment (e.g. app.<project_key>.* or live.*)';++ if (first === 'app' && second !== app.projectKey) return `app.* patterns must name your project: app.${app.projectKey}.*`;++ return null;++}++++/** Row visibility for an app scope ({ projectId, env }): own project + same env, or public first-party. */++function visibleToApp(row, scope) {++ if (row.project_id) return row.project_id === scope.projectId && (row.env || 'production') === scope.env;++ return row.visibility === 'public' && (row.env || 'production') === 'production';++}++++/** Does a first-party (service) pattern select this row? Never sandbox; app events only via `app.` patterns. */++function serviceMatches(pattern, row) {++ if ((row.env || 'production') !== 'production') return false;++ if (row.project_id && !pattern.startsWith('app.')) return false;++ return topics.matches(pattern, row.event_type);++}++++/** Should `sub` (a subscriptions row) receive `row` (an events row)? */++function deliverable(sub, row) {++ if (sub.project_id) {++ return topics.matches(sub.topic_pattern, row.event_type) && visibleToApp(row, { projectId: sub.project_id, env: sub.env || 'production' });++ }++ return serviceMatches(sub.topic_pattern, row);++}++++module.exports = { projectKey, appSource, appPrincipal, patternScopeError, visibleToApp, serviceMatches, deliverable, ENVS, APP_SUB_RE };@@@ -6,20 +6,28 @@* openvibe.events), verified with openvibe-contracts serviceAuth.verifyServiceToken.* - Browsers: Network user JWTs (RS256, same key), cookie `ov_token` or Bearer.*-- * Capability ids events.event.publish / events.subscription.manage / events.event.read / events.delivery.admin are proposed in-- * docs/capabilities-proposal/ and are not in openvibe-contracts yet. capabilities.check() answers-- * capability.unknown for an id it does not know; until the release that defines them, hasCap()-- * decides with the same grant rule (exact id, or a `family.*` grant).++ * - Developer apps (ADR-014): app tokens (sub app:app_<ULID>, project_id, env) holding++ * events.app.publish / events.app.read / events.app.subscribe, on the routes that accept them++ * (appOrService guard). Only there is a sandbox token (env=sandbox) accepted; every other route++ * refuses it with token.sandbox_refused.++ *++ * The service capabilities are in openvibe-contracts since v0.7; events.app.* arrive in v0.28.++ * capabilities.check() answers capability.unknown for an id the installed release does not know;++ * until then hasCap() decides with the same grant rule (exact id, or a `family.*` grant).*/const crypto = require('crypto');const { serviceAuth, capabilities, http } = require('openvibe-contracts');++const apps = require('./apps');--/** The capability ids this service enforces (manifests: docs/capabilities-proposal/). */++/** The capability ids this service enforces. */const CAPS = Object.freeze({publish: 'events.event.publish',subscribe: 'events.subscription.manage',read: 'events.event.read',admin: 'events.delivery.admin',++ appPublish: 'events.app.publish',++ appRead: 'events.app.read',++ appSubscribe: 'events.app.subscribe',});// ── Network public key ─────────────────────────────────────@@@ -147,12 +155,15 @@ function serviceSlug(sub) {return m ? m[1] : null;}--function createAuth({ config, keys }) {++function createAuth({ config, keys, store = null }) {// Token lifetimes are judged on wall-clock time, never on the worker's (injectable) clock.-- function verifyService(token) {++ function verifyService(token, { acceptSandbox = false } = {}) {const publicKey = keys.get();if (!publicKey) return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };-- return serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience, acceptSandbox });++ // Belt and braces for a contracts release that does not know `env` yet.++ if (r.ok && r.claims.env === 'sandbox' && !acceptSandbox) return { ok: false, code: 'token.sandbox_refused', reason: 'sandbox tokens are not accepted here' };++ return r;}function verifyUser(token) {@@@ -170,11 +181,40 @@ function createAuth({ config, keys }) {if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx });const r = verifyService(token);if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });++ // Developer apps only ever act through events.app.* (appOrService); a first-party++ // capability in an app token is never honoured.++ if (/^app:/.test(String(r.claims.sub))) return http.sendProblem(res, 403, 'capability.denied', { detail: 'app tokens are not accepted on this route', ctx });const c = allows(r.claims, id);if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });const service = serviceSlug(r.claims.sub);if (requireService && !service) return http.sendProblem(res, 403, 'capability.denied', { detail: 'only service principals may do this', ctx });-- req.principal = { sub: r.claims.sub, service, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti };++ req.principal = { kind: 'service', sub: r.claims.sub, service, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti };++ return next();++ };++ }++++ /**++ * A route shared by first-party services (serviceCap) and developer apps (appCap). An app token++ * is judged only on appCap and becomes req.principal = { kind: 'app', sub, appId, projectId,++ * projectKey, source, prefix, env, onBehalfOf }; sandbox tokens are accepted for apps only.++ * Anything else goes through requireCap(serviceCap).++ */++ function appOrService(serviceCap, appCap, { requireService = false } = {}) {++ const serviceGuard = requireCap(serviceCap, { requireService });++ return function appOrServiceGuard(req, res, next) {++ const ctx = req.ov;++ const token = bearer(req);++ if (!token || !config.apps.enabled) return serviceGuard(req, res, next);++ const r = verifyService(token, { acceptSandbox: true });++ if (!r.ok || !/^app:/.test(String(r.claims.sub))) return serviceGuard(req, res, next);++ const principal = apps.appPrincipal(r.claims);++ if (principal.error) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: principal.error, ctx });++ // Network revoked this app after the token was issued (network.app.revoked reached us).++ const revoked = store && store.revokedAt(principal.sub, 'app');++ if (revoked && r.claims.iat * 1000 <= revoked) return http.sendProblem(res, 401, 'token.revoked', { detail: 'this app was revoked', ctx });++ const c = allows(r.claims, appCap);++ if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });++ req.principal = { ...principal, cap: r.claims.cap, jti: r.claims.jti, iat: r.claims.iat };return next();};}@@@ -210,7 +250,7 @@ function createAuth({ config, keys }) {return { kind: 'user', subjectId, sub: String(claims.sub) };}-- return { verifyService, verifyUser, requireCap, realtimeViewer };++ return { verifyService, verifyUser, requireCap, appOrService, realtimeViewer };}module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, verifyUserJwt, serviceSlug, bearer, cookie };@@@ -29,6 +29,8 @@ function sourcePrefixes(env) {}for (const [source, prefixes] of Object.entries(map)) {if (!/^[a-z][a-z0-9-]{1,39}$/.test(source)) throw new Error(`EVENTS_SOURCE_PREFIXES: bad source "${source}"`);++ // app.* event types and app-<ulid> sources belong to developer apps (events.app.publish) only.++ if (source === 'app' || source.startsWith('app-')) throw new Error(`EVENTS_SOURCE_PREFIXES: "${source}" is reserved for developer apps`);if (!Array.isArray(prefixes) || !prefixes.length) throw new Error(`EVENTS_SOURCE_PREFIXES: ${source} needs at least one prefix`);for (const p of prefixes) {if (typeof p !== 'string' || p.split('.')[0] !== source) {@@@ -84,9 +86,30 @@ function load(env = process.env) {// /api/ready reports `dlq` degraded (still ready) once more dead deliveries than this wait for replay.dlqDegradedAt: int(env.EVENTS_DLQ_DEGRADED_AT, 100),maxSubscriptionsPerConsumer: int(env.EVENTS_MAX_SUBSCRIPTIONS, 100),-- // Hostname patterns a subscription endpoint may point at (SSRF guard).++ // Hostname patterns a first-party subscription endpoint may point at (SSRF guard). App++ // subscriptions (events.app.subscribe) use server/egress.js instead: public https only.endpointHosts: list(env.EVENTS_ENDPOINT_HOSTS, ['127.0.0.1', '*.openvibe.*']),++ // Developer apps (ADR-014; events.app.publish|read|subscribe). Quotas are per project and++ // environment and enforced here; 0 turns a limit off.++ apps: {++ enabled: env.EVENTS_APPS !== 'off',++ quotas: {++ production: {++ publishPerMinute: int(env.EVENTS_APP_PUBLISH_PER_MINUTE, 120),++ retainedBytes: int(env.EVENTS_APP_RETAINED_BYTES, 50 * 1024 * 1024),++ maxSubscriptions: int(env.EVENTS_APP_MAX_SUBSCRIPTIONS, 20),++ },++ sandbox: {++ publishPerMinute: int(env.EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE, 30),++ retainedBytes: int(env.EVENTS_APP_SANDBOX_RETAINED_BYTES, 5 * 1024 * 1024),++ maxSubscriptions: int(env.EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS, 5),++ },++ },++ // Sandbox events are pruned sooner than everything else.++ sandboxRetentionDays: int(env.EVENTS_APP_SANDBOX_RETENTION_DAYS, 7),++ },++// Realtime (SSE) gatewayrealtime: {maxConnections: int(env.REALTIME_MAX_CONNECTIONS, 2000),nnew file mode 100644@@@ -0,0 +1,169 @@++'use strict';++/**++ * Outbound delivery to developer-app endpoints (events.app.subscribe). SSRF guard:++ *++ * - https only, no credentials in the URL, no trailing-dot or single-label hostnames++ * - every address the hostname resolves to must be public (no loopback, private, link-local,++ * CGNAT, multicast, documentation, benchmarking, reserved, unique-local, NAT64/6to4/Teredo++ * wrapping a non-public v4 address, …)++ * - checked when the subscription is created AND again at delivery time, inside the socket's own++ * DNS lookup, so the connection goes to the address that was checked (no rebinding window)++ * - redirects are never followed (a 3xx is a failed attempt)++ *++ * First-party subscriptions keep the host allow-list in server/endpoints.js.++ */++const dns = require('dns');++const net = require('net');++const https = require('https');++++const blocked = new net.BlockList();++for (const [addr, prefix] of [++ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],++ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],++ ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],++]) blocked.addSubnet(addr, prefix, 'ipv4');++for (const [addr, prefix] of [++ ['::', 128], ['::1', 128], ['::', 96], ['100::', 64], ['2001::', 23], ['2001:db8::', 32], ['fc00::', 7], ['fe80::', 10],++ ['fec0::', 10], ['ff00::', 8], ['3fff::', 20], ['5f00::', 16],++]) blocked.addSubnet(addr, prefix, 'ipv6');++++/** The IPv4 address an IPv6 address carries (mapped, compatible-ish NAT64, 6to4, Teredo), or null. */++function embeddedV4(ip) {++ const words = expandV6(ip);++ if (!words) return null;++ const v4 = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;++ if (words.slice(0, 5).every(w => w === 0) && words[5] === 0xffff) return v4(words[6], words[7]); // ::ffff:a.b.c.d++ if (words[0] === 0x64 && words[1] === 0xff9b) return v4(words[6], words[7]); // 64:ff9b::/96 (and /48)++ if (words[0] === 0x2002) return v4(words[1], words[2]); // 6to4++ if (words[0] === 0x2001 && words[1] === 0) return v4(words[6] ^ 0xffff, words[7] ^ 0xffff); // Teredo client++ return null;++}++++function expandV6(ip) {++ let s = String(ip).toLowerCase();++ const pct = s.indexOf('%');++ if (pct >= 0) s = s.slice(0, pct);++ if (net.isIPv6(s) === false) return null;++ // Trailing dotted quad -> two words.++ const dq = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);++ if (dq) {++ const p = dq[1].split('.').map(Number);++ s = s.slice(0, -dq[1].length) + `${((p[0] << 8) | p[1]).toString(16)}:${((p[2] << 8) | p[3]).toString(16)}`;++ }++ const [head, tail] = s.split('::');++ const h = head ? head.split(':') : [];++ const t = tail !== undefined ? (tail ? tail.split(':') : []) : null;++ const words = t === null ? h : [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];++ if (words.length !== 8) return null;++ return words.map(w => parseInt(w || '0', 16));++}++++/** True only for a globally routable unicast address. */++function isPublicAddress(ip) {++ const s = String(ip || '');++ const family = net.isIP(s);++ if (family === 4) return !blocked.check(s, 'ipv4');++ if (family === 6) {++ const bare = s.split('%')[0];++ if (blocked.check(bare, 'ipv6')) return false;++ const v4 = embeddedV4(bare);++ if (v4 !== null) return !blocked.check(v4, 'ipv4');++ return true;++ }++ return false;++}++++/** Syntax part of the check: { ok, url } or { ok: false, reason }. No DNS. */++function parseAppEndpoint(endpoint) {++ if (typeof endpoint !== 'string' || endpoint.length > 2048) return { ok: false, reason: 'endpoint must be a URL of at most 2048 characters' };++ let url;++ try { url = new URL(endpoint); } catch { return { ok: false, reason: 'not a URL' }; }++ if (url.protocol !== 'https:') return { ok: false, reason: 'app endpoints must use https' };++ if (url.username || url.password) return { ok: false, reason: 'credentials in the URL are not allowed' };++ let host = url.hostname.toLowerCase();++ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);++ if (host.endsWith('.')) return { ok: false, reason: 'trailing-dot hostnames are not allowed' };++ if (net.isIP(host)) {++ if (!isPublicAddress(host)) return { ok: false, reason: `${host} is not a public address` };++ return { ok: true, url, host, literal: true };++ }++ if (!host.includes('.') || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {++ return { ok: false, reason: `${host} is not a public hostname` };++ }++ return { ok: true, url, host, literal: false };++}++++/** Resolve every address of `host`; { ok, addresses } or { ok: false, reason, transient }. */++function resolvePublic(host, { lookup = dns.lookup } = {}) {++ return new Promise((resolve) => {++ lookup(host, { all: true, verbatim: true }, (err, addresses) => {++ if (err) return resolve({ ok: false, reason: `cannot resolve ${host}: ${err.code || err.message}`, transient: true });++ const list = Array.isArray(addresses) ? addresses : [{ address: addresses, family: net.isIP(addresses) }];++ if (!list.length) return resolve({ ok: false, reason: `${host} has no addresses`, transient: true });++ const bad = list.find(a => !isPublicAddress(a.address));++ if (bad) return resolve({ ok: false, reason: `${host} resolves to a non-public address` });++ return resolve({ ok: true, addresses: list });++ });++ });++}++++/** Full subscribe-time check: syntax plus DNS. */++async function checkAppEndpoint(endpoint, { lookup } = {}) {++ const p = parseAppEndpoint(endpoint);++ if (!p.ok || p.literal) return p;++ const r = await resolvePublic(p.host, { lookup });++ return r.ok ? p : { ok: false, reason: r.reason };++}++++/**++ * POST to an app endpoint through the guard. Returns { status } or throws (err.permanent = true++ * when the endpoint itself is refused). `requestImpl`/`isAllowed` exist for tests only.++ */++function createGuardedPost({ lookup = dns.lookup, requestImpl = https.request, isAllowed = isPublicAddress, maxResponseBytes = 64 * 1024 } = {}) {++ return function guardedPost(endpoint, { headers, body, timeoutMs = 10000 }) {++ const p = parseAppEndpoint(endpoint);++ if (!p.ok) return Promise.reject(Object.assign(new Error(`endpoint not allowed: ${p.reason}`), { permanent: true }));++ if (p.literal && !isAllowed(p.host)) return Promise.reject(Object.assign(new Error('endpoint not allowed: not a public address'), { permanent: true }));++ return new Promise((resolve, reject) => {++ let settled = false;++ const done = (fn, v) => { if (!settled) { settled = true; fn(v); } };++ // The socket's own lookup: the address we connect to is the one we checked.++ const guardedLookup = (hostname, opts, cb) => {++ if (typeof opts === 'function') { cb = opts; opts = {}; }++ lookup(hostname, { all: true, verbatim: true, family: (opts && opts.family) || 0 }, (err, addresses) => {++ if (err) return cb(err);++ const list = Array.isArray(addresses) ? addresses : [{ address: addresses, family: net.isIP(addresses) }];++ if (!list.length || list.some(a => !isAllowed(a.address))) {++ return cb(Object.assign(new Error(`${hostname} resolves to a non-public address`), { code: 'EOV_NOT_PUBLIC', permanent: true }));++ }++ if (opts && opts.all) return cb(null, list.map(a => ({ address: a.address, family: a.family || net.isIP(a.address) })));++ return cb(null, list[0].address, list[0].family || net.isIP(list[0].address));++ });++ };++ const req = requestImpl({++ hostname: p.host,++ port: p.url.port || undefined,++ path: `${p.url.pathname}${p.url.search}`,++ method: 'POST',++ headers: { ...headers, 'Content-Length': Buffer.byteLength(body) },++ lookup: p.literal ? undefined : guardedLookup,++ servername: p.literal ? undefined : p.host,++ timeout: timeoutMs,++ }, (res) => {++ let n = 0;++ res.on('data', (c) => { n += c.length; if (n > maxResponseBytes) res.destroy(); });++ res.on('end', () => done(resolve, { status: res.statusCode }));++ res.on('close', () => done(resolve, { status: res.statusCode }));++ res.on('error', () => done(resolve, { status: res.statusCode }));++ });++ req.on('timeout', () => { req.destroy(Object.assign(new Error('timeout'), { name: 'TimeoutError' })); });++ req.on('error', (err) => {++ if (err.code === 'EOV_NOT_PUBLIC' || (err.cause && err.cause.code === 'EOV_NOT_PUBLIC')) err.permanent = true;++ done(reject, err);++ });++ req.end(body);++ });++ };++}++++module.exports = { isPublicAddress, parseAppEndpoint, resolvePublic, checkAppEndpoint, createGuardedPost, embeddedV4 };@@@ -14,18 +14,24 @@ const { createWorker } = require('./worker');const { createRealtime } = require('./realtime');const { createApp } = require('./app');const { createMetrics } = require('./metrics');++const { createGuardedPost } = require('./egress');--async function start({ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, deliveryFetch = fetchImpl, log = console, listen = true } = {}) {++async function start({++ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, deliveryFetch = fetchImpl,++ appPost = undefined, dnsLookup = undefined, log = console, listen = true,++} = {}) {config = config || load();const db = openDb(config.dbPath);const store = createStore(db, { clock, maxHops: config.maxHops });const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });-- const auth = createAuth({ config, keys });++ const auth = createAuth({ config, keys, store });const metrics = createMetrics({ store });-- const worker = createWorker({ store, config, clock, fetchImpl: deliveryFetch, log, observe: metrics.observe });++ // appPost / dnsLookup: developer-app delivery and the subscribe-time DNS check (server/egress.js);++ // injectable for tests only.++ const worker = createWorker({ store, config, clock, fetchImpl: deliveryFetch, appPost: appPost || createGuardedPost({ lookup: dnsLookup }), log, observe: metrics.observe });const realtime = createRealtime({ store, auth, config, log });metrics.bind({ realtime });-- const app = createApp({ config, store, auth, keys, worker, realtime, metrics, log });++ const app = createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, log });// The key loads in the background (retrying while Network boots); /api/ready says when it has.const keyLoaded = keys.start().catch(() => null);@@@ -34,7 +40,7 @@ async function start({ config, clock = { now: () => Date.now() }, fetchImpl = glconst prune = () => {try {-- const r = store.prune({ retentionDays: config.retentionDays, receiptRetentionDays: config.receiptRetentionDays });++ const r = store.prune({ retentionDays: config.retentionDays, receiptRetentionDays: config.receiptRetentionDays, sandboxRetentionDays: config.apps.sandboxRetentionDays });if (r.events || r.receipts) log.log(`[retention] pruned ${r.events} events, ${r.receipts} receipts`);} catch (err) {log.error(`[retention] prune failed: ${err.message}`);@@@ -10,6 +10,7 @@* subject only the user whose subject id is the event's actor.id or its subject.id (subject* type user); a guessed topic yields nothing for anyone else* internal service principals (token with events.event.read) only, never a browser++ * Developer-app events (app.<project_key>.*, events.app.publish) are never streamed here, to anyone.** Resume: `Last-Event-ID` (or ?last_event_id=) is a seq. Missed events are replayed first; when the* cursor is older than retention an `event: gap` message comes first, so the client knows to@@@ -51,6 +52,8 @@ function createRealtime({ store, auth, config, log = console }) {}function visibleTo(viewer, row) {++ // Developer-app events (and anything sandbox) never reach realtime: apps pull or subscribe.++ if (row.project_id || (row.env && row.env !== 'production')) return false;if (row.visibility === 'public') return true;if (viewer.kind === 'service') return true;if (row.visibility !== 'subject' || viewer.kind !== 'user' || !viewer.subjectId) return false;@@@ -9,6 +9,7 @@ const fs = require('fs');const path = require('path');const Database = require('better-sqlite3');const topics = require('./topics');++const apps = require('./apps');const PRIORITY_RANK = { critical: 0, important: 1, low: 2 };const RANK_PRIORITY = ['critical', 'important', 'low'];@@@ -82,6 +83,16 @@ CREATE TABLE IF NOT EXISTS consumer_checkpoints (PRIMARY KEY (consumer, topic_pattern));++-- Developer apps whose access Network revoked (network.app.revoked -> scope 'app') or whose++-- events.app.subscribe grant it withdrew (scope 'events.app.subscribe'). An app token issued at or++-- before revoked_at (ms, the revocation's own timestamp) is refused for that scope.++CREATE TABLE IF NOT EXISTS app_revocations (++ consumer TEXT NOT NULL,++ scope TEXT NOT NULL,++ revoked_at INTEGER NOT NULL,++ PRIMARY KEY (consumer, scope)++);++-- Publish receipts: remember accepted event ids for longer than the events themselves, so a-- producer that re-publishes an old id after retention is still answered "duplicate".CREATE TABLE IF NOT EXISTS idempotency_receipts (@@@ -94,6 +105,26 @@ CREATE TABLE IF NOT EXISTS idempotency_receipts (const PUBLISH_RECEIPT = 'events:publish'; // receipts' consumer column for accepted publishes++/**++ * Columns added after the first release (developer apps, ADR-014). Existing databases get them++ * with ALTER TABLE; every row stored before is a first-party production row.++ * events.project_id / env / size_bytes who owns an app event, its environment, stored bytes++ * subscriptions.project_id / env an app subscription's scope (NULL project = first-party)++ */++const ADDED_COLUMNS = {++ events: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"], ['size_bytes', 'INTEGER NOT NULL DEFAULT 0']],++ subscriptions: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"]],++};++++function migrate(db) {++ for (const [table, cols] of Object.entries(ADDED_COLUMNS)) {++ const have = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name));++ for (const [name, type] of cols) if (!have.has(name)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${type}`);++ }++ db.exec(`CREATE INDEX IF NOT EXISTS idx_events_project ON events(project_id, env, received_at) WHERE project_id IS NOT NULL;++ CREATE INDEX IF NOT EXISTS idx_subscriptions_project ON subscriptions(project_id, env) WHERE project_id IS NOT NULL;`);++}++function openDb(dbPath) {if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });const db = new Database(dbPath);@@@ -102,6 +133,7 @@ function openDb(dbPath) {db.pragma('foreign_keys = ON');db.pragma('busy_timeout = 5000');db.exec(SCHEMA);++ migrate(db);db.prepare("INSERT OR IGNORE INTO sequences (name, value) VALUES ('events', 0)").run();return db;}@@@ -145,6 +177,10 @@ function subscriptionView(row, { withSecret = false } = {}) {created_at: new Date(row.created_at).toISOString(),updated_at: new Date(row.updated_at).toISOString(),};++ if (row.project_id) {++ out.project_id = row.project_id;++ out.env = row.env || 'production';++ }if (withSecret) out.secret = row.secret;return out;}@@@ -161,10 +197,18 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}selfRepeats: db.prepare(`SELECT COUNT(*) AS n FROM events WHERE trace_id = ? AND source = ? AND event_type = ?AND subject_type = ? AND subject_id = ? AND (request_id IS NULL OR request_id != ?)`),insertEvent: db.prepare(`INSERT INTO events (id, seq, event_type, version, source, actor, subject_type, subject_id,-- subject_revision, trace_id, priority, visibility, occurred_at, received_at, payload, hops, publisher, request_id)++ subject_revision, trace_id, priority, visibility, occurred_at, received_at, payload, hops, publisher, request_id,++ project_id, env, size_bytes)VALUES (@id, @seq, @event_type, @version, @source, @actor, @subject_type, @subject_id, @subject_revision,-- @trace_id, @priority, @visibility, @occurred_at, @received_at, @payload, @hops, @publisher, @request_id)`),-- enabledSubs: db.prepare('SELECT id, topic_pattern FROM subscriptions WHERE enabled = 1'),++ @trace_id, @priority, @visibility, @occurred_at, @received_at, @payload, @hops, @publisher, @request_id,++ @project_id, @env, @size_bytes)`),++ enabledSubs: db.prepare('SELECT id, topic_pattern, project_id, env FROM subscriptions WHERE enabled = 1'),++ projectRecent: db.prepare('SELECT COUNT(*) AS n FROM events WHERE project_id = ? AND env = ? AND received_at > ?'),++ projectBytes: db.prepare('SELECT COALESCE(SUM(size_bytes), 0) AS b FROM events WHERE project_id = ? AND env = ?'),++ disableAppSubs: db.prepare('UPDATE subscriptions SET enabled = 0, updated_at = ? WHERE consumer = ? AND enabled = 1'),++ revoke: db.prepare(`INSERT INTO app_revocations (consumer, scope, revoked_at) VALUES (?, ?, ?)++ ON CONFLICT(consumer, scope) DO UPDATE SET revoked_at = MAX(revoked_at, excluded.revoked_at)`),++ revokedAt: db.prepare('SELECT revoked_at FROM app_revocations WHERE consumer = ? AND scope = ?'),insertDelivery: db.prepare(`INSERT OR IGNORE INTO deliveries (event_id, subscription_id, seq, priority, attempt, status,next_attempt_at, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 'pending', ?, ?, ?)`),afterSeq: db.prepare('SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?'),@@@ -176,13 +220,23 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}/*** Store a batch atomically. `items` are validated envelopes with defaults filled. Returns* { results: [{ event_id, seq, duplicate }], inserted: [row] }. Throws StoreError for an id-- * conflict or a detected loop; nothing of the batch is stored then.++ * conflict, a detected loop or an exceeded project quota; nothing of the batch is stored then.++ *++ * `project` ({ projectId, env, publishPerMinute, retainedBytes }) marks an app publish: the rows++ * carry project_id and env, and the project's rate and retained-bytes quotas are checked here,++ * inside the transaction, against what is already stored.*/-- const insertBatch = db.transaction((items, { publisher, requestId }) => {++ const insertBatch = db.transaction((items, { publisher, requestId, project = null }) => {const now = clock.now();const results = [];const inserted = [];const subs = q.enabledSubs.all();++ let recent = null;++ let bytes = null;++ if (project) {++ recent = q.projectRecent.get(project.projectId, project.env, now - 60 * 1000).n;++ bytes = q.projectBytes.get(project.projectId, project.env).b;++ }for (const env of items) {const existing = q.getEvent.get(env.event_id);if (existing) {@@@ -206,6 +260,21 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}`trace ${env.trace_id} already carries ${hops - 1} hops for ${env.event_type}; refusing to extend a probable loop`,{ event_id: env.event_id, hops: hops - 1, max_hops: maxHops });}++ const payloadJson = JSON.stringify(env.payload);++ const actorJson = JSON.stringify(env.actor);++ const sizeBytes = Buffer.byteLength(payloadJson) + Buffer.byteLength(actorJson) + env.event_type.length + env.subject.id.length;++ if (project) {++ recent += 1;++ bytes += sizeBytes;++ if (project.publishPerMinute && recent > project.publishPerMinute) {++ throw new StoreError(429, 'events.quota_exceeded', `project ${project.projectId} (${project.env}) may publish at most ${project.publishPerMinute} events per minute`,++ { quota: 'publish_rate', limit: project.publishPerMinute, window: 'minute', retry_after: 60 });++ }++ if (project.retainedBytes && bytes > project.retainedBytes) {++ throw new StoreError(429, 'events.quota_exceeded', `project ${project.projectId} (${project.env}) may keep at most ${project.retainedBytes} bytes of events`,++ { quota: 'retained_bytes', limit: project.retainedBytes, used: bytes - sizeBytes });++ }++ }const seq = q.nextSeq.get().value;const row = {id: env.event_id,@@@ -213,7 +282,7 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}event_type: env.event_type,version: env.version,source: env.source,-- actor: JSON.stringify(env.actor),++ actor: actorJson,subject_type: env.subject.type,subject_id: env.subject.id,subject_revision: env.subject.revision ?? null,@@@ -222,24 +291,55 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}visibility: env.visibility,occurred_at: env.timestamp,received_at: now,-- payload: JSON.stringify(env.payload),++ payload: payloadJson,hops,publisher,request_id: requestId || null,++ project_id: project ? project.projectId : null,++ env: project ? project.env : 'production',++ size_bytes: sizeBytes,};q.insertEvent.run(row);q.addReceipt.run(PUBLISH_RECEIPT, env.event_id, now);for (const s of subs) {-- if (topics.matches(s.topic_pattern, env.event_type)) {++ if (apps.deliverable(s, row)) {q.insertDelivery.run(env.event_id, s.id, seq, PRIORITY_RANK[env.priority], now, now, now);}}++ revocationHook(row, now);results.push({ event_id: env.event_id, seq, duplicate: false });inserted.push(row);}return { results, inserted };});++ /**++ * Network tells Events when an app loses its access (network.app.revoked, also sent for every++ * app of an archived project) or its events.app.subscribe grant (network.grant.changed). The++ * app's subscriptions stop at once, in the same transaction that stores the event, so a++ * revocation reaches deliveries without waiting for anything else.++ */++ function revocationHook(row, now) {++ if (row.source !== 'network' || row.project_id || row.subject_type !== 'app') return;++ let payload = {};++ try { payload = JSON.parse(row.payload) || {}; } catch { payload = {}; }++ let scope = null;++ if (row.event_type === 'network.app.revoked') scope = 'app';++ else if (row.event_type === 'network.grant.changed' && ['events.app.subscribe', 'events.app.*', 'events.*'].includes(payload.capability)++ && !['approved', 'requested'].includes(payload.to)) scope = 'events.app.subscribe';++ if (!scope || !/^app_[0-9A-HJKMNP-TV-Z]{26}$/.test(row.subject_id)) return;++ const consumer = `app:${row.subject_id}`;++ const at = Date.parse(row.occurred_at);++ q.revoke.run(consumer, scope, Number.isFinite(at) ? at : now);++ q.disableAppSubs.run(now, consumer);++ }++++ /** When Network revoked `consumer` (app:app_…) for `scope` ('app' | 'events.app.subscribe'), in ms; or null. */++ function revokedAt(consumer, scope) {++ const r = q.revokedAt.get(consumer, scope);++ return r ? r.revoked_at : null;++ }++function getEvent(id) {return q.getEvent.get(id) || null;}@@@ -285,12 +385,15 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}// ── Subscriptions ────────────────────────────────────────-- function createSubscription({ id, consumer, topicPattern, endpoint, secret, retryPolicy }) {++ function createSubscription({ id, consumer, topicPattern, endpoint, secret, retryPolicy, projectId = null, env = 'production' }) {const now = clock.now();-- db.prepare(`INSERT INTO subscriptions (id, consumer, topic_pattern, endpoint, secret, enabled, retry_policy, created_at, updated_at)-- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`).run(id, consumer, topicPattern, endpoint, secret, retryPolicy ? JSON.stringify(retryPolicy) : null, now, now);++ db.prepare(`INSERT INTO subscriptions (id, consumer, topic_pattern, endpoint, secret, enabled, retry_policy, created_at, updated_at, project_id, env)++ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)`).run(id, consumer, topicPattern, endpoint, secret, retryPolicy ? JSON.stringify(retryPolicy) : null, now, now, projectId, env);return getSubscription(id);}++ function countProjectSubscriptions(projectId, env) {++ return db.prepare('SELECT COUNT(*) AS n FROM subscriptions WHERE project_id = ? AND env = ?').get(projectId, env).n;++ }function getSubscription(id) {return db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id) || null;}@@@ -358,14 +461,16 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}const requeue = db.transaction((sub, { fromSeq, eventIds, max = 10000 }) => {const now = clock.now();let rows;++ const cols = 'id, seq, priority, event_type, visibility, project_id, env';if (Array.isArray(eventIds)) {-- const get = db.prepare('SELECT id, seq, priority, event_type FROM events WHERE id = ?');++ const get = db.prepare(`SELECT ${cols} FROM events WHERE id = ?`);rows = eventIds.map(id => get.get(id)).filter(Boolean);} else {-- rows = db.prepare('SELECT id, seq, priority, event_type FROM events WHERE seq >= ? AND event_type GLOB ? ORDER BY seq LIMIT ?')++ rows = db.prepare(`SELECT ${cols} FROM events WHERE seq >= ? AND event_type GLOB ? ORDER BY seq LIMIT ?`).all(fromSeq, topics.toGlob(sub.topic_pattern), max * 2);}-- rows = rows.filter(r => topics.matches(sub.topic_pattern, r.event_type)).slice(0, max);++ // Replay never widens a subscription's scope (app scope, sandbox separation).++ rows = rows.filter(r => apps.deliverable(sub, r)).slice(0, max);const up = db.prepare(`INSERT INTO deliveries (event_id, subscription_id, seq, priority, attempt, status, next_attempt_at, created_at, updated_at)VALUES (?, ?, ?, ?, 0, 'pending', ?, ?, ?)ON CONFLICT(event_id, subscription_id) DO UPDATE SET status = 'pending', attempt = 0, next_attempt_at = excluded.next_attempt_at,@@@ -395,9 +500,15 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}// ── Retention ────────────────────────────────────────────-- /** Drop events (and their deliveries) older than retentionDays, and old publish receipts. */-- function prune({ retentionDays = 30, receiptRetentionDays = 90, now = clock.now() } = {}) {-- const events = db.prepare('DELETE FROM events WHERE received_at < ?').run(now - retentionDays * DAY_MS).changes;++ /**++ * Drop events (and their deliveries) older than retentionDays (sandbox events older than++ * sandboxRetentionDays, when shorter), and old publish receipts.++ */++ function prune({ retentionDays = 30, receiptRetentionDays = 90, sandboxRetentionDays = retentionDays, now = clock.now() } = {}) {++ let events = db.prepare('DELETE FROM events WHERE received_at < ?').run(now - retentionDays * DAY_MS).changes;++ if (sandboxRetentionDays < retentionDays) {++ events += db.prepare("DELETE FROM events WHERE env = 'sandbox' AND received_at < ?").run(now - sandboxRetentionDays * DAY_MS).changes;++ }const receipts = db.prepare('DELETE FROM idempotency_receipts WHERE processed_at < ?').run(now - Math.max(receiptRetentionDays, retentionDays) * DAY_MS).changes;return { events, receipts };@@@ -408,8 +519,8 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}}return {-- db, insertBatch, getEvent, lastSeq, oldestSeq, scan,-- createSubscription, getSubscription, listSubscriptions, countSubscriptions, setSubscriptionEnabled,++ db, insertBatch, getEvent, revokedAt, lastSeq, oldestSeq, scan,++ createSubscription, getSubscription, listSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled,dueDeliveries, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,getCheckpoint, setCheckpoint, prune, ping,};@@@ -9,6 +9,10 @@* (including redirects, which are never followed) is retried with backoff 1s, 5s, 30s, 2m, 10m, 1h* up to maxAttempts, then the delivery is `dead` (the DLQ; replay requeues it).*++ * App subscriptions (events.app.subscribe) go through server/egress.js instead of fetch: https to a++ * public address only, re-resolved and checked on every attempt, the connection pinned to the checked++ * address, redirects refused. A refused address is permanent (dead at once; replay requeues it).++ ** Scheduling: a setTimeout chain (never overlapping ticks); priority classes critical > important >* low, then seq; one delivery in flight per subscription; at most maxInflight overall.*/@@@ -16,9 +20,10 @@ const crypto = require('crypto');const { sign } = require('../lib/client');const { rowToEnvelope } = require('./store');const { checkEndpoint } = require('./endpoints');++const { createGuardedPost } = require('./egress');// observe (optional): { delivered(seconds, row), attempt(outcome) } — metrics hooks, never required.--function createWorker({ store, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, observe = null }) {++function createWorker({ store, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, appPost = createGuardedPost(), log = console, observe = null }) {const opts = config.worker;const busy = new Set(); // subscription ids with a delivery in flightconst inflight = new Set(); // promises@@@ -43,29 +48,36 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchIconst { maxAttempts, backoffMs } = policy(sub);let outcome;try {-- // Re-check the endpoint at send time too (the allow-list may have been tightened).-- const check = checkEndpoint(sub.endpoint, config.endpointHosts);-- if (!check.ok) throw Object.assign(new Error(`endpoint not allowed: ${check.reason}`), { permanent: true });const body = JSON.stringify({ event: rowToEnvelope(row), seq: row.seq });-- const res = await fetchImpl(sub.endpoint, {-- method: 'POST',-- redirect: 'manual',-- signal: AbortSignal.timeout(opts.timeoutMs),-- headers: {-- 'Content-Type': 'application/json',-- 'User-Agent': 'OpenVibe.Events/0.1',-- 'X-OpenVibe-Event-Id': row.id,-- 'X-OpenVibe-Event-Type': row.event_type,-- 'X-OpenVibe-Seq': String(row.seq),-- 'X-OpenVibe-Subscription-Id': sub.id,-- 'X-OpenVibe-Delivery-Attempt': String(attempt),-- 'X-OpenVibe-Hops': String(row.hops),-- 'X-OpenVibe-Signature': sign(body, sub.secret),-- traceparent: `00-${row.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,-- },-- body,-- });-- try { await res.body?.cancel(); } catch { /* body is not needed */ }++ const headers = {++ 'Content-Type': 'application/json',++ 'User-Agent': 'OpenVibe.Events/0.1',++ 'X-OpenVibe-Event-Id': row.id,++ 'X-OpenVibe-Event-Type': row.event_type,++ 'X-OpenVibe-Seq': String(row.seq),++ 'X-OpenVibe-Subscription-Id': sub.id,++ 'X-OpenVibe-Delivery-Attempt': String(attempt),++ 'X-OpenVibe-Hops': String(row.hops),++ 'X-OpenVibe-Signature': sign(body, sub.secret),++ traceparent: `00-${row.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,++ };++ let res;++ if (sub.project_id) {++ // Developer-app endpoint: public https only, checked again now (egress.js).++ res = await appPost(sub.endpoint, { headers, body, timeoutMs: opts.timeoutMs });++ } else {++ // Re-check the endpoint at send time too (the allow-list may have been tightened).++ const check = checkEndpoint(sub.endpoint, config.endpointHosts);++ if (!check.ok) throw Object.assign(new Error(`endpoint not allowed: ${check.reason}`), { permanent: true });++ res = await fetchImpl(sub.endpoint, {++ method: 'POST',++ redirect: 'manual',++ signal: AbortSignal.timeout(opts.timeoutMs),++ headers,++ body,++ });++ try { await res.body?.cancel(); } catch { /* body is not needed */ }++ }if (res.status >= 200 && res.status < 300) outcome = { ok: true, attempt, status: res.status };else outcome = { ok: false, attempt, status: res.status, error: `HTTP ${res.status}` };} catch (err) {nnew file mode 100644@@@ -0,0 +1,369 @@++'use strict';++// Developer apps (ADR-014): events.app.publish / events.app.read / events.app.subscribe with app++// tokens, project scoping, sandbox separation, the public-endpoint SSRF guard, quotas, revocation.++const assert = require('assert');++const nodeHttp = require('http');++const { ids } = require('openvibe-contracts');++const { verifyDelivery } = require('../lib/client');++const apps = require('../server/apps');++const { createGuardedPost, isPublicAddress } = require('../server/egress');++const { load } = require('../server/config');++const { boot, request, serviceToken, appToken, envelope, subscriber, suite, sleep } = require('./helpers');++++const t = suite('apps');++const ALL = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];++++// ── Fake DNS: the test controls what every hostname resolves to ──++const dnsTable = new Map([['hooks.example.com', '93.184.216.34'], ['evil.example.com', '10.0.0.7'], ['rebind.example.com', '93.184.216.35']]);++function fakeLookup(host, opts, cb) {++ if (typeof opts === 'function') { cb = opts; opts = {}; }++ const ip = dnsTable.get(host);++ if (!ip) return cb(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }));++ const family = ip.includes(':') ? 6 : 4;++ return opts.all ? cb(null, [{ address: ip, family }]) : cb(null, ip, family);++}++++// A receiving server on 127.0.0.1 stands in for the app's public https endpoint: deliveries go through++// the real guarded poster, with DNS answered by the table above, loopback allowed only for this++// fake, and plain http underneath (the code path under test is the guard and the pinned lookup).++const seen = [];++const receiver = nodeHttp.createServer((req, res) => {++ const chunks = [];++ req.on('data', c => chunks.push(c));++ req.on('end', () => {++ seen.push({ url: req.url, headers: req.headers, rawBody: Buffer.concat(chunks) });++ if (req.url.startsWith('/redirect')) { res.writeHead(302, { Location: 'http://127.0.0.1:1/metadata' }); return res.end(); }++ res.statusCode = 204;++ res.end();++ });++});++let receiverPort;++const pinned = [];++const testPost = createGuardedPost({++ lookup: (host, opts, cb) => fakeLookup(host, opts, (err, a, f) => {++ if (err) return cb(err);++ // Every public test host is served by the local receiver.++ const list = (Array.isArray(a) ? a : [{ address: a, family: f }]).map(x => ({ ...x, address: isPublicAddress(x.address) ? '127.0.0.1' : x.address }));++ pinned.push(list[0].address);++ return opts.all ? cb(null, list) : cb(null, list[0].address, list[0].family);++ }),++ requestImpl: (options, cb) => nodeHttp.request({ ...options, port: receiverPort }, cb),++ isAllowed: (ip) => ip === '127.0.0.1' || isPublicAddress(ip),++});++++let h;++const prjA = `prj_${ids.ulid()}`;++const prjB = `prj_${ids.ulid()}`;++const appA = ids.newId('app');++const appA2 = ids.newId('app'); // a production app of the same project++const appB = ids.newId('app');++const keyA = apps.projectKey(prjA);++const keyB = apps.projectKey(prjB);++const tokA = (over = {}) => appToken({ appId: appA, projectId: prjA, env: 'sandbox', cap: ALL, ...over });++const tokA2 = (over = {}) => appToken({ appId: appA2, projectId: prjA, env: 'production', cap: ALL, ...over });++const tokB = (over = {}) => appToken({ appId: appB, projectId: prjB, env: 'sandbox', cap: ALL, ...over });++const live = serviceToken('live', ['events.event.publish']);++const reader = serviceToken('search', ['events.event.read']);++++function appEvent(appId, projectId, name = 'order.created', over = {}) {++ return envelope('x', {++ source: apps.appSource(appId), event_type: `app.${apps.projectKey(projectId)}.${name}`,++ actor: { type: 'app', id: appId }, subject: { type: 'order', id: '42' }, visibility: 'internal', ...over,++ });++}++const publish = (token, body) => request(h.base, 'POST', '/api/v1/events', { token, body });++const pull = (token, topic, after = 0) => request(h.base, 'GET', `/api/v1/events?topic=${encodeURIComponent(topic)}&after_seq=${after}`, { token });++++t('boot', async () => {++ await new Promise(r => receiver.listen(0, '127.0.0.1', r));++ receiverPort = receiver.address().port;++ h = await boot({++ appPost: testPost, dnsLookup: fakeLookup,++ env: { EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE: '50', EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS: '3' },++ });++});++++t('project_key and app source are defined exactly (and fit the envelope contract)', async () => {++ assert.strictEqual(apps.projectKey('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'p01jab2c3d4e5f6g7h8j9k0mnpq');++ assert.strictEqual(apps.appSource('app:app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'app-01jab2c3d4e5f6g7h8j9k0mnpq');++ assert.strictEqual(apps.projectKey('prj_bad'), null);++ assert.strictEqual(apps.appSource('svc:live'), null);++ assert.throws(() => load({ EVENTS_SOURCE_PREFIXES: JSON.stringify({ app: ['app.'] }) }), /reserved for developer apps/);++ assert.throws(() => load({ EVENTS_SOURCE_PREFIXES: JSON.stringify({ 'app-x1': ['app-x1.'] }) }), /reserved/);++});++++t('publish: an app publishes app.<project_key>.* only, as its own source and actor', async () => {++ let r = await publish(tokA(), appEvent(appA, prjA));++ assert.strictEqual(r.status, 201, r.text);++ const row = h.store.getEvent(r.body.event_id);++ assert.strictEqual(row.project_id, prjA);++ assert.strictEqual(row.env, 'sandbox');++ assert.strictEqual(row.publisher, `app:${appA}`);++++ r = await publish(tokA(), appEvent(appA, prjB));++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'events.type_not_allowed', 'never another project\'s types');++ r = await publish(tokA(), appEvent(appA, prjA, 'x', { event_type: 'live.stream.started' }));++ assert.strictEqual(r.body.code, 'events.type_not_allowed', 'never a first-party namespace');++ r = await publish(tokA(), appEvent(appA, prjA, 'x', { event_type: 'app.other.thing' }));++ assert.strictEqual(r.body.code, 'events.type_not_allowed');++ r = await publish(tokA(), appEvent(appA, prjA, 'x', { source: 'live' }));++ assert.strictEqual(r.body.code, 'events.source_mismatch', 'never a first-party source');++ r = await publish(tokA(), appEvent(appA, prjA, 'x', { source: apps.appSource(appB) }));++ assert.strictEqual(r.body.code, 'events.source_mismatch');++ r = await publish(tokA(), appEvent(appA, prjA, 'x', { actor: { type: 'service', id: 'live' } }));++ assert.strictEqual(r.body.code, 'events.actor_mismatch');++ const user = ids.newId('user');++ r = await publish(tokA({ onBehalfOf: user }), appEvent(appA, prjA, 'order.paid', { actor: { type: 'user', id: user } }));++ assert.strictEqual(r.status, 201, 'the user the token acts for may be the actor');++ r = await publish(appToken({ appId: appA, projectId: prjA, cap: ['events.app.read'] }), appEvent(appA, prjA));++ assert.strictEqual(r.status, 403, 'events.app.publish is required');++ // A first-party service can never publish into app.*: 'app' is not a source it can own.++ r = await publish(live, envelope('live', { event_type: `app.${keyA}.order.created` }));++ assert.strictEqual(r.status, 403);++});++++t('sandbox tokens: accepted on the app routes only', async () => {++ const admin = await request(h.base, 'GET', '/api/v1/deliveries', { token: appToken({ cap: ['events.delivery.admin'], env: 'sandbox' }) });++ assert.strictEqual(admin.status, 401); assert.strictEqual(admin.body.code, 'token.sandbox_refused');++ const prodAdmin = await request(h.base, 'GET', '/api/v1/deliveries', { token: appToken({ cap: ['events.delivery.admin'], env: 'production' }) });++ assert.strictEqual(prodAdmin.status, 403, 'apps never hold operator capabilities');++ const sbxService = serviceToken('live', ['events.event.publish']);++ const withEnv = require('openvibe-contracts').serviceAuth.signServiceToken({++ ...JSON.parse(Buffer.from(sbxService.split('.')[1], 'base64url')), env: 'sandbox',++ }, require('./helpers').privateKey);++ const r = await publish(withEnv, envelope('live'));++ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.sandbox_refused', 'a sandbox service token is refused');++ const rt = await request(h.base, 'GET', '/realtime/stream?topics=live.*', { token: tokA() });++ assert.strictEqual(rt.status, 401, 'realtime refuses sandbox app tokens');++});++++t('read: own project in the same environment, plus public first-party events', async () => {++ const pub = await publish(live, envelope('live', { event_type: 'live.stream.started', visibility: 'public' }));++ const internal = await publish(live, envelope('live', { event_type: 'live.stream.ended', visibility: 'internal' }));++ const prodA = await publish(tokA2(), appEvent(appA2, prjA, 'order.created'));++ assert.strictEqual(prodA.status, 201, prodA.text);++ const sbxB = await publish(tokB(), appEvent(appB, prjB, 'order.created'));++ assert.strictEqual(sbxB.status, 201);++++ let r = await pull(tokA(), `app.${keyA}.*`);++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.body.events.length >= 2 && r.body.events.every(e => e.event.event_type.startsWith(`app.${keyA}.`)));++ assert.ok(!r.body.events.some(e => e.event.event_id === prodA.body.event_id), 'a sandbox app never sees production events');++ r = await pull(tokA2(), `app.${keyA}.*`);++ assert.deepStrictEqual(r.body.events.map(e => e.event.event_id), [prodA.body.event_id], 'a production app never sees sandbox events');++++ r = await pull(tokA(), `app.${keyB}.*`);++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'events.topic_not_allowed');++ for (const bad of ['*', '*.created', 'app.*', `live.*,app.${keyB}.*`]) {++ r = await pull(tokA(), bad);++ assert.strictEqual(r.status, 403, bad);++ }++ r = await pull(tokA(), 'live.*');++ assert.deepStrictEqual(r.body.events.map(e => e.event.event_id), [pub.body.event_id], 'public first-party only, never internal');++++ r = await request(h.base, 'GET', `/api/v1/events/${sbxB.body.event_id}`, { token: tokA() });++ assert.strictEqual(r.status, 404, 'another project\'s event does not exist for this app');++ r = await request(h.base, 'GET', `/api/v1/events/${internal.body.event_id}`, { token: tokA() });++ assert.strictEqual(r.status, 404);++ r = await request(h.base, 'GET', `/api/v1/events/${pub.body.event_id}`, { token: tokA() });++ assert.strictEqual(r.status, 200);++++ // First-party readers: never sandbox; app events only through an app.* pattern.++ r = await pull(reader, '*');++ assert.ok(!r.body.events.some(e => e.event.event_type.startsWith('app.')), 'no app events through *');++ r = await pull(reader, 'app.*');++ assert.deepStrictEqual(r.body.events.map(e => e.event.event_id), [prodA.body.event_id], 'production app events only');++ r = await request(h.base, 'GET', `/api/v1/events/${sbxB.body.event_id}`, { token: reader });++ assert.strictEqual(r.status, 404, 'sandbox events are invisible to first-party readers');++++ // Checkpoints are per app and follow the same scope.++ r = await request(h.base, 'PUT', '/api/v1/checkpoints', { token: tokA(), body: { topic: `app.${keyA}.*`, cursor: 3 } });++ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.consumer, `app:${appA}`);++ r = await request(h.base, 'PUT', '/api/v1/checkpoints', { token: tokA(), body: { topic: `app.${keyB}.*`, cursor: 3 } });++ assert.strictEqual(r.status, 403);++ r = await request(h.base, 'GET', `/api/v1/checkpoints?topic=app.${keyA}.*`, { token: tokB() });++ assert.strictEqual(r.status, 403);++});++++let subA;++t('subscribe: scoped patterns, public https endpoints only', async () => {++ const sub = (token, body) => request(h.base, 'POST', '/api/v1/subscriptions', { token, body });++ for (const endpoint of ['http://hooks.example.com/x', 'https://127.0.0.1/x', 'https://[::1]/x', 'https://10.1.2.3/x', 'https://169.254.169.254/latest',++ 'https://evil.example.com/x', 'https://localhost/x', 'https://u:[email protected]/x', 'https://nowhere.example.com/x', 'https://intranet/x']) {++ const r = await sub(tokA(), { topic_pattern: `app.${keyA}.*`, endpoint });++ assert.strictEqual(r.status, 422, endpoint);++ assert.strictEqual(r.body.code, 'events.endpoint_not_allowed', endpoint);++ }++ let r = await sub(tokA(), { topic_pattern: `app.${keyB}.*`, endpoint: 'https://hooks.example.com/a' });++ assert.strictEqual(r.status, 403);++ r = await sub(tokA(), { topic_pattern: '*', endpoint: 'https://hooks.example.com/a' });++ assert.strictEqual(r.status, 403);++ r = await sub(appToken({ appId: appA, projectId: prjA, cap: ['events.app.read'] }), { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks.example.com/a' });++ assert.strictEqual(r.status, 403, 'events.app.subscribe is required');++++ r = await sub(tokA(), { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks.example.com/a' });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.consumer, `app:${appA}`);++ assert.strictEqual(r.body.project_id, prjA);++ assert.strictEqual(r.body.env, 'sandbox');++ subA = r.body;++ r = await sub(tokA(), { topic_pattern: 'live.*', endpoint: 'https://hooks.example.com/live' });++ assert.strictEqual(r.status, 201);++ const list = await request(h.base, 'GET', '/api/v1/subscriptions', { token: tokA() });++ assert.strictEqual(list.body.subscriptions.length, 2);++ assert.strictEqual((await request(h.base, 'GET', '/api/v1/subscriptions', { token: tokB() })).body.subscriptions.length, 0);++ assert.strictEqual((await request(h.base, 'GET', `/api/v1/subscriptions/${subA.id}`, { token: tokB() })).status, 404);++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/subscriptions/${subA.id}/disable`, { token: tokA2() })).status, 404, 'another app of the project cannot touch it');++ // A first-party consumer never sees app subscriptions either.++ const media = serviceToken('media', ['events.subscription.manage']);++ assert.strictEqual((await request(h.base, 'GET', `/api/v1/subscriptions/${subA.id}`, { token: media })).status, 404);++ // Project quota on subscriptions (sandbox: 3 in this test).++ r = await sub(tokA(), { topic_pattern: `app.${keyA}.order.*`, endpoint: 'https://hooks.example.com/b' });++ assert.strictEqual(r.status, 201);++ r = await sub(tokA(), { topic_pattern: `app.${keyA}.order.paid`, endpoint: 'https://hooks.example.com/c' });++ assert.strictEqual(r.status, 429); assert.strictEqual(r.body.code, 'events.quota_exceeded');++});++++t('delivery: signed, scoped, sandbox never meets production', async () => {++ // A production subscription of the same project, and one of another project.++ const prodSub = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tokA2(), body: { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks.example.com/prod' } });++ assert.strictEqual(prodSub.status, 201, prodSub.text);++ const bSub = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tokB(), body: { topic_pattern: `app.${keyB}.*`, endpoint: 'https://hooks.example.com/b-project' } });++ assert.strictEqual(bSub.status, 201);++ const firstParty = await subscriber();++ const svcSub = await request(h.base, 'POST', '/api/v1/subscriptions', { token: serviceToken('ops', ['events.subscription.manage']), body: { topic_pattern: 'app.*', endpoint: firstParty.url } });++ assert.strictEqual(svcSub.status, 201);++ seen.length = 0;++++ const e1 = await publish(tokA(), appEvent(appA, prjA, 'order.shipped'));++ const pub = await publish(live, envelope('live', { event_type: 'live.stream.started', visibility: 'public' }));++ await publish(live, envelope('live', { event_type: 'live.stream.ended', visibility: 'internal' }));++ await h.worker.drain();++++ const byPath = (p) => seen.filter(g => g.url === p).map(g => g.headers['x-openvibe-event-id']);++ assert.deepStrictEqual(byPath('/a'), [e1.body.event_id], 'own sandbox event reached the sandbox subscription');++ assert.deepStrictEqual(byPath('/b'), [e1.body.event_id], 'app.<key>.order.* matches order.shipped');++ assert.deepStrictEqual(byPath('/live'), [pub.body.event_id], 'public first-party events only, never internal');++ assert.deepStrictEqual(byPath('/prod'), [], 'a sandbox event never reaches a production subscription');++ assert.deepStrictEqual(byPath('/b-project'), [], 'never another project');++ assert.strictEqual(firstParty.calls.length, 0, 'first-party subscriptions never get sandbox events');++ const a = seen.find(g => g.url === '/a');++ assert.ok(verifyDelivery(a.rawBody, a.headers['x-openvibe-signature'], subA.secret), 'HMAC-signed with the subscription secret');++ assert.strictEqual(JSON.parse(a.rawBody).event.source, apps.appSource(appA));++ assert.ok(pinned.length && pinned.every(ip => ip === '127.0.0.1'), 'the socket connected to the address the guard checked');++++ // A production app event reaches the production subscription and first-party app.* subscribers, not sandbox ones.++ seen.length = 0;++ const e2 = await publish(tokA2(), appEvent(appA2, prjA, 'order.created'));++ await h.worker.drain();++ assert.deepStrictEqual(byPath('/prod'), [e2.body.event_id]);++ assert.deepStrictEqual(byPath('/a'), []);++ assert.deepStrictEqual(firstParty.calls.map(c => c.headers['x-openvibe-event-id']), [e2.body.event_id]);++ await firstParty.close();++});++++t('delivery-time SSRF guard: re-resolved on every attempt, redirects never followed', async () => {++ // Real guard (no test allowances): the socket's lookup refuses, so nothing is ever sent.++ seen.length = 0;++ const strict = createGuardedPost({ lookup: fakeLookup, requestImpl: (o, cb) => nodeHttp.request({ ...o, port: receiverPort }, cb) });++ await assert.rejects(strict('https://evil.example.com/x', { headers: {}, body: '{}' }), e => e.permanent === true && /non-public/.test(e.message));++ assert.strictEqual(seen.length, 0);++ await assert.rejects(strict('https://10.0.0.1/x', { headers: {}, body: '{}' }), e => e.permanent === true);++ await assert.rejects(strict('http://hooks.example.com/x', { headers: {}, body: '{}' }), e => e.permanent === true);++++ // DNS rebinding: public when subscribed, private when delivered.++ const r = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tokB(), body: { topic_pattern: `app.${keyB}.rebind.*`, endpoint: 'https://rebind.example.com/hook' } });++ assert.strictEqual(r.status, 201, r.text);++ const redir = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tokB(), body: { topic_pattern: `app.${keyB}.redirect.*`, endpoint: 'https://hooks.example.com/redirect' } });++ assert.strictEqual(redir.status, 201, redir.text);++ dnsTable.set('rebind.example.com', '10.0.0.9');++ seen.length = 0;++ const ev = await publish(tokB(), appEvent(appB, prjB, 'rebind.test'));++ const ev2 = await publish(tokB(), appEvent(appB, prjB, 'redirect.test'));++ await h.worker.drain();++ const d = h.store.getDelivery(ev.body.event_id, r.body.id);++ assert.strictEqual(d.status, 'dead', 'a private address at delivery time is refused for good');++ assert.match(d.last_error, /non-public/);++ const d2 = h.store.getDelivery(ev2.body.event_id, redir.body.id);++ assert.strictEqual(d2.status, 'failed'); assert.strictEqual(d2.last_status, 302);++ assert.deepStrictEqual(seen.map(s => s.url).filter(u => u !== '/b-project'), ['/redirect'], 'one request, the redirect was not followed, nothing reached the rebound host');++});++++t('quotas: per-project publish rate and retained bytes', async () => {++ const h2 = await boot({ appPost: testPost, dnsLookup: fakeLookup, env: { EVENTS_APP_PUBLISH_PER_MINUTE: '3', EVENTS_APP_SANDBOX_RETAINED_BYTES: '3000' } });++ try {++ const pub = (token, body) => request(h2.base, 'POST', '/api/v1/events', { token, body });++ for (let i = 0; i < 3; i++) assert.strictEqual((await pub(tokA2(), appEvent(appA2, prjA))).status, 201);++ let r = await pub(tokA2(), appEvent(appA2, prjA));++ assert.strictEqual(r.status, 429); assert.strictEqual(r.body.code, 'events.quota_exceeded');++ assert.strictEqual(r.body.quota, 'publish_rate'); assert.strictEqual(r.headers.get('retry-after'), '60');++ r = await pub(appToken({ projectId: prjB, env: 'production', cap: ALL, appId: appB }), appEvent(appB, prjB));++ assert.strictEqual(r.status, 201, 'another project has its own quota');++ h2.clock.advance(61 * 1000);++ assert.strictEqual((await pub(tokA2(), appEvent(appA2, prjA))).status, 201, 'the window moves on');++++ const big = { blob: 'x'.repeat(1200) };++ assert.strictEqual((await pub(tokA(), appEvent(appA, prjA, 'big.one', { payload: big }))).status, 201);++ assert.strictEqual((await pub(tokA(), appEvent(appA, prjA, 'big.two', { payload: big }))).status, 201);++ r = await pub(tokA(), appEvent(appA, prjA, 'big.three', { payload: big }));++ assert.strictEqual(r.status, 429); assert.strictEqual(r.body.quota, 'retained_bytes');++ r = await pub(tokA(), { events: [appEvent(appA, prjA, 'small.one')] });++ assert.strictEqual(r.status, 201, 'small events still fit');++ } finally {++ await h2.stop();++ }++});++++t('revocation from Network stops an app\'s subscriptions and old tokens', async () => {++ const network = serviceToken('network', ['events.event.publish']);++ const future = new Date(Date.now() + 2000).toISOString();++ let r = await publish(network, envelope('network', {++ event_type: 'network.grant.changed', actor: { type: 'system', id: 'network' }, timestamp: future,++ subject: { type: 'app', id: appB }, payload: { project_id: prjB, capability: 'events.app.subscribe', audience: 'openvibe.events', from: 'approved', to: 'revoked' },++ }));++ assert.strictEqual(r.status, 201, r.text);++ const subsB = (await request(h.base, 'GET', '/api/v1/subscriptions', { token: tokB() })).body.subscriptions;++ assert.ok(subsB.length && subsB.every(s => !s.enabled), 'every subscription of the app is disabled');++ r = await request(h.base, 'POST', `/api/v1/subscriptions/${subsB[0].id}/enable`, { token: tokB() });++ assert.strictEqual(r.status, 403, 'a token issued before the revocation cannot re-enable');++ r = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tokB(), body: { topic_pattern: `app.${keyB}.again`, endpoint: 'https://hooks.example.com/again' } });++ assert.strictEqual(r.status, 403, '...nor create one');++ assert.strictEqual((await publish(tokB(), appEvent(appB, prjB))).status, 201, 'publishing is a different grant');++ const later = tokB({ iat: Math.floor(Date.now() / 1000) + 10 });++ r = await request(h.base, 'POST', `/api/v1/subscriptions/${subsB[0].id}/enable`, { token: later });++ assert.strictEqual(r.status, 200, 'a token issued after a re-approval may');++++ r = await publish(network, envelope('network', {++ event_type: 'network.app.revoked', actor: { type: 'system', id: 'network' }, timestamp: future,++ subject: { type: 'app', id: appA }, payload: { project_id: prjA, environment: 'sandbox', reason: 'test' },++ }));++ assert.strictEqual(r.status, 201, r.text);++ assert.ok(h.store.listSubscriptions(`app:${appA}`).every(s => !s.enabled));++ r = await pull(tokA(), `app.${keyA}.*`);++ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.revoked');++ r = await publish(tokA(), appEvent(appA, prjA));++ assert.strictEqual(r.status, 401);++ // Only svc:network speaks for Network, and the hook ignores app-published look-alikes.++ assert.strictEqual((await pull(tokA2(), `app.${keyA}.*`)).status, 200, 'other apps of the project are untouched');++});++++t('realtime never streams app events, to anyone', async () => {++ const { sse } = require('./helpers');++ const svc = await sse(h.base, '/realtime/stream?topics=app.*,live.*', { headers: { Authorization: `Bearer ${serviceToken('ops', ['events.event.read'])}` } });++ const anon = await sse(h.base, `/realtime/stream?topics=app.${keyA}.*,live.*`);++ await publish(tokA2(), appEvent(appA2, prjA, 'order.realtime', { visibility: 'public' }));++ const marker = await publish(live, envelope('live', { event_type: 'live.stream.started', visibility: 'public' }));++ await svc.waitFor(c => c.events().some(e => e.event.event_id === marker.body.event_id));++ await anon.waitFor(c => c.events().some(e => e.event.event_id === marker.body.event_id));++ assert.ok(!svc.events().some(e => e.event.event_type.startsWith('app.')));++ assert.ok(!anon.events().some(e => e.event.event_type.startsWith('app.')));++ svc.close(); anon.close();++});++++t('stop', async () => {++ await h.stop();++ await new Promise(r => receiver.close(r));++ await sleep(10);++});++++t.run();@@@ -29,6 +29,16 @@ function serviceToken(slug, cap, { aud = 'openvibe.events', exp = Math.floor(Dat}, key);}++/** A developer-app token as Network mints it (ADR-014): sub app:app_…, project_id, env, ns [project_id]. */++function appToken({ appId = ids.newId('app'), projectId = `prj_${ids.ulid()}`, env = 'sandbox', cap = [], onBehalfOf, iat, exp, key = privateKey } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({++ iss: ISSUER, sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.events'], cap, ns: [projectId], project_id: projectId, env,++ ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),++ iat: iat ?? now, exp: exp ?? now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,++ }, key);++}++function userToken({ subjectId = ids.newId('user'), aud = ['openvibe.live', 'openvibe.network'], exp = Math.floor(Date.now() / 1000) + 3600, key = privateKey } = {}) {return serviceAuth.signServiceToken({sub: 57, id: 57, subject_id: subjectId, username: 'viewer', role: 'user', iss: ISSUER, aud, iat: Math.floor(Date.now() / 1000), exp,@@@ -50,7 +60,7 @@ function tmpDir() {}/** Boot the service. env overrides go through config.load(); `worker: 'manual'` keeps the loop off. */--async function boot({ env = {}, clock = manualClock(), worker = 'manual', fetchImpl, deliveryFetch } = {}) {++async function boot({ env = {}, clock = manualClock(), worker = 'manual', fetchImpl, deliveryFetch, appPost, dnsLookup } = {}) {const dir = tmpDir();const config = load({NODE_ENV: 'test',@@@ -60,7 +70,7 @@ async function boot({ env = {}, clock = manualClock(), worker = 'manual', fetchIEVENTS_WORKER: worker === 'manual' ? 'off' : 'on',...env,});-- const h = await start({ config, clock, log: silent, fetchImpl, deliveryFetch });++ const h = await start({ config, clock, log: silent, fetchImpl, deliveryFetch, appPost, dnsLookup });const base = `http://127.0.0.1:${h.server.address().port}`;return {...h, base, clock, dir,@@@ -189,6 +199,6 @@ function suite(name) {}module.exports = {-- ISSUER, privateKey, publicKey, silent, serviceToken, userToken, manualClock, tmpDir, boot, request,++ ISSUER, privateKey, publicKey, silent, serviceToken, appToken, userToken, manualClock, tmpDir, boot, request,envelope, subscriber, sse, sleep, suite,};@@@ -1,7 +1,7 @@'use strict';const assert = require('assert');const { ids } = require('openvibe-contracts');--const { boot, request, serviceToken, envelope, suite } = require('./helpers');++const { boot, request, serviceToken, appToken, envelope, suite } = require('./helpers');const t = suite('publish');let h;@@@ -78,11 +78,18 @@ t('unknown source (not in the manifest map) -> 403', async () => {assert.strictEqual(r.body.code, 'events.unknown_source');});--t('app principals cannot publish', async () => {-- const r = await request(h.base, 'POST', '/api/v1/events', {-- token: serviceToken('x', ['events.event.publish'], { sub: `app:${ids.newId('app')}` }), body: envelope('live'),++t('app principals cannot publish as a service', async () => {++ // A first-party capability in an app token is never honoured (apps publish with events.app.publish).++ let r = await request(h.base, 'POST', '/api/v1/events', {++ token: appToken({ cap: ['events.event.publish'], env: 'production' }), body: envelope('live'),});assert.strictEqual(r.status, 403);++ // An app token without a project is not a usable app token at all.++ r = await request(h.base, 'POST', '/api/v1/events', {++ token: serviceToken('x', ['events.event.publish'], { sub: `app:${ids.newId('app')}` }), body: envelope('live'),++ });++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.body.code, 'token.invalid_claims');});t('idempotent repeat: same event_id -> 200, same seq, stored once', async () => {
@@ -37,7 +37,8 @@ EVENTS_RECEIPT_RETENTION_DAYS=90
@@ -37,7 +37,8 @@ EVENTS_RECEIPT_RETENTION_DAYS=90
#EVENTS_MAX_HOPS=8
#EVENTS_MAX_HOPS=8
# Which source may publish which event_type prefixes. JSON; replaces the default, which is
# Which source may publish which event_type prefixes. JSON; replaces the default, which is
# { "<source>": ["<source>."] } for live media network community chat openre billing tips vip
# { "<source>": ["<source>."] } for live media network community chat openre billing tips vip
-# ai games tools codes host. Every prefix must start with "<source>.".
+# ai games tools codes host sources search wiki blog news reviews deals coupons trade. Every prefix
+# must start with "<source>."; "app" and "app-*" are reserved for developer apps.
#EVENTS_SOURCE_PREFIXES={"live":["live."],"media":["media."]}
#EVENTS_SOURCE_PREFIXES={"live":["live."],"media":["media."]}
# Delivery worker
# Delivery worker
@@ -54,6 +55,18 @@ EVENTS_RECEIPT_RETENTION_DAYS=90
@@ -54,6 +55,18 @@ EVENTS_RECEIPT_RETENTION_DAYS=90
# and its subdomains; IP literals only when listed exactly.
# and its subdomains; IP literals only when listed exactly.
#EVENTS_ENDPOINT_HOSTS=127.0.0.1,*.openvibe.*
#EVENTS_ENDPOINT_HOSTS=127.0.0.1,*.openvibe.*
+# Developer apps (events.app.publish|read|subscribe, ADR-014). App subscriptions must use public
+# https endpoints (no allow-list; checked at subscribe and delivery time). Quotas are per project
+# and environment; 0 turns a limit off. EVENTS_APPS=off disables the app paths.
+#EVENTS_APPS=on
+#EVENTS_APP_PUBLISH_PER_MINUTE=120
+#EVENTS_APP_RETAINED_BYTES=52428800
+#EVENTS_APP_MAX_SUBSCRIPTIONS=20
+#EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE=30
+#EVENTS_APP_SANDBOX_RETAINED_BYTES=5242880
+#EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS=5
+#EVENTS_APP_SANDBOX_RETENTION_DAYS=7
+
# Realtime (SSE) gateway
# Realtime (SSE) gateway
#REALTIME_MAX_CONNECTIONS=2000
#REALTIME_MAX_CONNECTIONS=2000
#REALTIME_MAX_TOPICS=20
#REALTIME_MAX_TOPICS=20
@@ -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 `/var/lib/openvibe-events/events.db`, nginx [deploy/nginx/events.openvibe.network.conf](deploy/nginx/events.openvibe.network.conf) (public vhost exposes `/realtime/stream` and health only; 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 `/var/lib/openvibe-events/events.db`, 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`).
`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`.
@@ -37,7 +37,9 @@ Services call with an OpenVibe.Network client-credentials token (`POST /oauth/to
@@ -37,7 +37,9 @@ Services call with an OpenVibe.Network client-credentials token (`POST /oauth/to
| `events.event.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints`, realtime as a service |
| `events.event.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints`, realtime as a service |
| `events.delivery.admin` | `GET /api/v1/deliveries`, `POST /api/v1/deliveries/replay` |
| `events.delivery.admin` | `GET /api/v1/deliveries`, `POST /api/v1/deliveries/replay` |
-These ids are not in `openvibe-contracts` yet; their manifests are proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) (with the service manifest). Until the contracts release defines them, `server/auth.js` grants them with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` as soon as contracts know the id.
+These four are `internal` in `openvibe-contracts` (never granted to developer apps). Developer apps use the three `public` capabilities in [Developer apps](#developer-apps) instead, on the same routes. `server/auth.js` grants with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` for every id the installed contracts know (v0.28.0 knows all seven).
+
+Sandbox tokens (`env: sandbox`, developer apps only) are accepted only on the developer-app routes; every other route answers `401 token.sandbox_refused`. App tokens are never judged on a first-party capability: an app token on an operator route is a `403`.
Errors are RFC 9457 problem+json (`openvibe-contracts` `http.problem`) with a stable `code`.
Errors are RFC 9457 problem+json (`openvibe-contracts` `http.problem`) with a stable `code`.
@@ -67,6 +69,51 @@ Operators: `GET /api/v1/deliveries?status=dead` is the dead-letter queue; `POST
@@ -67,6 +69,51 @@ Operators: `GET /api/v1/deliveries?status=dead` is the dead-letter queue; `POST
Pull consumers: `GET /api/v1/events?topic=media.vod.*&after_seq=<cursor>&limit=100` returns `{ events: [{ seq, event }], next_after_seq, latest_seq }`, plus `gap: { from_seq, to_seq }` when the cursor is older than retention. `PUT /api/v1/checkpoints { topic, cursor }` stores a consumer's cursor here if it has nowhere better.
Pull consumers: `GET /api/v1/events?topic=media.vod.*&after_seq=<cursor>&limit=100` returns `{ events: [{ seq, event }], next_after_seq, latest_seq }`, plus `gap: { from_seq, to_seq }` when the cursor is older than retention. `PUT /api/v1/checkpoints { topic, cursor }` stores a consumer's cursor here if it has nowhere better.
+## Developer apps
+
+Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`POST /oauth/token`, `grant_type=client_credentials`, `audience=openvibe.events`) with `sub: app:app_<ULID>`, `project_id: prj_<ULID>`, `env: sandbox|production`. It uses the same routes as services, with these capabilities (all `public`, openvibe-contracts ≥ 0.28.0):
+
+| Capability | Routes | Scope |
+|---|---|---|
+| `events.app.publish` | `POST /api/v1/events` | event types `app.<project_key>.<name>[.<more>]` only |
+| `events.app.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints` | own project's events in the token's env, plus first-party `public` events |
+| `events.app.subscribe` | `/api/v1/subscriptions…` (the app's own) | same as read; public https endpoints only |
+
+**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
+PK=p$(echo "${PRJ#prj_}" | tr 'A-Z' 'a-z') # project_key
+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 '{
+ "event_id": "evt_01JAB2C3D4E5F6G7H8J9K0MNPQ", "event_type": "app.'$PK'.order.created", "version": 1,
+ "source": "'$SRC'", "actor": { "type": "app", "id": "'$APP'" }, "timestamp": "2026-09-23T12:00:00Z",
+ "subject": { "type": "order", "id": "42" }, "payload": { "total": 3 } }'
+curl -s "$EVENTS/api/v1/events?topic=app.$PK.*&after_seq=0" -H "Authorization: Bearer $TOKEN"
+curl -s -X POST "$EVENTS/api/v1/subscriptions" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
+ -d '{ "topic_pattern": "app.'$PK'.*", "endpoint": "https://hooks.example.com/openvibe" }' # → secret, shown once
+```
+
+**Topic scope.** Every app pattern must start with a literal segment; an `app.*` pattern must name the app's own `project_key` (`app.<project_key>.*`); `*`, `*.created`, `app.*` and another project's key are `403 events.topic_not_allowed`. A first-party pattern (`live.*`) is allowed and yields only that namespace's `public` events.
+
+**Sandbox.** Events store each app event's `project_id` and `env`. Apps see and receive only events of their own environment: sandbox events never reach production apps or production subscriptions, and production events never reach sandbox ones. First-party readers and subscribers never see sandbox events, and see production app events only through a pattern that starts with `app.`. Realtime (SSE) never streams app events. Sandbox events are pruned after `EVENTS_APP_SANDBOX_RETENTION_DAYS` (7).
+
+**Webhook endpoints (SSRF guard, [server/egress.js](server/egress.js)).** An app subscription's endpoint must be `https`, without credentials, and its hostname must resolve only to public unicast addresses (loopback, RFC 1918, link-local and cloud metadata, CGNAT, multicast, documentation and benchmarking ranges, unique-local IPv6, and v4-in-v6 forms of any of those are refused). This is checked when the subscription is created and again on every delivery attempt, inside the connection's own DNS lookup, so the socket goes to the address that was checked. A refused address is a permanent failure (dead at once; replayable). Redirects are never followed (a `3xx` is a failed attempt). Deliveries are signed exactly like first-party ones (`X-OpenVibe-Signature`).
+
+**Quotas** (per project and environment, enforced here, `429 events.quota_exceeded` with `quota` = `publish_rate` (plus `Retry-After: 60`), `retained_bytes` or `subscriptions`):
+
+| | production | sandbox |
+|---|---|---|
+| events per minute | `EVENTS_APP_PUBLISH_PER_MINUTE` = 120 | `EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE` = 30 |
+| retained bytes (payload + actor + type + subject id of stored events) | `EVENTS_APP_RETAINED_BYTES` = 50 MiB | `EVENTS_APP_SANDBOX_RETAINED_BYTES` = 5 MiB |
+| subscriptions | `EVENTS_APP_MAX_SUBSCRIPTIONS` = 20 | `EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS` = 5 |
+
+Quotas recorded in Network (`dev_quotas`) are not read yet: that needs `network.project.read`, which is still planned. Until then these defaults apply to every project.
+
+**Revocation.** When Network's event relay delivers `network.app.revoked` (sent for every app of an archived project too), Events disables that app's subscriptions in the same transaction and refuses its tokens issued before the revocation (`401 token.revoked`). `network.grant.changed` that withdraws `events.app.subscribe` disables the app's subscriptions and refuses creating or re-enabling one with a token issued before the change. Without the relay, revoked apps' tokens still end within their 5-minute lifetime, but existing subscriptions keep delivering.
+
+`EVENTS_APPS=off` turns the developer-app paths off (app tokens are then judged like any other token and refused).
+
## Client library
## Client library
`require('openvibe-events')` (package `main` is [lib/client.js](lib/client.js); services can depend on this repo by tarball):
`require('openvibe-events')` (package `main` is [lib/client.js](lib/client.js); services can depend on this repo by tarball):
@@ -111,7 +158,8 @@ es.addEventListener('gap', (m) => { /* events were missed: refetch state */ });
@@ -111,7 +158,8 @@ es.addEventListener('gap', (m) => { /* events were missed: refetch state */ });
## Owns
## Owns
-- `events`, `subscriptions`, `deliveries`, `consumer_checkpoints`, `idempotency_receipts` (SQLite today; the plan's PostgreSQL + Redis fanout is a later step)
+- `events`, `subscriptions`, `deliveries`, `consumer_checkpoints`, `idempotency_receipts`, `app_revocations` (SQLite today; the plan's PostgreSQL + Redis fanout is a later step)
+- developer-app event scope, sandbox separation and per-project Events quotas (ADR-014)
- canonical event envelope (event_id, trace_id, type, version, source, actor, subject + revision, payload)
- canonical event envelope (event_id, trace_id, type, version, source, actor, subject + revision, payload)
- priority classes `critical|important|low`, loop guards, backpressure, DLQ and replay
- priority classes `critical|important|low`, loop guards, backpressure, DLQ and replay
- Realtime: SSE topics, `Last-Event-ID`/cursor resume, gap detection (WS and presence are not built yet)
- Realtime: SSE topics, `Last-Event-ID`/cursor resume, gap detection (WS and presence are not built yet)
@@ -132,6 +180,7 @@ es.addEventListener('gap', (m) => { /* events were missed: refetch state */ });
@@ -132,6 +180,7 @@ es.addEventListener('gap', (m) => { /* events were missed: refetch state */ });
- kill a consumer mid-processing; replay creates exactly one effect (`test/outbox-inbox.test.js`)
- kill a consumer mid-processing; replay creates exactly one effect (`test/outbox-inbox.test.js`)
- browser reconnect resumes from a cursor or reports a gap (`test/realtime.test.js`)
- browser reconnect resumes from a cursor or reports a gap (`test/realtime.test.js`)
- a guessed private topic yields no data (`test/realtime.test.js`)
- a guessed private topic yields no data (`test/realtime.test.js`)
+- a developer app cannot publish, read or subscribe outside its project, sandbox never meets production, app webhooks reach public addresses only, quotas hold (`test/apps.test.js`)
## Bootstrap / extraction source
## Bootstrap / extraction source
@@ -7,9 +7,17 @@
@@ -7,9 +7,17 @@
"runtime": "node 22, express, better-sqlite3",
"runtime": "node 22, express, better-sqlite3",
"port": 4300,
"port": 4300,
"domain": "events.openvibe.network",
"domain": "events.openvibe.network",
- "contracts": "openvibe-contracts v0.5.1",
- "capabilities": ["events.event.publish", "events.subscription.manage", "events.event.read", "events.delivery.admin"],
- "capabilitiesRegistered": false,
+ "contracts": "openvibe-contracts v0.28.0",
+ "capabilities": [
+ "events.event.publish",
+ "events.subscription.manage",
+ "events.event.read",
+ "events.delivery.admin",
+ "events.app.publish",
+ "events.app.read",
+ "events.app.subscribe"
+ ],
+ "capabilitiesRegistered": true,
"deployed": false,
"deployed": false,
- "updated": "2026-09-22"
+ "updated": "2026-09-23"
}
}
@@ -8,13 +8,16 @@
@@ -8,13 +8,16 @@
## /realtime/stream browser Server-Sent Events (long-lived; must not be buffered)
## /realtime/stream browser Server-Sent Events (long-lived; must not be buffered)
## /api/health, /api/ready, /release.json
## /api/health, /api/ready, /release.json
## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
-## /api/v1/* service-to-service API. Services on this host call 127.0.0.1:4300
-## directly, so the public vhost refuses it; open the location below only
-## when a service on another host needs it (it still requires a token).
+## /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.
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=ovevents_connect:10m rate=2r/s;
limit_req_zone $binary_remote_addr zone=ovevents_connect:10m rate=2r/s;
limit_conn_zone $binary_remote_addr zone=ovevents_streams:10m;
limit_conn_zone $binary_remote_addr zone=ovevents_streams:10m;
+limit_req_zone $binary_remote_addr zone=ovevents_api:10m rate=20r/s;
# ── HTTP → HTTPS redirect ──────────────────────────────────
# ── HTTP → HTTPS redirect ──────────────────────────────────
server {
server {
@@ -78,7 +81,20 @@ server {
@@ -78,7 +81,20 @@ server {
# 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; }
- # ── Service API: host-local only (see header) ───────────
+ # ── 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_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 $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # ── Everything else under /api/: host-local only (see header) ──
location /api/ {
location /api/ {
return 403;
return 403;
# To expose it (token-guarded) to off-host services instead:
# To expose it (token-guarded) to off-host services instead:
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
"engines": {
"engines": {
@@ -967,9 +967,9 @@
@@ -967,9 +967,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.6.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0",
- "integrity": "sha512-bRN/XG+mlvg0lnOc+9kVa5HgxBWTXy/xRfDn8Gnz5k41FT73zg6i463Le7yWkcLKhq6HI3NKlpBf3C/NRpSX9g==",
+ "version": "0.28.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
+ "integrity": "sha512-cOOEmIhcjDmnWbo4jsz6kYOKT+Kc+aitYoc4w/EJOKg5uvJDt10vctjKB3IIl53UXeuDOhbckP53yBdBeewC1A==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
},
"engines": {
"engines": {
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
'use strict';
'use strict';
/**
/**
- * POST /api/v1/events (service token, audience openvibe.events, capability events.event.publish)
+ * POST /api/v1/events (service token, audience openvibe.events, capability events.event.publish;
+ * or a developer app token with events.app.publish)
*
*
* body: <envelope> -> 201 { event_id, seq, duplicate: false } | 200 on a repeat
* body: <envelope> -> 201 { event_id, seq, duplicate: false } | 200 on a repeat
* body: { events: [...] } -> 201 { results: [{ event_id, seq, duplicate }] } (<= 100, atomic)
* body: { events: [...] } -> 201 { results: [{ event_id, seq, duplicate }] } (<= 100, atomic)
@@ -8,6 +9,11 @@
@@ -8,6 +9,11 @@
* Every envelope must validate against events.event-envelope@1; `source` must be the calling
* Every envelope must validate against events.event-envelope@1; `source` must be the calling
* service (svc:live -> 'live'); `event_type` must start with a prefix that source owns. A
* service (svc:live -> 'live'); `event_type` must start with a prefix that source owns. A
* re-published event_id is answered with the stored seq and never stored twice.
* re-published event_id is answered with the stored seq and never stored twice.
+ *
+ * Developer apps (ADR-014, server/apps.js): `source` must be the app's `app-<lowercased ULID>`,
+ * `event_type` must start with `app.<project_key>.`, and `actor` must be the app itself or the user
+ * the token acts for. The event is stored with the token's project_id and env, under the project's
+ * publish-rate and retained-bytes quotas (429 events.quota_exceeded).
*/
*/
const express = require('express');
const express = require('express');
const { validate, http } = require('openvibe-contracts');
const { validate, http } = require('openvibe-contracts');
@@ -37,6 +43,7 @@ function publishRouter({ config, store, auth, worker, realtime }) {
@@ -37,6 +43,7 @@ function publishRouter({ config, store, auth, worker, realtime }) {
if (Buffer.byteLength(JSON.stringify(env.payload)) > config.maxPayloadBytes) {
if (Buffer.byteLength(JSON.stringify(env.payload)) > config.maxPayloadBytes) {
return { status: 413, code: 'events.payload_too_large', detail: `payload is larger than ${config.maxPayloadBytes} bytes` };
return { status: 413, code: 'events.payload_too_large', detail: `payload is larger than ${config.maxPayloadBytes} bytes` };
}
}
+ if (principal.kind === 'app') return checkApp(env, principal);
if (env.source !== principal.service) {
if (env.source !== principal.service) {
return { status: 403, code: 'events.source_mismatch', detail: `source "${env.source}" is not the calling service "${principal.service}"` };
return { status: 403, code: 'events.source_mismatch', detail: `source "${env.source}" is not the calling service "${principal.service}"` };
}
}
@@ -48,7 +55,22 @@ function publishRouter({ config, store, auth, worker, realtime }) {
@@ -48,7 +55,22 @@ function publishRouter({ config, store, auth, worker, realtime }) {
return { env };
return { env };
}
}
- router.post('/api/v1/events', auth.requireCap(CAPS.publish, { requireService: true }), (req, res) => {
+ function checkApp(env, app) {
+ if (env.source !== app.source) {
+ return { status: 403, code: 'events.source_mismatch', detail: `source must be your app's "${app.source}", not "${env.source}"` };
+ }
+ if (!env.event_type.startsWith(app.prefix)) {
+ return { status: 403, code: 'events.type_not_allowed', detail: `an app may publish ${app.prefix}<name> only, not ${env.event_type}` };
+ }
+ const a = env.actor || {};
+ const actorOk = (a.type === 'app' && a.id === app.appId) || (app.onBehalfOf && a.type === 'user' && a.id === app.onBehalfOf);
+ if (!actorOk) {
+ return { status: 403, code: 'events.actor_mismatch', detail: `actor must be { type: 'app', id: '${app.appId}' }${app.onBehalfOf ? ' or the user the token acts for' : ''}` };
+ }
+ return { env };
+ }
+
+ router.post('/api/v1/events', auth.appOrService(CAPS.publish, CAPS.appPublish, { requireService: true }), (req, res) => {
const ctx = req.ov;
const ctx = req.ov;
const body = req.body;
const body = req.body;
const isBatch = body && typeof body === 'object' && !Array.isArray(body) && Array.isArray(body.events) && body.event_id === undefined;
const isBatch = body && typeof body === 'object' && !Array.isArray(body) && Array.isArray(body.events) && body.event_id === undefined;
@@ -76,9 +98,14 @@ function publishRouter({ config, store, auth, worker, realtime }) {
@@ -76,9 +98,14 @@ function publishRouter({ config, store, auth, worker, realtime }) {
let out;
let out;
try {
try {
- out = store.insertBatch(envelopes, { publisher: req.principal.sub, requestId: ctx.requestId });
+ const p = req.principal;
+ const project = p.kind === 'app' ? { projectId: p.projectId, env: p.env, ...config.apps.quotas[p.env] } : null;
+ out = store.insertBatch(envelopes, { publisher: p.sub, requestId: ctx.requestId, project });
} catch (err) {
} catch (err) {
- if (err instanceof StoreError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx, extra: err.extra });
+ if (err instanceof StoreError) {
+ if (err.status === 429 && err.extra && err.extra.retry_after) res.setHeader('Retry-After', String(err.extra.retry_after));
+ return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx, extra: err.extra });
+ }
throw err;
throw err;
}
}
if (out.inserted.length) {
if (out.inserted.length) {
@@ -10,6 +10,12 @@
@@ -10,6 +10,12 @@
* GET /api/v1/checkpoints?topic=… / PUT /api/v1/checkpoints { topic, cursor }
* GET /api/v1/checkpoints?topic=… / PUT /api/v1/checkpoints { topic, cursor }
* a consumer's own stored cursor per topic pattern (consumer = calling principal)
* a consumer's own stored cursor per topic pattern (consumer = calling principal)
*
*
+ * Developer apps (events.app.read) use the same routes with an app token: only their project's
+ * events in the token's environment plus public first-party events (server/apps.js); every topic
+ * pattern must start with a literal segment, and app.* patterns must name the app's project_key.
+ * Checkpoints are per app. First-party readers never see sandbox events, and see app events only
+ * through patterns that start with `app.`.
+ *
* Operators (events.delivery.admin):
* Operators (events.delivery.admin):
*
*
* GET /api/v1/deliveries?status=dead&subscription_id=&after_seq=&limit=
* GET /api/v1/deliveries?status=dead&subscription_id=&after_seq=&limit=
@@ -20,6 +26,23 @@ const { http } = require('openvibe-contracts');
@@ -20,6 +26,23 @@ const { http } = require('openvibe-contracts');
const topics = require('../topics');
const topics = require('../topics');
const { rowToEnvelope } = require('../store');
const { rowToEnvelope } = require('../store');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
+const apps = require('../apps');
+
+/** Why an app may not use these patterns (null for services and for allowed patterns). */
+function scopeError(principal, patterns) {
+ if (principal.kind !== 'app') return null;
+ for (const p of patterns) {
+ const err = apps.patternScopeError(p, principal);
+ if (err) return `${p}: ${err}`;
+ }
+ return null;
+}
+
+/** Row filter for a reader: an app's scope, or a first-party reader's (no sandbox; app events via app.*). */
+function acceptFor(principal, patterns) {
+ if (principal.kind === 'app') return (row) => apps.visibleToApp(row, principal);
+ return (row) => patterns.some(p => apps.serviceMatches(p, row));
+}
const intParam = (v, d, min, max) => {
const intParam = (v, d, min, max) => {
if (v === undefined || v === '') return d;
if (v === undefined || v === '') return d;
@@ -29,7 +52,7 @@ const intParam = (v, d, min, max) => {
@@ -29,7 +52,7 @@ const intParam = (v, d, min, max) => {
function readRouter({ store, auth, worker }) {
function readRouter({ store, auth, worker }) {
const router = express.Router();
const router = express.Router();
- const canRead = auth.requireCap(CAPS.read);
+ const canRead = auth.appOrService(CAPS.read, CAPS.appRead);
const isAdmin = auth.requireCap(CAPS.admin);
const isAdmin = auth.requireCap(CAPS.admin);
router.get('/api/v1/events', canRead, (req, res) => {
router.get('/api/v1/events', canRead, (req, res) => {
@@ -38,6 +61,8 @@ function readRouter({ store, auth, worker }) {
@@ -38,6 +61,8 @@ function readRouter({ store, auth, worker }) {
if (!patterns.length || patterns.length > 20 || !patterns.every(topics.isValidPattern)) {
if (!patterns.length || patterns.length > 20 || !patterns.every(topics.isValidPattern)) {
return http.sendProblem(res, 400, 'events.bad_topic', { detail: 'topic must be 1..20 comma-separated patterns', ctx });
return http.sendProblem(res, 400, 'events.bad_topic', { detail: 'topic must be 1..20 comma-separated patterns', ctx });
}
}
+ const scopeErr = scopeError(req.principal, patterns);
+ if (scopeErr) return http.sendProblem(res, 403, 'events.topic_not_allowed', { detail: scopeErr, ctx });
const after = intParam(req.query.after_seq, 0, 0, Number.MAX_SAFE_INTEGER);
const after = intParam(req.query.after_seq, 0, 0, Number.MAX_SAFE_INTEGER);
const limit = intParam(req.query.limit, 100, 1, 1000);
const limit = intParam(req.query.limit, 100, 1, 1000);
if (Number.isNaN(after) || Number.isNaN(limit)) {
if (Number.isNaN(after) || Number.isNaN(limit)) {
@@ -50,7 +75,7 @@ function readRouter({ store, auth, worker }) {
@@ -50,7 +75,7 @@ function readRouter({ store, auth, worker }) {
out.gap = { from_seq: after + 1, to_seq: oldest - 1 };
out.gap = { from_seq: after + 1, to_seq: oldest - 1 };
from = oldest - 1;
from = oldest - 1;
}
}
- const { rows, cursor } = store.scan(from, { patterns, limit });
+ const { rows, cursor } = store.scan(from, { patterns, limit, accept: acceptFor(req.principal, patterns) });
out.events = rows.map(r => ({ seq: r.seq, event: rowToEnvelope(r) }));
out.events = rows.map(r => ({ seq: r.seq, event: rowToEnvelope(r) }));
out.next_after_seq = cursor;
out.next_after_seq = cursor;
out.latest_seq = store.lastSeq();
out.latest_seq = store.lastSeq();
@@ -59,7 +84,8 @@ function readRouter({ store, auth, worker }) {
@@ -59,7 +84,8 @@ function readRouter({ store, auth, worker }) {
router.get('/api/v1/events/:id', canRead, (req, res) => {
router.get('/api/v1/events/:id', canRead, (req, res) => {
const row = store.getEvent(String(req.params.id));
const row = store.getEvent(String(req.params.id));
- if (!row) return http.sendProblem(res, 404, 'events.not_found', { detail: 'no such event (or pruned by retention)', ctx: req.ov });
+ const visible = row && (req.principal.kind === 'app' ? apps.visibleToApp(row, req.principal) : (row.env || 'production') === 'production');
+ if (!visible) return http.sendProblem(res, 404, 'events.not_found', { detail: 'no such event (or pruned by retention)', ctx: req.ov });
return res.json({ seq: row.seq, event: rowToEnvelope(row) });
return res.json({ seq: row.seq, event: rowToEnvelope(row) });
});
});
@@ -68,6 +94,8 @@ function readRouter({ store, auth, worker }) {
@@ -68,6 +94,8 @@ function readRouter({ store, auth, worker }) {
router.get('/api/v1/checkpoints', canRead, (req, res) => {
router.get('/api/v1/checkpoints', canRead, (req, res) => {
const topic = String(req.query.topic || '');
const topic = String(req.query.topic || '');
if (!topics.isValidPattern(topic)) return http.sendProblem(res, 400, 'events.bad_topic', { detail: 'topic is required', ctx: req.ov });
if (!topics.isValidPattern(topic)) return http.sendProblem(res, 400, 'events.bad_topic', { detail: 'topic is required', ctx: req.ov });
+ const scopeErr = scopeError(req.principal, [topic]);
+ if (scopeErr) return http.sendProblem(res, 403, 'events.topic_not_allowed', { detail: scopeErr, ctx: req.ov });
const cp = store.getCheckpoint(consumerOf(req), topic);
const cp = store.getCheckpoint(consumerOf(req), topic);
res.json({ consumer: consumerOf(req), topic, cursor: cp ? cp.cursor : 0, updated_at: cp ? cp.updated_at : null });
res.json({ consumer: consumerOf(req), topic, cursor: cp ? cp.cursor : 0, updated_at: cp ? cp.updated_at : null });
});
});
@@ -77,6 +105,8 @@ function readRouter({ store, auth, worker }) {
@@ -77,6 +105,8 @@ function readRouter({ store, auth, worker }) {
if (!topics.isValidPattern(b.topic) || !Number.isInteger(b.cursor) || b.cursor < 0) {
if (!topics.isValidPattern(b.topic) || !Number.isInteger(b.cursor) || b.cursor < 0) {
return http.sendProblem(res, 400, 'events.bad_request', { detail: 'topic (pattern) and cursor (integer >= 0) are required', ctx: req.ov });
return http.sendProblem(res, 400, 'events.bad_request', { detail: 'topic (pattern) and cursor (integer >= 0) are required', ctx: req.ov });
}
}
+ const scopeErr = scopeError(req.principal, [b.topic]);
+ if (scopeErr) return http.sendProblem(res, 403, 'events.topic_not_allowed', { detail: scopeErr, ctx: req.ov });
const cp = store.setCheckpoint(consumerOf(req), b.topic, b.cursor);
const cp = store.setCheckpoint(consumerOf(req), b.topic, b.cursor);
res.json({ consumer: consumerOf(req), topic: b.topic, ...cp });
res.json({ consumer: consumerOf(req), topic: b.topic, ...cp });
});
});
@@ -9,6 +9,12 @@
@@ -9,6 +9,12 @@
* POST /api/v1/subscriptions/:id/disable | /enable
* POST /api/v1/subscriptions/:id/disable | /enable
*
*
* The endpoint must be http(s) on the allow-list (127.0.0.1, *.openvibe.*): server/endpoints.js.
* The endpoint must be http(s) on the allow-list (127.0.0.1, *.openvibe.*): server/endpoints.js.
+ *
+ * Developer apps (events.app.subscribe) use the same routes with an app token. The consumer is the
+ * app (app:app_<ULID>); the subscription records the token's project_id and env, and delivers only
+ * what events.app.read would show that app (server/apps.js). Topic patterns follow the same scope
+ * rule. The endpoint must be https and resolve only to public addresses (server/egress.js), checked
+ * here and again on every delivery. At most EVENTS_APP_[SANDBOX_]MAX_SUBSCRIPTIONS per project and env.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
@@ -17,6 +23,8 @@ const topics = require('../topics');
@@ -17,6 +23,8 @@ const topics = require('../topics');
const { checkEndpoint } = require('../endpoints');
const { checkEndpoint } = require('../endpoints');
const { subscriptionView } = require('../store');
const { subscriptionView } = require('../store');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
+const apps = require('../apps');
+const { checkAppEndpoint } = require('../egress');
function checkRetryPolicy(p) {
function checkRetryPolicy(p) {
if (p == null) return { ok: true, value: null };
if (p == null) return { ok: true, value: null };
@@ -37,27 +45,54 @@ function checkRetryPolicy(p) {
@@ -37,27 +45,54 @@ function checkRetryPolicy(p) {
return { ok: true, value: out };
return { ok: true, value: out };
}
}
-function subscriptionsRouter({ config, store, auth }) {
+function subscriptionsRouter({ config, store, auth, dnsLookup }) {
const router = express.Router();
const router = express.Router();
- const guard = auth.requireCap(CAPS.subscribe, { requireService: true });
+ const guard = auth.appOrService(CAPS.subscribe, CAPS.appSubscribe, { requireService: true });
+ const consumerOf = (req) => (req.principal.kind === 'app' ? req.principal.sub : req.principal.service);
+
+ /** The app's events.app.subscribe grant was withdrawn after this token was issued. */
+ function subscribeRevoked(req, res) {
+ const p = req.principal;
+ if (p.kind !== 'app') return false;
+ const at = store.revokedAt(p.sub, CAPS.appSubscribe);
+ if (at && p.iat * 1000 <= at) {
+ http.sendProblem(res, 403, 'capability.denied', { detail: `${CAPS.appSubscribe} was revoked after this token was issued`, ctx: req.ov });
+ return true;
+ }
+ return false;
+ }
function own(req, res) {
function own(req, res) {
const sub = store.getSubscription(String(req.params.id));
const sub = store.getSubscription(String(req.params.id));
- if (!sub || sub.consumer !== req.principal.service) {
+ if (!sub || sub.consumer !== consumerOf(req)) {
http.sendProblem(res, 404, 'events.not_found', { detail: 'no such subscription', ctx: req.ov });
http.sendProblem(res, 404, 'events.not_found', { detail: 'no such subscription', ctx: req.ov });
return null;
return null;
}
}
return sub;
return sub;
}
}
- router.post('/api/v1/subscriptions', guard, (req, res) => {
+ router.post('/api/v1/subscriptions', guard, async (req, res, next) => {
+ try {
+ await create(req, res);
+ } catch (err) {
+ next(err);
+ }
+ });
+
+ async function create(req, res) {
const ctx = req.ov;
const ctx = req.ov;
const b = req.body && typeof req.body === 'object' ? req.body : {};
const b = req.body && typeof req.body === 'object' ? req.body : {};
const pattern = b.topic_pattern ?? b.topic;
const pattern = b.topic_pattern ?? b.topic;
if (!topics.isValidPattern(pattern)) {
if (!topics.isValidPattern(pattern)) {
return http.sendProblem(res, 422, 'events.bad_topic', { detail: 'topic_pattern must be dot-separated segments of [a-z0-9_] or *', ctx });
return http.sendProblem(res, 422, 'events.bad_topic', { detail: 'topic_pattern must be dot-separated segments of [a-z0-9_] or *', ctx });
}
}
- const ep = checkEndpoint(b.endpoint, config.endpointHosts);
+ const app = req.principal.kind === 'app' ? req.principal : null;
+ if (subscribeRevoked(req, res)) return undefined;
+ if (app) {
+ const err = apps.patternScopeError(pattern, app);
+ if (err) return http.sendProblem(res, 403, 'events.topic_not_allowed', { detail: err, ctx });
+ }
+ const ep = app ? await checkAppEndpoint(b.endpoint, { lookup: dnsLookup }) : checkEndpoint(b.endpoint, config.endpointHosts);
if (!ep.ok) return http.sendProblem(res, 422, 'events.endpoint_not_allowed', { detail: ep.reason, ctx });
if (!ep.ok) return http.sendProblem(res, 422, 'events.endpoint_not_allowed', { detail: ep.reason, ctx });
if (b.secret !== undefined && (typeof b.secret !== 'string' || b.secret.length < 32 || b.secret.length > 256)) {
if (b.secret !== undefined && (typeof b.secret !== 'string' || b.secret.length < 32 || b.secret.length > 256)) {
return http.sendProblem(res, 422, 'events.bad_request', { detail: 'secret must be a string of 32..256 characters', ctx });
return http.sendProblem(res, 422, 'events.bad_request', { detail: 'secret must be a string of 32..256 characters', ctx });
@@ -65,7 +100,7 @@ function subscriptionsRouter({ config, store, auth }) {
@@ -65,7 +100,7 @@ function subscriptionsRouter({ config, store, auth }) {
const rp = checkRetryPolicy(b.retry_policy);
const rp = checkRetryPolicy(b.retry_policy);
if (!rp.ok) return http.sendProblem(res, 422, 'events.bad_request', { detail: rp.reason, ctx });
if (!rp.ok) return http.sendProblem(res, 422, 'events.bad_request', { detail: rp.reason, ctx });
- const consumer = req.principal.service;
+ const consumer = consumerOf(req);
const endpoint = ep.url.toString();
const endpoint = ep.url.toString();
const dup = store.listSubscriptions(consumer).find(s => s.topic_pattern === pattern && s.endpoint === endpoint);
const dup = store.listSubscriptions(consumer).find(s => s.topic_pattern === pattern && s.endpoint === endpoint);
if (dup) {
if (dup) {
@@ -74,6 +109,14 @@ function subscriptionsRouter({ config, store, auth }) {
@@ -74,6 +109,14 @@ function subscriptionsRouter({ config, store, auth }) {
if (store.countSubscriptions(consumer) >= config.maxSubscriptionsPerConsumer) {
if (store.countSubscriptions(consumer) >= config.maxSubscriptionsPerConsumer) {
return http.sendProblem(res, 409, 'events.subscription_limit', { detail: `at most ${config.maxSubscriptionsPerConsumer} subscriptions per consumer`, ctx });
return http.sendProblem(res, 409, 'events.subscription_limit', { detail: `at most ${config.maxSubscriptionsPerConsumer} subscriptions per consumer`, ctx });
}
}
+ if (app) {
+ const max = config.apps.quotas[app.env].maxSubscriptions;
+ if (max && store.countProjectSubscriptions(app.projectId, app.env) >= max) {
+ return http.sendProblem(res, 429, 'events.quota_exceeded', {
+ detail: `at most ${max} subscriptions per project in ${app.env}`, ctx, extra: { quota: 'subscriptions', limit: max },
+ });
+ }
+ }
const row = store.createSubscription({
const row = store.createSubscription({
id: `sub_${ids.ulid()}`,
id: `sub_${ids.ulid()}`,
consumer,
consumer,
@@ -81,12 +124,14 @@ function subscriptionsRouter({ config, store, auth }) {
@@ -81,12 +124,14 @@ function subscriptionsRouter({ config, store, auth }) {
endpoint,
endpoint,
secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`,
secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`,
retryPolicy: rp.value && Object.keys(rp.value).length ? rp.value : null,
retryPolicy: rp.value && Object.keys(rp.value).length ? rp.value : null,
+ projectId: app ? app.projectId : null,
+ env: app ? app.env : 'production',
});
});
return res.status(201).json(subscriptionView(row, { withSecret: true }));
return res.status(201).json(subscriptionView(row, { withSecret: true }));
- });
+ }
router.get('/api/v1/subscriptions', guard, (req, res) => {
router.get('/api/v1/subscriptions', guard, (req, res) => {
- res.json({ subscriptions: store.listSubscriptions(req.principal.service).map(s => subscriptionView(s)) });
+ res.json({ subscriptions: store.listSubscriptions(consumerOf(req)).map(s => subscriptionView(s)) });
});
});
router.get('/api/v1/subscriptions/:id', guard, (req, res) => {
router.get('/api/v1/subscriptions/:id', guard, (req, res) => {
@@ -96,6 +141,7 @@ function subscriptionsRouter({ config, store, auth }) {
@@ -96,6 +141,7 @@ function subscriptionsRouter({ config, store, auth }) {
for (const [action, enabled] of [['disable', false], ['enable', true]]) {
for (const [action, enabled] of [['disable', false], ['enable', true]]) {
router.post(`/api/v1/subscriptions/:id/${action}`, guard, (req, res) => {
router.post(`/api/v1/subscriptions/:id/${action}`, guard, (req, res) => {
+ if (enabled && subscribeRevoked(req, res)) return;
const sub = own(req, res);
const sub = own(req, res);
if (sub) res.json(subscriptionView(store.setSubscriptionEnabled(sub.id, enabled)));
if (sub) res.json(subscriptionView(store.setSubscriptionEnabled(sub.id, enabled)));
});
});
@@ -11,7 +11,7 @@ const { subscriptionsRouter } = require('./api/subscriptions');
@@ -11,7 +11,7 @@ const { subscriptionsRouter } = require('./api/subscriptions');
const { readRouter } = require('./api/read');
const { readRouter } = require('./api/read');
const pkg = require('../package.json');
const pkg = require('../package.json');
-function createApp({ config, store, auth, keys, worker, realtime, metrics, log = console }) {
+function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, log = console }) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
@@ -66,16 +66,16 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, log =
@@ -66,16 +66,16 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, log =
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
app.use(publishRouter({ config, store, auth, worker, realtime }));
app.use(publishRouter({ config, store, auth, worker, realtime }));
- app.use(subscriptionsRouter({ config, store, auth }));
+ app.use(subscriptionsRouter({ config, store, auth, dnsLookup }));
app.use(readRouter({ store, auth, worker }));
app.use(readRouter({ store, auth, worker }));
app.get('/', (_req, res) => {
app.get('/', (_req, res) => {
res.type('text/plain').send([
res.type('text/plain').send([
'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
'',
'',
- 'POST /api/v1/events publish (service token, events.event.publish)',
- 'GET /api/v1/events pull with a cursor (events.event.read)',
- ' /api/v1/subscriptions webhook subscriptions (events.subscription.manage)',
+ '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)',
' /api/v1/deliveries DLQ inspect and replay (events.delivery.admin)',
'GET /realtime/stream?topics=... server-sent events for browsers',
'GET /realtime/stream?topics=... server-sent events for browsers',
'GET /api/health, /api/ready, /release.json',
'GET /api/health, /api/ready, /release.json',
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * Developer apps (roadmap Wave 20, ADR-014; capabilities events.app.publish|read|subscribe).
+ *
+ * An app token from OpenVibe.Network has sub `app:app_<ULID>`, actor_type `app`, `project_id`
+ * `prj_<ULID>`, `env` sandbox|production and ns [project_id]. Here it becomes an app principal:
+ *
+ * project_key 'p' + the project's ULID in lowercase prj_01JAB… -> p01jab…
+ * source 'app-' + the app's ULID in lowercase app_01JAB… -> app-01jab…
+ * prefix 'app.<project_key>.' the only event types it may publish
+ *
+ * Both derived names fit the existing events.event-envelope@1 patterns (a type segment is
+ * [a-z0-9_]+, a source is [a-z][a-z0-9-]{1,39}), so the envelope contract is unchanged.
+ *
+ * Scope (what an app sees, pulls and gets delivered):
+ * - its own project's events, in the SAME environment as the token (sandbox never meets production)
+ * - first-party platform events with visibility `public` (always production)
+ * First-party readers (services with events.event.read) never see sandbox events, and see app
+ * events only through a pattern that starts with the literal segment `app`.
+ */
+const topics = require('./topics');
+
+const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
+const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);
+const PROJECT_RE = new RegExp(`^prj_(${ULID})$`);
+const ENVS = ['sandbox', 'production'];
+
+/** 'prj_01JAB…' -> 'p01jab…' (null for anything that is not a project id). */
+function projectKey(projectId) {
+ const m = PROJECT_RE.exec(String(projectId || ''));
+ return m ? `p${m[1].toLowerCase()}` : null;
+}
+
+/** 'app:app_01JAB…' (or 'app_01JAB…') -> 'app-01jab…'. */
+function appSource(sub) {
+ const s = String(sub || '');
+ const m = APP_SUB_RE.exec(s.startsWith('app:') ? s : `app:${s}`);
+ return m ? `app-${m[1].toLowerCase()}` : null;
+}
+
+/**
+ * The app principal for verified token claims, or { error } when the claims are not a usable app
+ * token (no project, unknown env, project outside ns).
+ */
+function appPrincipal(claims) {
+ const m = APP_SUB_RE.exec(String(claims && claims.sub));
+ if (!m) return { error: 'not an app token' };
+ if (claims.actor_type && claims.actor_type !== 'app') return { error: 'actor_type must be app' };
+ const key = projectKey(claims.project_id);
+ if (!key) return { error: 'app token without a project_id' };
+ const env = claims.env === undefined ? 'production' : claims.env;
+ if (!ENVS.includes(env)) return { error: `unknown env ${claims.env}` };
+ if (Array.isArray(claims.ns) && claims.ns.length && !claims.ns.includes(claims.project_id)) {
+ return { error: 'project_id is not in the token namespaces' };
+ }
+ return {
+ kind: 'app',
+ sub: claims.sub,
+ appId: `app_${m[1]}`,
+ projectId: claims.project_id,
+ projectKey: key,
+ source: `app-${m[1].toLowerCase()}`,
+ prefix: `app.${key}.`,
+ env,
+ onBehalfOf: typeof claims.on_behalf_of === 'string' ? claims.on_behalf_of : null,
+ };
+}
+
+/**
+ * May an app use this topic pattern (pull, checkpoint, subscription)? The first segment must be
+ * literal; an `app` pattern must name the app's own project_key as its second segment.
+ * Returns null when allowed, or the reason.
+ */
+function patternScopeError(pattern, app) {
+ if (!topics.isValidPattern(pattern)) return 'topic patterns are dot-separated segments of [a-z0-9_] or *';
+ const [first, second] = pattern.split('.');
+ if (first === '*') return 'an app topic pattern must start with a literal segment (e.g. app.<project_key>.* or live.*)';
+ if (first === 'app' && second !== app.projectKey) return `app.* patterns must name your project: app.${app.projectKey}.*`;
+ return null;
+}
+
+/** Row visibility for an app scope ({ projectId, env }): own project + same env, or public first-party. */
+function visibleToApp(row, scope) {
+ if (row.project_id) return row.project_id === scope.projectId && (row.env || 'production') === scope.env;
+ return row.visibility === 'public' && (row.env || 'production') === 'production';
+}
+
+/** Does a first-party (service) pattern select this row? Never sandbox; app events only via `app.` patterns. */
+function serviceMatches(pattern, row) {
+ if ((row.env || 'production') !== 'production') return false;
+ if (row.project_id && !pattern.startsWith('app.')) return false;
+ return topics.matches(pattern, row.event_type);
+}
+
+/** Should `sub` (a subscriptions row) receive `row` (an events row)? */
+function deliverable(sub, row) {
+ if (sub.project_id) {
+ return topics.matches(sub.topic_pattern, row.event_type) && visibleToApp(row, { projectId: sub.project_id, env: sub.env || 'production' });
+ }
+ return serviceMatches(sub.topic_pattern, row);
+}
+
+module.exports = { projectKey, appSource, appPrincipal, patternScopeError, visibleToApp, serviceMatches, deliverable, ENVS, APP_SUB_RE };
@@ -6,20 +6,28 @@
@@ -6,20 +6,28 @@
* openvibe.events), verified with openvibe-contracts serviceAuth.verifyServiceToken.
* openvibe.events), verified with openvibe-contracts serviceAuth.verifyServiceToken.
* - Browsers: Network user JWTs (RS256, same key), cookie `ov_token` or Bearer.
* - Browsers: Network user JWTs (RS256, same key), cookie `ov_token` or Bearer.
*
*
- * Capability ids events.event.publish / events.subscription.manage / events.event.read / events.delivery.admin are proposed in
- * docs/capabilities-proposal/ and are not in openvibe-contracts yet. capabilities.check() answers
- * capability.unknown for an id it does not know; until the release that defines them, hasCap()
- * decides with the same grant rule (exact id, or a `family.*` grant).
+ * - Developer apps (ADR-014): app tokens (sub app:app_<ULID>, project_id, env) holding
+ * events.app.publish / events.app.read / events.app.subscribe, on the routes that accept them
+ * (appOrService guard). Only there is a sandbox token (env=sandbox) accepted; every other route
+ * refuses it with token.sandbox_refused.
+ *
+ * The service capabilities are in openvibe-contracts since v0.7; events.app.* arrive in v0.28.
+ * capabilities.check() answers capability.unknown for an id the installed release does not know;
+ * until then hasCap() decides with the same grant rule (exact id, or a `family.*` grant).
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
+const apps = require('./apps');
-/** The capability ids this service enforces (manifests: docs/capabilities-proposal/). */
+/** The capability ids this service enforces. */
const CAPS = Object.freeze({
const CAPS = Object.freeze({
publish: 'events.event.publish',
publish: 'events.event.publish',
subscribe: 'events.subscription.manage',
subscribe: 'events.subscription.manage',
read: 'events.event.read',
read: 'events.event.read',
admin: 'events.delivery.admin',
admin: 'events.delivery.admin',
+ appPublish: 'events.app.publish',
+ appRead: 'events.app.read',
+ appSubscribe: 'events.app.subscribe',
});
});
// ── Network public key ─────────────────────────────────────
// ── Network public key ─────────────────────────────────────
@@ -147,12 +155,15 @@ function serviceSlug(sub) {
@@ -147,12 +155,15 @@ function serviceSlug(sub) {
return m ? m[1] : null;
return m ? m[1] : null;
}
}
-function createAuth({ config, keys }) {
+function createAuth({ config, keys, store = null }) {
// Token lifetimes are judged on wall-clock time, never on the worker's (injectable) clock.
// Token lifetimes are judged on wall-clock time, never on the worker's (injectable) clock.
- function verifyService(token) {
+ function verifyService(token, { acceptSandbox = false } = {}) {
const publicKey = keys.get();
const publicKey = keys.get();
if (!publicKey) return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
if (!publicKey) return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
- return serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience, acceptSandbox });
+ // Belt and braces for a contracts release that does not know `env` yet.
+ if (r.ok && r.claims.env === 'sandbox' && !acceptSandbox) return { ok: false, code: 'token.sandbox_refused', reason: 'sandbox tokens are not accepted here' };
+ return r;
}
}
function verifyUser(token) {
function verifyUser(token) {
@@ -170,11 +181,40 @@ function createAuth({ config, keys }) {
@@ -170,11 +181,40 @@ function createAuth({ config, keys }) {
if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx });
if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx });
const r = verifyService(token);
const r = verifyService(token);
if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
+ // Developer apps only ever act through events.app.* (appOrService); a first-party
+ // capability in an app token is never honoured.
+ if (/^app:/.test(String(r.claims.sub))) return http.sendProblem(res, 403, 'capability.denied', { detail: 'app tokens are not accepted on this route', ctx });
const c = allows(r.claims, id);
const c = allows(r.claims, id);
if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });
if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });
const service = serviceSlug(r.claims.sub);
const service = serviceSlug(r.claims.sub);
if (requireService && !service) return http.sendProblem(res, 403, 'capability.denied', { detail: 'only service principals may do this', ctx });
if (requireService && !service) return http.sendProblem(res, 403, 'capability.denied', { detail: 'only service principals may do this', ctx });
- req.principal = { sub: r.claims.sub, service, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti };
+ req.principal = { kind: 'service', sub: r.claims.sub, service, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti };
+ return next();
+ };
+ }
+
+ /**
+ * A route shared by first-party services (serviceCap) and developer apps (appCap). An app token
+ * is judged only on appCap and becomes req.principal = { kind: 'app', sub, appId, projectId,
+ * projectKey, source, prefix, env, onBehalfOf }; sandbox tokens are accepted for apps only.
+ * Anything else goes through requireCap(serviceCap).
+ */
+ function appOrService(serviceCap, appCap, { requireService = false } = {}) {
+ const serviceGuard = requireCap(serviceCap, { requireService });
+ return function appOrServiceGuard(req, res, next) {
+ const ctx = req.ov;
+ const token = bearer(req);
+ if (!token || !config.apps.enabled) return serviceGuard(req, res, next);
+ const r = verifyService(token, { acceptSandbox: true });
+ if (!r.ok || !/^app:/.test(String(r.claims.sub))) return serviceGuard(req, res, next);
+ const principal = apps.appPrincipal(r.claims);
+ if (principal.error) return http.sendProblem(res, 401, 'token.invalid_claims', { detail: principal.error, ctx });
+ // Network revoked this app after the token was issued (network.app.revoked reached us).
+ const revoked = store && store.revokedAt(principal.sub, 'app');
+ if (revoked && r.claims.iat * 1000 <= revoked) return http.sendProblem(res, 401, 'token.revoked', { detail: 'this app was revoked', ctx });
+ const c = allows(r.claims, appCap);
+ if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });
+ req.principal = { ...principal, cap: r.claims.cap, jti: r.claims.jti, iat: r.claims.iat };
return next();
return next();
};
};
}
}
@@ -210,7 +250,7 @@ function createAuth({ config, keys }) {
@@ -210,7 +250,7 @@ function createAuth({ config, keys }) {
return { kind: 'user', subjectId, sub: String(claims.sub) };
return { kind: 'user', subjectId, sub: String(claims.sub) };
}
}
- return { verifyService, verifyUser, requireCap, realtimeViewer };
+ return { verifyService, verifyUser, requireCap, appOrService, realtimeViewer };
}
}
module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, verifyUserJwt, serviceSlug, bearer, cookie };
module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, verifyUserJwt, serviceSlug, bearer, cookie };
@@ -29,6 +29,8 @@ function sourcePrefixes(env) {
@@ -29,6 +29,8 @@ function sourcePrefixes(env) {
}
}
for (const [source, prefixes] of Object.entries(map)) {
for (const [source, prefixes] of Object.entries(map)) {
if (!/^[a-z][a-z0-9-]{1,39}$/.test(source)) throw new Error(`EVENTS_SOURCE_PREFIXES: bad source "${source}"`);
if (!/^[a-z][a-z0-9-]{1,39}$/.test(source)) throw new Error(`EVENTS_SOURCE_PREFIXES: bad source "${source}"`);
+ // app.* event types and app-<ulid> sources belong to developer apps (events.app.publish) only.
+ if (source === 'app' || source.startsWith('app-')) throw new Error(`EVENTS_SOURCE_PREFIXES: "${source}" is reserved for developer apps`);
if (!Array.isArray(prefixes) || !prefixes.length) throw new Error(`EVENTS_SOURCE_PREFIXES: ${source} needs at least one prefix`);
if (!Array.isArray(prefixes) || !prefixes.length) throw new Error(`EVENTS_SOURCE_PREFIXES: ${source} needs at least one prefix`);
for (const p of prefixes) {
for (const p of prefixes) {
if (typeof p !== 'string' || p.split('.')[0] !== source) {
if (typeof p !== 'string' || p.split('.')[0] !== source) {
@@ -84,9 +86,30 @@ function load(env = process.env) {
@@ -84,9 +86,30 @@ function load(env = process.env) {
// /api/ready reports `dlq` degraded (still ready) once more dead deliveries than this wait for replay.
// /api/ready reports `dlq` degraded (still ready) once more dead deliveries than this wait for replay.
dlqDegradedAt: int(env.EVENTS_DLQ_DEGRADED_AT, 100),
dlqDegradedAt: int(env.EVENTS_DLQ_DEGRADED_AT, 100),
maxSubscriptionsPerConsumer: int(env.EVENTS_MAX_SUBSCRIPTIONS, 100),
maxSubscriptionsPerConsumer: int(env.EVENTS_MAX_SUBSCRIPTIONS, 100),
- // Hostname patterns a subscription endpoint may point at (SSRF guard).
+ // Hostname patterns a first-party subscription endpoint may point at (SSRF guard). App
+ // subscriptions (events.app.subscribe) use server/egress.js instead: public https only.
endpointHosts: list(env.EVENTS_ENDPOINT_HOSTS, ['127.0.0.1', '*.openvibe.*']),
endpointHosts: list(env.EVENTS_ENDPOINT_HOSTS, ['127.0.0.1', '*.openvibe.*']),
+ // Developer apps (ADR-014; events.app.publish|read|subscribe). Quotas are per project and
+ // environment and enforced here; 0 turns a limit off.
+ apps: {
+ enabled: env.EVENTS_APPS !== 'off',
+ quotas: {
+ production: {
+ publishPerMinute: int(env.EVENTS_APP_PUBLISH_PER_MINUTE, 120),
+ retainedBytes: int(env.EVENTS_APP_RETAINED_BYTES, 50 * 1024 * 1024),
+ maxSubscriptions: int(env.EVENTS_APP_MAX_SUBSCRIPTIONS, 20),
+ },
+ sandbox: {
+ publishPerMinute: int(env.EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE, 30),
+ retainedBytes: int(env.EVENTS_APP_SANDBOX_RETAINED_BYTES, 5 * 1024 * 1024),
+ maxSubscriptions: int(env.EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS, 5),
+ },
+ },
+ // Sandbox events are pruned sooner than everything else.
+ sandboxRetentionDays: int(env.EVENTS_APP_SANDBOX_RETENTION_DAYS, 7),
+ },
+
// Realtime (SSE) gateway
// Realtime (SSE) gateway
realtime: {
realtime: {
maxConnections: int(env.REALTIME_MAX_CONNECTIONS, 2000),
maxConnections: int(env.REALTIME_MAX_CONNECTIONS, 2000),
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
+'use strict';
+/**
+ * Outbound delivery to developer-app endpoints (events.app.subscribe). SSRF guard:
+ *
+ * - https only, no credentials in the URL, no trailing-dot or single-label hostnames
+ * - every address the hostname resolves to must be public (no loopback, private, link-local,
+ * CGNAT, multicast, documentation, benchmarking, reserved, unique-local, NAT64/6to4/Teredo
+ * wrapping a non-public v4 address, …)
+ * - checked when the subscription is created AND again at delivery time, inside the socket's own
+ * DNS lookup, so the connection goes to the address that was checked (no rebinding window)
+ * - redirects are never followed (a 3xx is a failed attempt)
+ *
+ * First-party subscriptions keep the host allow-list in server/endpoints.js.
+ */
+const dns = require('dns');
+const net = require('net');
+const https = require('https');
+
+const blocked = new net.BlockList();
+for (const [addr, prefix] of [
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
+ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
+ ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
+]) blocked.addSubnet(addr, prefix, 'ipv4');
+for (const [addr, prefix] of [
+ ['::', 128], ['::1', 128], ['::', 96], ['100::', 64], ['2001::', 23], ['2001:db8::', 32], ['fc00::', 7], ['fe80::', 10],
+ ['fec0::', 10], ['ff00::', 8], ['3fff::', 20], ['5f00::', 16],
+]) blocked.addSubnet(addr, prefix, 'ipv6');
+
+/** The IPv4 address an IPv6 address carries (mapped, compatible-ish NAT64, 6to4, Teredo), or null. */
+function embeddedV4(ip) {
+ const words = expandV6(ip);
+ if (!words) return null;
+ const v4 = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
+ if (words.slice(0, 5).every(w => w === 0) && words[5] === 0xffff) return v4(words[6], words[7]); // ::ffff:a.b.c.d
+ if (words[0] === 0x64 && words[1] === 0xff9b) return v4(words[6], words[7]); // 64:ff9b::/96 (and /48)
+ if (words[0] === 0x2002) return v4(words[1], words[2]); // 6to4
+ if (words[0] === 0x2001 && words[1] === 0) return v4(words[6] ^ 0xffff, words[7] ^ 0xffff); // Teredo client
+ return null;
+}
+
+function expandV6(ip) {
+ let s = String(ip).toLowerCase();
+ const pct = s.indexOf('%');
+ if (pct >= 0) s = s.slice(0, pct);
+ if (net.isIPv6(s) === false) return null;
+ // Trailing dotted quad -> two words.
+ const dq = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
+ if (dq) {
+ const p = dq[1].split('.').map(Number);
+ s = s.slice(0, -dq[1].length) + `${((p[0] << 8) | p[1]).toString(16)}:${((p[2] << 8) | p[3]).toString(16)}`;
+ }
+ const [head, tail] = s.split('::');
+ const h = head ? head.split(':') : [];
+ const t = tail !== undefined ? (tail ? tail.split(':') : []) : null;
+ const words = t === null ? h : [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
+ if (words.length !== 8) return null;
+ return words.map(w => parseInt(w || '0', 16));
+}
+
+/** True only for a globally routable unicast address. */
+function isPublicAddress(ip) {
+ const s = String(ip || '');
+ const family = net.isIP(s);
+ if (family === 4) return !blocked.check(s, 'ipv4');
+ if (family === 6) {
+ const bare = s.split('%')[0];
+ if (blocked.check(bare, 'ipv6')) return false;
+ const v4 = embeddedV4(bare);
+ if (v4 !== null) return !blocked.check(v4, 'ipv4');
+ return true;
+ }
+ return false;
+}
+
+/** Syntax part of the check: { ok, url } or { ok: false, reason }. No DNS. */
+function parseAppEndpoint(endpoint) {
+ if (typeof endpoint !== 'string' || endpoint.length > 2048) return { ok: false, reason: 'endpoint must be a URL of at most 2048 characters' };
+ let url;
+ try { url = new URL(endpoint); } catch { return { ok: false, reason: 'not a URL' }; }
+ if (url.protocol !== 'https:') return { ok: false, reason: 'app endpoints must use https' };
+ if (url.username || url.password) return { ok: false, reason: 'credentials in the URL are not allowed' };
+ let host = url.hostname.toLowerCase();
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
+ if (host.endsWith('.')) return { ok: false, reason: 'trailing-dot hostnames are not allowed' };
+ if (net.isIP(host)) {
+ if (!isPublicAddress(host)) return { ok: false, reason: `${host} is not a public address` };
+ return { ok: true, url, host, literal: true };
+ }
+ if (!host.includes('.') || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {
+ return { ok: false, reason: `${host} is not a public hostname` };
+ }
+ return { ok: true, url, host, literal: false };
+}
+
+/** Resolve every address of `host`; { ok, addresses } or { ok: false, reason, transient }. */
+function resolvePublic(host, { lookup = dns.lookup } = {}) {
+ return new Promise((resolve) => {
+ lookup(host, { all: true, verbatim: true }, (err, addresses) => {
+ if (err) return resolve({ ok: false, reason: `cannot resolve ${host}: ${err.code || err.message}`, transient: true });
+ const list = Array.isArray(addresses) ? addresses : [{ address: addresses, family: net.isIP(addresses) }];
+ if (!list.length) return resolve({ ok: false, reason: `${host} has no addresses`, transient: true });
+ const bad = list.find(a => !isPublicAddress(a.address));
+ if (bad) return resolve({ ok: false, reason: `${host} resolves to a non-public address` });
+ return resolve({ ok: true, addresses: list });
+ });
+ });
+}
+
+/** Full subscribe-time check: syntax plus DNS. */
+async function checkAppEndpoint(endpoint, { lookup } = {}) {
+ const p = parseAppEndpoint(endpoint);
+ if (!p.ok || p.literal) return p;
+ const r = await resolvePublic(p.host, { lookup });
+ return r.ok ? p : { ok: false, reason: r.reason };
+}
+
+/**
+ * POST to an app endpoint through the guard. Returns { status } or throws (err.permanent = true
+ * when the endpoint itself is refused). `requestImpl`/`isAllowed` exist for tests only.
+ */
+function createGuardedPost({ lookup = dns.lookup, requestImpl = https.request, isAllowed = isPublicAddress, maxResponseBytes = 64 * 1024 } = {}) {
+ return function guardedPost(endpoint, { headers, body, timeoutMs = 10000 }) {
+ const p = parseAppEndpoint(endpoint);
+ if (!p.ok) return Promise.reject(Object.assign(new Error(`endpoint not allowed: ${p.reason}`), { permanent: true }));
+ if (p.literal && !isAllowed(p.host)) return Promise.reject(Object.assign(new Error('endpoint not allowed: not a public address'), { permanent: true }));
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const done = (fn, v) => { if (!settled) { settled = true; fn(v); } };
+ // The socket's own lookup: the address we connect to is the one we checked.
+ const guardedLookup = (hostname, opts, cb) => {
+ if (typeof opts === 'function') { cb = opts; opts = {}; }
+ lookup(hostname, { all: true, verbatim: true, family: (opts && opts.family) || 0 }, (err, addresses) => {
+ if (err) return cb(err);
+ const list = Array.isArray(addresses) ? addresses : [{ address: addresses, family: net.isIP(addresses) }];
+ if (!list.length || list.some(a => !isAllowed(a.address))) {
+ return cb(Object.assign(new Error(`${hostname} resolves to a non-public address`), { code: 'EOV_NOT_PUBLIC', permanent: true }));
+ }
+ if (opts && opts.all) return cb(null, list.map(a => ({ address: a.address, family: a.family || net.isIP(a.address) })));
+ return cb(null, list[0].address, list[0].family || net.isIP(list[0].address));
+ });
+ };
+ const req = requestImpl({
+ hostname: p.host,
+ port: p.url.port || undefined,
+ path: `${p.url.pathname}${p.url.search}`,
+ method: 'POST',
+ headers: { ...headers, 'Content-Length': Buffer.byteLength(body) },
+ lookup: p.literal ? undefined : guardedLookup,
+ servername: p.literal ? undefined : p.host,
+ timeout: timeoutMs,
+ }, (res) => {
+ let n = 0;
+ res.on('data', (c) => { n += c.length; if (n > maxResponseBytes) res.destroy(); });
+ res.on('end', () => done(resolve, { status: res.statusCode }));
+ res.on('close', () => done(resolve, { status: res.statusCode }));
+ res.on('error', () => done(resolve, { status: res.statusCode }));
+ });
+ req.on('timeout', () => { req.destroy(Object.assign(new Error('timeout'), { name: 'TimeoutError' })); });
+ req.on('error', (err) => {
+ if (err.code === 'EOV_NOT_PUBLIC' || (err.cause && err.cause.code === 'EOV_NOT_PUBLIC')) err.permanent = true;
+ done(reject, err);
+ });
+ req.end(body);
+ });
+ };
+}
+
+module.exports = { isPublicAddress, parseAppEndpoint, resolvePublic, checkAppEndpoint, createGuardedPost, embeddedV4 };
@@ -14,18 +14,24 @@ const { createWorker } = require('./worker');
@@ -14,18 +14,24 @@ const { createWorker } = require('./worker');
const { createRealtime } = require('./realtime');
const { createRealtime } = require('./realtime');
const { createApp } = require('./app');
const { createApp } = require('./app');
const { createMetrics } = require('./metrics');
const { createMetrics } = require('./metrics');
+const { createGuardedPost } = require('./egress');
-async function start({ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, deliveryFetch = fetchImpl, log = console, listen = true } = {}) {
+async function start({
+ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, deliveryFetch = fetchImpl,
+ appPost = undefined, dnsLookup = undefined, log = console, listen = true,
+} = {}) {
config = config || load();
config = config || load();
const db = openDb(config.dbPath);
const db = openDb(config.dbPath);
const store = createStore(db, { clock, maxHops: config.maxHops });
const store = createStore(db, { clock, maxHops: config.maxHops });
const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
- const auth = createAuth({ config, keys });
+ const auth = createAuth({ config, keys, store });
const metrics = createMetrics({ store });
const metrics = createMetrics({ store });
- const worker = createWorker({ store, config, clock, fetchImpl: deliveryFetch, log, observe: metrics.observe });
+ // appPost / dnsLookup: developer-app delivery and the subscribe-time DNS check (server/egress.js);
+ // injectable for tests only.
+ const worker = createWorker({ store, config, clock, fetchImpl: deliveryFetch, appPost: appPost || createGuardedPost({ lookup: dnsLookup }), log, observe: metrics.observe });
const realtime = createRealtime({ store, auth, config, log });
const realtime = createRealtime({ store, auth, config, log });
metrics.bind({ realtime });
metrics.bind({ realtime });
- const app = createApp({ config, store, auth, keys, worker, realtime, metrics, log });
+ const app = createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, log });
// The key loads in the background (retrying while Network boots); /api/ready says when it has.
// The key loads in the background (retrying while Network boots); /api/ready says when it has.
const keyLoaded = keys.start().catch(() => null);
const keyLoaded = keys.start().catch(() => null);
@@ -34,7 +40,7 @@ async function start({ config, clock = { now: () => Date.now() }, fetchImpl = gl
@@ -34,7 +40,7 @@ async function start({ config, clock = { now: () => Date.now() }, fetchImpl = gl
const prune = () => {
const prune = () => {
try {
try {
- const r = store.prune({ retentionDays: config.retentionDays, receiptRetentionDays: config.receiptRetentionDays });
+ const r = store.prune({ retentionDays: config.retentionDays, receiptRetentionDays: config.receiptRetentionDays, sandboxRetentionDays: config.apps.sandboxRetentionDays });
if (r.events || r.receipts) log.log(`[retention] pruned ${r.events} events, ${r.receipts} receipts`);
if (r.events || r.receipts) log.log(`[retention] pruned ${r.events} events, ${r.receipts} receipts`);
} catch (err) {
} catch (err) {
log.error(`[retention] prune failed: ${err.message}`);
log.error(`[retention] prune failed: ${err.message}`);
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
* subject only the user whose subject id is the event's actor.id or its subject.id (subject
* subject only the user whose subject id is the event's actor.id or its subject.id (subject
* type user); a guessed topic yields nothing for anyone else
* type user); a guessed topic yields nothing for anyone else
* internal service principals (token with events.event.read) only, never a browser
* internal service principals (token with events.event.read) only, never a browser
+ * Developer-app events (app.<project_key>.*, events.app.publish) are never streamed here, to anyone.
*
*
* Resume: `Last-Event-ID` (or ?last_event_id=) is a seq. Missed events are replayed first; when the
* Resume: `Last-Event-ID` (or ?last_event_id=) is a seq. Missed events are replayed first; when the
* cursor is older than retention an `event: gap` message comes first, so the client knows to
* cursor is older than retention an `event: gap` message comes first, so the client knows to
@@ -51,6 +52,8 @@ function createRealtime({ store, auth, config, log = console }) {
@@ -51,6 +52,8 @@ function createRealtime({ store, auth, config, log = console }) {
}
}
function visibleTo(viewer, row) {
function visibleTo(viewer, row) {
+ // Developer-app events (and anything sandbox) never reach realtime: apps pull or subscribe.
+ if (row.project_id || (row.env && row.env !== 'production')) return false;
if (row.visibility === 'public') return true;
if (row.visibility === 'public') return true;
if (viewer.kind === 'service') return true;
if (viewer.kind === 'service') return true;
if (row.visibility !== 'subject' || viewer.kind !== 'user' || !viewer.subjectId) return false;
if (row.visibility !== 'subject' || viewer.kind !== 'user' || !viewer.subjectId) return false;
@@ -9,6 +9,7 @@ const fs = require('fs');
@@ -9,6 +9,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const topics = require('./topics');
const topics = require('./topics');
+const apps = require('./apps');
const PRIORITY_RANK = { critical: 0, important: 1, low: 2 };
const PRIORITY_RANK = { critical: 0, important: 1, low: 2 };
const RANK_PRIORITY = ['critical', 'important', 'low'];
const RANK_PRIORITY = ['critical', 'important', 'low'];
@@ -82,6 +83,16 @@ CREATE TABLE IF NOT EXISTS consumer_checkpoints (
@@ -82,6 +83,16 @@ CREATE TABLE IF NOT EXISTS consumer_checkpoints (
PRIMARY KEY (consumer, topic_pattern)
PRIMARY KEY (consumer, topic_pattern)
);
);
+-- Developer apps whose access Network revoked (network.app.revoked -> scope 'app') or whose
+-- events.app.subscribe grant it withdrew (scope 'events.app.subscribe'). An app token issued at or
+-- before revoked_at (ms, the revocation's own timestamp) is refused for that scope.
+CREATE TABLE IF NOT EXISTS app_revocations (
+ consumer TEXT NOT NULL,
+ scope TEXT NOT NULL,
+ revoked_at INTEGER NOT NULL,
+ PRIMARY KEY (consumer, scope)
+);
+
-- Publish receipts: remember accepted event ids for longer than the events themselves, so a
-- Publish receipts: remember accepted event ids for longer than the events themselves, so a
-- producer that re-publishes an old id after retention is still answered "duplicate".
-- producer that re-publishes an old id after retention is still answered "duplicate".
CREATE TABLE IF NOT EXISTS idempotency_receipts (
CREATE TABLE IF NOT EXISTS idempotency_receipts (
@@ -94,6 +105,26 @@ CREATE TABLE IF NOT EXISTS idempotency_receipts (
@@ -94,6 +105,26 @@ CREATE TABLE IF NOT EXISTS idempotency_receipts (
const PUBLISH_RECEIPT = 'events:publish'; // receipts' consumer column for accepted publishes
const PUBLISH_RECEIPT = 'events:publish'; // receipts' consumer column for accepted publishes
+/**
+ * Columns added after the first release (developer apps, ADR-014). Existing databases get them
+ * with ALTER TABLE; every row stored before is a first-party production row.
+ * events.project_id / env / size_bytes who owns an app event, its environment, stored bytes
+ * subscriptions.project_id / env an app subscription's scope (NULL project = first-party)
+ */
+const ADDED_COLUMNS = {
+ events: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"], ['size_bytes', 'INTEGER NOT NULL DEFAULT 0']],
+ subscriptions: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"]],
+};
+
+function migrate(db) {
+ for (const [table, cols] of Object.entries(ADDED_COLUMNS)) {
+ const have = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name));
+ for (const [name, type] of cols) if (!have.has(name)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${type}`);
+ }
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_events_project ON events(project_id, env, received_at) WHERE project_id IS NOT NULL;
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_project ON subscriptions(project_id, env) WHERE project_id IS NOT NULL;`);
+}
+
function openDb(dbPath) {
function openDb(dbPath) {
if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
const db = new Database(dbPath);
const db = new Database(dbPath);
@@ -102,6 +133,7 @@ function openDb(dbPath) {
@@ -102,6 +133,7 @@ function openDb(dbPath) {
db.pragma('foreign_keys = ON');
db.pragma('foreign_keys = ON');
db.pragma('busy_timeout = 5000');
db.pragma('busy_timeout = 5000');
db.exec(SCHEMA);
db.exec(SCHEMA);
+ migrate(db);
db.prepare("INSERT OR IGNORE INTO sequences (name, value) VALUES ('events', 0)").run();
db.prepare("INSERT OR IGNORE INTO sequences (name, value) VALUES ('events', 0)").run();
return db;
return db;
}
}
@@ -145,6 +177,10 @@ function subscriptionView(row, { withSecret = false } = {}) {
@@ -145,6 +177,10 @@ function subscriptionView(row, { withSecret = false } = {}) {
created_at: new Date(row.created_at).toISOString(),
created_at: new Date(row.created_at).toISOString(),
updated_at: new Date(row.updated_at).toISOString(),
updated_at: new Date(row.updated_at).toISOString(),
};
};
+ if (row.project_id) {
+ out.project_id = row.project_id;
+ out.env = row.env || 'production';
+ }
if (withSecret) out.secret = row.secret;
if (withSecret) out.secret = row.secret;
return out;
return out;
}
}
@@ -161,10 +197,18 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -161,10 +197,18 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
selfRepeats: db.prepare(`SELECT COUNT(*) AS n FROM events WHERE trace_id = ? AND source = ? AND event_type = ?
selfRepeats: db.prepare(`SELECT COUNT(*) AS n FROM events WHERE trace_id = ? AND source = ? AND event_type = ?
AND subject_type = ? AND subject_id = ? AND (request_id IS NULL OR request_id != ?)`),
AND subject_type = ? AND subject_id = ? AND (request_id IS NULL OR request_id != ?)`),
insertEvent: db.prepare(`INSERT INTO events (id, seq, event_type, version, source, actor, subject_type, subject_id,
insertEvent: db.prepare(`INSERT INTO events (id, seq, event_type, version, source, actor, subject_type, subject_id,
- subject_revision, trace_id, priority, visibility, occurred_at, received_at, payload, hops, publisher, request_id)
+ subject_revision, trace_id, priority, visibility, occurred_at, received_at, payload, hops, publisher, request_id,
+ project_id, env, size_bytes)
VALUES (@id, @seq, @event_type, @version, @source, @actor, @subject_type, @subject_id, @subject_revision,
VALUES (@id, @seq, @event_type, @version, @source, @actor, @subject_type, @subject_id, @subject_revision,
- @trace_id, @priority, @visibility, @occurred_at, @received_at, @payload, @hops, @publisher, @request_id)`),
- enabledSubs: db.prepare('SELECT id, topic_pattern FROM subscriptions WHERE enabled = 1'),
+ @trace_id, @priority, @visibility, @occurred_at, @received_at, @payload, @hops, @publisher, @request_id,
+ @project_id, @env, @size_bytes)`),
+ enabledSubs: db.prepare('SELECT id, topic_pattern, project_id, env FROM subscriptions WHERE enabled = 1'),
+ projectRecent: db.prepare('SELECT COUNT(*) AS n FROM events WHERE project_id = ? AND env = ? AND received_at > ?'),
+ projectBytes: db.prepare('SELECT COALESCE(SUM(size_bytes), 0) AS b FROM events WHERE project_id = ? AND env = ?'),
+ disableAppSubs: db.prepare('UPDATE subscriptions SET enabled = 0, updated_at = ? WHERE consumer = ? AND enabled = 1'),
+ revoke: db.prepare(`INSERT INTO app_revocations (consumer, scope, revoked_at) VALUES (?, ?, ?)
+ ON CONFLICT(consumer, scope) DO UPDATE SET revoked_at = MAX(revoked_at, excluded.revoked_at)`),
+ revokedAt: db.prepare('SELECT revoked_at FROM app_revocations WHERE consumer = ? AND scope = ?'),
insertDelivery: db.prepare(`INSERT OR IGNORE INTO deliveries (event_id, subscription_id, seq, priority, attempt, status,
insertDelivery: db.prepare(`INSERT OR IGNORE INTO deliveries (event_id, subscription_id, seq, priority, attempt, status,
next_attempt_at, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 'pending', ?, ?, ?)`),
next_attempt_at, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 'pending', ?, ?, ?)`),
afterSeq: db.prepare('SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?'),
afterSeq: db.prepare('SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?'),
@@ -176,13 +220,23 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -176,13 +220,23 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
/**
/**
* Store a batch atomically. `items` are validated envelopes with defaults filled. Returns
* Store a batch atomically. `items` are validated envelopes with defaults filled. Returns
* { results: [{ event_id, seq, duplicate }], inserted: [row] }. Throws StoreError for an id
* { results: [{ event_id, seq, duplicate }], inserted: [row] }. Throws StoreError for an id
- * conflict or a detected loop; nothing of the batch is stored then.
+ * conflict, a detected loop or an exceeded project quota; nothing of the batch is stored then.
+ *
+ * `project` ({ projectId, env, publishPerMinute, retainedBytes }) marks an app publish: the rows
+ * carry project_id and env, and the project's rate and retained-bytes quotas are checked here,
+ * inside the transaction, against what is already stored.
*/
*/
- const insertBatch = db.transaction((items, { publisher, requestId }) => {
+ const insertBatch = db.transaction((items, { publisher, requestId, project = null }) => {
const now = clock.now();
const now = clock.now();
const results = [];
const results = [];
const inserted = [];
const inserted = [];
const subs = q.enabledSubs.all();
const subs = q.enabledSubs.all();
+ let recent = null;
+ let bytes = null;
+ if (project) {
+ recent = q.projectRecent.get(project.projectId, project.env, now - 60 * 1000).n;
+ bytes = q.projectBytes.get(project.projectId, project.env).b;
+ }
for (const env of items) {
for (const env of items) {
const existing = q.getEvent.get(env.event_id);
const existing = q.getEvent.get(env.event_id);
if (existing) {
if (existing) {
@@ -206,6 +260,21 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -206,6 +260,21 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
`trace ${env.trace_id} already carries ${hops - 1} hops for ${env.event_type}; refusing to extend a probable loop`,
`trace ${env.trace_id} already carries ${hops - 1} hops for ${env.event_type}; refusing to extend a probable loop`,
{ event_id: env.event_id, hops: hops - 1, max_hops: maxHops });
{ event_id: env.event_id, hops: hops - 1, max_hops: maxHops });
}
}
+ const payloadJson = JSON.stringify(env.payload);
+ const actorJson = JSON.stringify(env.actor);
+ const sizeBytes = Buffer.byteLength(payloadJson) + Buffer.byteLength(actorJson) + env.event_type.length + env.subject.id.length;
+ if (project) {
+ recent += 1;
+ bytes += sizeBytes;
+ if (project.publishPerMinute && recent > project.publishPerMinute) {
+ throw new StoreError(429, 'events.quota_exceeded', `project ${project.projectId} (${project.env}) may publish at most ${project.publishPerMinute} events per minute`,
+ { quota: 'publish_rate', limit: project.publishPerMinute, window: 'minute', retry_after: 60 });
+ }
+ if (project.retainedBytes && bytes > project.retainedBytes) {
+ throw new StoreError(429, 'events.quota_exceeded', `project ${project.projectId} (${project.env}) may keep at most ${project.retainedBytes} bytes of events`,
+ { quota: 'retained_bytes', limit: project.retainedBytes, used: bytes - sizeBytes });
+ }
+ }
const seq = q.nextSeq.get().value;
const seq = q.nextSeq.get().value;
const row = {
const row = {
id: env.event_id,
id: env.event_id,
@@ -213,7 +282,7 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -213,7 +282,7 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
event_type: env.event_type,
event_type: env.event_type,
version: env.version,
version: env.version,
source: env.source,
source: env.source,
- actor: JSON.stringify(env.actor),
+ actor: actorJson,
subject_type: env.subject.type,
subject_type: env.subject.type,
subject_id: env.subject.id,
subject_id: env.subject.id,
subject_revision: env.subject.revision ?? null,
subject_revision: env.subject.revision ?? null,
@@ -222,24 +291,55 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -222,24 +291,55 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
visibility: env.visibility,
visibility: env.visibility,
occurred_at: env.timestamp,
occurred_at: env.timestamp,
received_at: now,
received_at: now,
- payload: JSON.stringify(env.payload),
+ payload: payloadJson,
hops,
hops,
publisher,
publisher,
request_id: requestId || null,
request_id: requestId || null,
+ project_id: project ? project.projectId : null,
+ env: project ? project.env : 'production',
+ size_bytes: sizeBytes,
};
};
q.insertEvent.run(row);
q.insertEvent.run(row);
q.addReceipt.run(PUBLISH_RECEIPT, env.event_id, now);
q.addReceipt.run(PUBLISH_RECEIPT, env.event_id, now);
for (const s of subs) {
for (const s of subs) {
- if (topics.matches(s.topic_pattern, env.event_type)) {
+ if (apps.deliverable(s, row)) {
q.insertDelivery.run(env.event_id, s.id, seq, PRIORITY_RANK[env.priority], now, now, now);
q.insertDelivery.run(env.event_id, s.id, seq, PRIORITY_RANK[env.priority], now, now, now);
}
}
}
}
+ revocationHook(row, now);
results.push({ event_id: env.event_id, seq, duplicate: false });
results.push({ event_id: env.event_id, seq, duplicate: false });
inserted.push(row);
inserted.push(row);
}
}
return { results, inserted };
return { results, inserted };
});
});
+ /**
+ * Network tells Events when an app loses its access (network.app.revoked, also sent for every
+ * app of an archived project) or its events.app.subscribe grant (network.grant.changed). The
+ * app's subscriptions stop at once, in the same transaction that stores the event, so a
+ * revocation reaches deliveries without waiting for anything else.
+ */
+ function revocationHook(row, now) {
+ if (row.source !== 'network' || row.project_id || row.subject_type !== 'app') return;
+ let payload = {};
+ try { payload = JSON.parse(row.payload) || {}; } catch { payload = {}; }
+ let scope = null;
+ if (row.event_type === 'network.app.revoked') scope = 'app';
+ else if (row.event_type === 'network.grant.changed' && ['events.app.subscribe', 'events.app.*', 'events.*'].includes(payload.capability)
+ && !['approved', 'requested'].includes(payload.to)) scope = 'events.app.subscribe';
+ if (!scope || !/^app_[0-9A-HJKMNP-TV-Z]{26}$/.test(row.subject_id)) return;
+ const consumer = `app:${row.subject_id}`;
+ const at = Date.parse(row.occurred_at);
+ q.revoke.run(consumer, scope, Number.isFinite(at) ? at : now);
+ q.disableAppSubs.run(now, consumer);
+ }
+
+ /** When Network revoked `consumer` (app:app_…) for `scope` ('app' | 'events.app.subscribe'), in ms; or null. */
+ function revokedAt(consumer, scope) {
+ const r = q.revokedAt.get(consumer, scope);
+ return r ? r.revoked_at : null;
+ }
+
function getEvent(id) {
function getEvent(id) {
return q.getEvent.get(id) || null;
return q.getEvent.get(id) || null;
}
}
@@ -285,12 +385,15 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -285,12 +385,15 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
// ── Subscriptions ────────────────────────────────────────
// ── Subscriptions ────────────────────────────────────────
- function createSubscription({ id, consumer, topicPattern, endpoint, secret, retryPolicy }) {
+ function createSubscription({ id, consumer, topicPattern, endpoint, secret, retryPolicy, projectId = null, env = 'production' }) {
const now = clock.now();
const now = clock.now();
- db.prepare(`INSERT INTO subscriptions (id, consumer, topic_pattern, endpoint, secret, enabled, retry_policy, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`).run(id, consumer, topicPattern, endpoint, secret, retryPolicy ? JSON.stringify(retryPolicy) : null, now, now);
+ db.prepare(`INSERT INTO subscriptions (id, consumer, topic_pattern, endpoint, secret, enabled, retry_policy, created_at, updated_at, project_id, env)
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)`).run(id, consumer, topicPattern, endpoint, secret, retryPolicy ? JSON.stringify(retryPolicy) : null, now, now, projectId, env);
return getSubscription(id);
return getSubscription(id);
}
}
+ function countProjectSubscriptions(projectId, env) {
+ return db.prepare('SELECT COUNT(*) AS n FROM subscriptions WHERE project_id = ? AND env = ?').get(projectId, env).n;
+ }
function getSubscription(id) {
function getSubscription(id) {
return db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id) || null;
return db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id) || null;
}
}
@@ -358,14 +461,16 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -358,14 +461,16 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
const requeue = db.transaction((sub, { fromSeq, eventIds, max = 10000 }) => {
const requeue = db.transaction((sub, { fromSeq, eventIds, max = 10000 }) => {
const now = clock.now();
const now = clock.now();
let rows;
let rows;
+ const cols = 'id, seq, priority, event_type, visibility, project_id, env';
if (Array.isArray(eventIds)) {
if (Array.isArray(eventIds)) {
- const get = db.prepare('SELECT id, seq, priority, event_type FROM events WHERE id = ?');
+ const get = db.prepare(`SELECT ${cols} FROM events WHERE id = ?`);
rows = eventIds.map(id => get.get(id)).filter(Boolean);
rows = eventIds.map(id => get.get(id)).filter(Boolean);
} else {
} else {
- rows = db.prepare('SELECT id, seq, priority, event_type FROM events WHERE seq >= ? AND event_type GLOB ? ORDER BY seq LIMIT ?')
+ rows = db.prepare(`SELECT ${cols} FROM events WHERE seq >= ? AND event_type GLOB ? ORDER BY seq LIMIT ?`)
.all(fromSeq, topics.toGlob(sub.topic_pattern), max * 2);
.all(fromSeq, topics.toGlob(sub.topic_pattern), max * 2);
}
}
- rows = rows.filter(r => topics.matches(sub.topic_pattern, r.event_type)).slice(0, max);
+ // Replay never widens a subscription's scope (app scope, sandbox separation).
+ rows = rows.filter(r => apps.deliverable(sub, r)).slice(0, max);
const up = db.prepare(`INSERT INTO deliveries (event_id, subscription_id, seq, priority, attempt, status, next_attempt_at, created_at, updated_at)
const up = db.prepare(`INSERT INTO deliveries (event_id, subscription_id, seq, priority, attempt, status, next_attempt_at, created_at, updated_at)
VALUES (?, ?, ?, ?, 0, 'pending', ?, ?, ?)
VALUES (?, ?, ?, ?, 0, 'pending', ?, ?, ?)
ON CONFLICT(event_id, subscription_id) DO UPDATE SET status = 'pending', attempt = 0, next_attempt_at = excluded.next_attempt_at,
ON CONFLICT(event_id, subscription_id) DO UPDATE SET status = 'pending', attempt = 0, next_attempt_at = excluded.next_attempt_at,
@@ -395,9 +500,15 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -395,9 +500,15 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
// ── Retention ────────────────────────────────────────────
// ── Retention ────────────────────────────────────────────
- /** Drop events (and their deliveries) older than retentionDays, and old publish receipts. */
- function prune({ retentionDays = 30, receiptRetentionDays = 90, now = clock.now() } = {}) {
- const events = db.prepare('DELETE FROM events WHERE received_at < ?').run(now - retentionDays * DAY_MS).changes;
+ /**
+ * Drop events (and their deliveries) older than retentionDays (sandbox events older than
+ * sandboxRetentionDays, when shorter), and old publish receipts.
+ */
+ function prune({ retentionDays = 30, receiptRetentionDays = 90, sandboxRetentionDays = retentionDays, now = clock.now() } = {}) {
+ let events = db.prepare('DELETE FROM events WHERE received_at < ?').run(now - retentionDays * DAY_MS).changes;
+ if (sandboxRetentionDays < retentionDays) {
+ events += db.prepare("DELETE FROM events WHERE env = 'sandbox' AND received_at < ?").run(now - sandboxRetentionDays * DAY_MS).changes;
+ }
const receipts = db.prepare('DELETE FROM idempotency_receipts WHERE processed_at < ?')
const receipts = db.prepare('DELETE FROM idempotency_receipts WHERE processed_at < ?')
.run(now - Math.max(receiptRetentionDays, retentionDays) * DAY_MS).changes;
.run(now - Math.max(receiptRetentionDays, retentionDays) * DAY_MS).changes;
return { events, receipts };
return { events, receipts };
@@ -408,8 +519,8 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -408,8 +519,8 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
}
}
return {
return {
- db, insertBatch, getEvent, lastSeq, oldestSeq, scan,
- createSubscription, getSubscription, listSubscriptions, countSubscriptions, setSubscriptionEnabled,
+ db, insertBatch, getEvent, revokedAt, lastSeq, oldestSeq, scan,
+ createSubscription, getSubscription, listSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled,
dueDeliveries, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,
dueDeliveries, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,
getCheckpoint, setCheckpoint, prune, ping,
getCheckpoint, setCheckpoint, prune, ping,
};
};
@@ -9,6 +9,10 @@
@@ -9,6 +9,10 @@
* (including redirects, which are never followed) is retried with backoff 1s, 5s, 30s, 2m, 10m, 1h
* (including redirects, which are never followed) is retried with backoff 1s, 5s, 30s, 2m, 10m, 1h
* up to maxAttempts, then the delivery is `dead` (the DLQ; replay requeues it).
* up to maxAttempts, then the delivery is `dead` (the DLQ; replay requeues it).
*
*
+ * App subscriptions (events.app.subscribe) go through server/egress.js instead of fetch: https to a
+ * public address only, re-resolved and checked on every attempt, the connection pinned to the checked
+ * address, redirects refused. A refused address is permanent (dead at once; replay requeues it).
+ *
* Scheduling: a setTimeout chain (never overlapping ticks); priority classes critical > important >
* Scheduling: a setTimeout chain (never overlapping ticks); priority classes critical > important >
* low, then seq; one delivery in flight per subscription; at most maxInflight overall.
* low, then seq; one delivery in flight per subscription; at most maxInflight overall.
*/
*/
@@ -16,9 +20,10 @@ const crypto = require('crypto');
@@ -16,9 +20,10 @@ const crypto = require('crypto');
const { sign } = require('../lib/client');
const { sign } = require('../lib/client');
const { rowToEnvelope } = require('./store');
const { rowToEnvelope } = require('./store');
const { checkEndpoint } = require('./endpoints');
const { checkEndpoint } = require('./endpoints');
+const { createGuardedPost } = require('./egress');
// observe (optional): { delivered(seconds, row), attempt(outcome) } — metrics hooks, never required.
// observe (optional): { delivered(seconds, row), attempt(outcome) } — metrics hooks, never required.
-function createWorker({ store, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, observe = null }) {
+function createWorker({ store, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, appPost = createGuardedPost(), log = console, observe = null }) {
const opts = config.worker;
const opts = config.worker;
const busy = new Set(); // subscription ids with a delivery in flight
const busy = new Set(); // subscription ids with a delivery in flight
const inflight = new Set(); // promises
const inflight = new Set(); // promises
@@ -43,29 +48,36 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
@@ -43,29 +48,36 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
const { maxAttempts, backoffMs } = policy(sub);
const { maxAttempts, backoffMs } = policy(sub);
let outcome;
let outcome;
try {
try {
- // Re-check the endpoint at send time too (the allow-list may have been tightened).
- const check = checkEndpoint(sub.endpoint, config.endpointHosts);
- if (!check.ok) throw Object.assign(new Error(`endpoint not allowed: ${check.reason}`), { permanent: true });
const body = JSON.stringify({ event: rowToEnvelope(row), seq: row.seq });
const body = JSON.stringify({ event: rowToEnvelope(row), seq: row.seq });
- const res = await fetchImpl(sub.endpoint, {
- method: 'POST',
- redirect: 'manual',
- signal: AbortSignal.timeout(opts.timeoutMs),
- headers: {
- 'Content-Type': 'application/json',
- 'User-Agent': 'OpenVibe.Events/0.1',
- 'X-OpenVibe-Event-Id': row.id,
- 'X-OpenVibe-Event-Type': row.event_type,
- 'X-OpenVibe-Seq': String(row.seq),
- 'X-OpenVibe-Subscription-Id': sub.id,
- 'X-OpenVibe-Delivery-Attempt': String(attempt),
- 'X-OpenVibe-Hops': String(row.hops),
- 'X-OpenVibe-Signature': sign(body, sub.secret),
- traceparent: `00-${row.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,
- },
- body,
- });
- try { await res.body?.cancel(); } catch { /* body is not needed */ }
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'User-Agent': 'OpenVibe.Events/0.1',
+ 'X-OpenVibe-Event-Id': row.id,
+ 'X-OpenVibe-Event-Type': row.event_type,
+ 'X-OpenVibe-Seq': String(row.seq),
+ 'X-OpenVibe-Subscription-Id': sub.id,
+ 'X-OpenVibe-Delivery-Attempt': String(attempt),
+ 'X-OpenVibe-Hops': String(row.hops),
+ 'X-OpenVibe-Signature': sign(body, sub.secret),
+ traceparent: `00-${row.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,
+ };
+ let res;
+ if (sub.project_id) {
+ // Developer-app endpoint: public https only, checked again now (egress.js).
+ res = await appPost(sub.endpoint, { headers, body, timeoutMs: opts.timeoutMs });
+ } else {
+ // Re-check the endpoint at send time too (the allow-list may have been tightened).
+ const check = checkEndpoint(sub.endpoint, config.endpointHosts);
+ if (!check.ok) throw Object.assign(new Error(`endpoint not allowed: ${check.reason}`), { permanent: true });
+ res = await fetchImpl(sub.endpoint, {
+ method: 'POST',
+ redirect: 'manual',
+ signal: AbortSignal.timeout(opts.timeoutMs),
+ headers,
+ body,
+ });
+ try { await res.body?.cancel(); } catch { /* body is not needed */ }
+ }
if (res.status >= 200 && res.status < 300) outcome = { ok: true, attempt, status: res.status };
if (res.status >= 200 && res.status < 300) outcome = { ok: true, attempt, status: res.status };
else outcome = { ok: false, attempt, status: res.status, error: `HTTP ${res.status}` };
else outcome = { ok: false, attempt, status: res.status, error: `HTTP ${res.status}` };
} catch (err) {
} catch (err) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,369 @@
@@ -0,0 +1,369 @@
+'use strict';
+// Developer apps (ADR-014): events.app.publish / events.app.read / events.app.subscribe with app
+// tokens, project scoping, sandbox separation, the public-endpoint SSRF guard, quotas, revocation.
+const assert = require('assert');
+const nodeHttp = require('http');
+const { ids } = require('openvibe-contracts');
+const { verifyDelivery } = require('../lib/client');
+const apps = require('../server/apps');
+const { createGuardedPost, isPublicAddress } = require('../server/egress');
+const { load } = require('../server/config');
+const { boot, request, serviceToken, appToken, envelope, subscriber, suite, sleep } = require('./helpers');
+
+const t = suite('apps');
+const ALL = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];
+
+// ── Fake DNS: the test controls what every hostname resolves to ──
+const dnsTable = new Map([['hooks.example.com', '93.184.216.34'], ['evil.example.com', '10.0.0.7'], ['rebind.example.com', '93.184.216.35']]);
+function fakeLookup(host, opts, cb) {
+ if (typeof opts === 'function') { cb = opts; opts = {}; }
+ const ip = dnsTable.get(host);
+ if (!ip) return cb(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }));
+ const family = ip.includes(':') ? 6 : 4;
+ return opts.all ? cb(null, [{ address: ip, family }]) : cb(null, ip, family);
+}
+
+// A receiving server on 127.0.0.1 stands in for the app's public https endpoint: deliveries go through
+// the real guarded poster, with DNS answered by the table above, loopback allowed only for this
+// fake, and plain http underneath (the code path under test is the guard and the pinned lookup).
+const seen = [];
+const receiver = nodeHttp.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', c => chunks.push(c));
+ req.on('end', () => {
+ seen.push({ url: req.url, headers: req.headers, rawBody: Buffer.concat(chunks) });
+ if (req.url.startsWith('/redirect')) { res.writeHead(302, { Location: 'http://127.0.0.1:1/metadata' }); return res.end(); }
+ res.statusCode = 204;
+ res.end();
+ });
+});
+let receiverPort;
+const pinned = [];
+const testPost = createGuardedPost({
+ lookup: (host, opts, cb) => fakeLookup(host, opts, (err, a, f) => {
+ if (err) return cb(err);
+ // Every public test host is served by the local receiver.
+ const list = (Array.isArray(a) ? a : [{ address: a, family: f }]).map(x => ({ ...x, address: isPublicAddress(x.address) ? '127.0.0.1' : x.address }));
+ pinned.push(list[0].address);
+ return opts.all ? cb(null, list) : cb(null, list[0].address, list[0].family);
+ }),
+ requestImpl: (options, cb) => nodeHttp.request({ ...options, port: receiverPort }, cb),
+ isAllowed: (ip) => ip === '127.0.0.1' || isPublicAddress(ip),
+});
+
+let h;
+const prjA = `prj_${ids.ulid()}`;
+const prjB = `prj_${ids.ulid()}`;
+const appA = ids.newId('app');
+const appA2 = ids.newId('app'); // a production app of the same project
+const appB = ids.newId('app');
+const keyA = apps.projectKey(prjA);
+const keyB = apps.projectKey(prjB);
+const tokA = (over = {}) => appToken({ appId: appA, projectId: prjA, env: 'sandbox', cap: ALL, ...over });
+const tokA2 = (over = {}) => appToken({ appId: appA2, projectId: prjA, env: 'production', cap: ALL, ...over });
+const tokB = (over = {}) => appToken({ appId: appB, projectId: prjB, env: 'sandbox', cap: ALL, ...over });
+const live = serviceToken('live', ['events.event.publish']);
+const reader = serviceToken('search', ['events.event.read']);
+
+function appEvent(appId, projectId, name = 'order.created', over = {}) {
+ return envelope('x', {
+ source: apps.appSource(appId), event_type: `app.${apps.projectKey(projectId)}.${name}`,
+ actor: { type: 'app', id: appId }, subject: { type: 'order', id: '42' }, visibility: 'internal', ...over,
+ });
+}
+const publish = (token, body) => request(h.base, 'POST', '/api/v1/events', { token, body });
+const pull = (token, topic, after = 0) => request(h.base, 'GET', `/api/v1/events?topic=${encodeURIComponent(topic)}&after_seq=${after}`, { token });
+
+t('boot', async () => {
+ await new Promise(r => receiver.listen(0, '127.0.0.1', r));
+ receiverPort = receiver.address().port;
+ h = await boot({
+ appPost: testPost, dnsLookup: fakeLookup,
+ env: { EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE: '50', EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS: '3' },
+ });
+});
+
+t('project_key and app source are defined exactly (and fit the envelope contract)', async () => {
+ assert.strictEqual(apps.projectKey('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'p01jab2c3d4e5f6g7h8j9k0mnpq');
+ assert.strictEqual(apps.appSource('app:app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'app-01jab2c3d4e5f6g7h8j9k0mnpq');
+ assert.strictEqual(apps.projectKey('prj_bad'), null);
+ assert.strictEqual(apps.appSource('svc:live'), null);
+ assert.throws(() => load({ EVENTS_SOURCE_PREFIXES: JSON.stringify({ app: ['app.'] }) }), /reserved for developer apps/);
+ assert.throws(() => load({ EVENTS_SOURCE_PREFIXES: JSON.stringify({ 'app-x1': ['app-x1.'] }) }), /reserved/);
+});
+
+t('publish: an app publishes app.<project_key>.* only, as its own source and actor', async () => {
+ let r = await publish(tokA(), appEvent(appA, prjA));
+ assert.strictEqual(r.status, 201, r.text);
+ const row = h.store.getEvent(r.body.event_id);
+ assert.strictEqual(row.project_id, prjA);
+ assert.strictEqual(row.env, 'sandbox');
+ assert.strictEqual(row.publisher, `app:${appA}`);
+
+ r = await publish(tokA(), appEvent(appA, prjB));
+ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'events.type_not_allowed', 'never another project\'s types');
+ r = await publish(tokA(), appEvent(appA, prjA, 'x', { event_type: 'live.stream.started' }));
+ assert.strictEqual(r.body.code, 'events.type_not_allowed', 'never a first-party namespace');
+ r = await publish(tokA(), appEvent(appA, prjA, 'x', { event_type: 'app.other.thing' }));
+ assert.strictEqual(r.body.code, 'events.type_not_allowed');
+ r = await publish(tokA(), appEvent(appA, prjA, 'x', { source: 'live' }));
+ assert.strictEqual(r.body.code, 'events.source_mismatch', 'never a first-party source');
+ r = await publish(tokA(), appEvent(appA, prjA, 'x', { source: apps.appSource(appB) }));
+ assert.strictEqual(r.body.code, 'events.source_mismatch');
+ r = await publish(tokA(), appEvent(appA, prjA, 'x', { actor: { type: 'service', id: 'live' } }));
+ assert.strictEqual(r.body.code, 'events.actor_mismatch');
+ const user = ids.newId('user');
+ r = await publish(tokA({ onBehalfOf: user }), appEvent(appA, prjA, 'order.paid', { actor: { type: 'user', id: user } }));
+ assert.strictEqual(r.status, 201, 'the user the token acts for may be the actor');
+ r = await publish(appToken({ appId: appA, projectId: prjA, cap: ['events.app.read'] }), appEvent(appA, prjA));
+ assert.strictEqual(r.status, 403, 'events.app.publish is required');
+ // A first-party service can never publish into app.*: 'app' is not a source it can own.
+ r = await publish(live, envelope('live', { event_type: `app.${keyA}.order.created` }));
+ assert.strictEqual(r.status, 403);
+});
+
+t('sandbox tokens: accepted on the app routes only', async () => {
+ const admin = await request(h.base, 'GET', '/api/v1/deliveries', { token: appToken({ cap: ['events.delivery.admin'], env: 'sandbox' }) });
+ assert.strictEqual(admin.status, 401); assert.strictEqual(admin.body.code, 'token.sandbox_refused');
+ const prodAdmin = await request(h.base, 'GET', '/api/v1/deliveries', { token: appToken({ cap: ['events.delivery.admin'], env: 'production' }) });
+ assert.strictEqual(prodAdmin.status, 403, 'apps never hold operator capabilities');
+ const sbxService = serviceToken('live', ['events.event.publish']);
+ const withEnv = require('openvibe-contracts').serviceAuth.signServiceToken({
+ ...JSON.parse(Buffer.from(sbxService.split('.')[1], 'base64url')), env: 'sandbox',
+ }, require('./helpers').privateKey);
+ const r = await publish(withEnv, envelope('live'));
+ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.sandbox_refused', 'a sandbox service token is refused');
+ const rt = await request(h.base, 'GET', '/realtime/stream?topics=live.*', { token: tokA() });
+ assert.strictEqual(rt.status, 401, 'realtime refuses sandbox app tokens');
+});
+
+t('read: own project in the same environment, plus public first-party events', async () => {
+ const pub = await publish(live, envelope('live', { event_type: 'live.stream.started', visibility: 'public' }));
+ const internal = await publish(live, envelope('live', { event_type: 'live.stream.ended', visibility: 'internal' }));
+ const prodA = await publish(tokA2(), appEvent(appA2, prjA, 'order.created'));
+ assert.strictEqual(prodA.status, 201, prodA.text);
+ const sbxB = await publish(tokB(), appEvent(appB, prjB, 'order.created'));
+ assert.strictEqual(sbxB.status, 201);
+
+ let r = await pull(tokA(), `app.${keyA}.*`);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.body.events.length >= 2 && r.body.events.every(e => e.event.event_type.startsWith(`app.${keyA}.`)));
+ assert.ok(!r.body.events.some(e => e.event.event_id === prodA.body.event_id), 'a sandbox app never sees production events');
+ r = await pull(tokA2(), `app.${keyA}.*`);
+ assert.deepStrictEqual(r.body.events.map(e => e.event.event_id), [prodA.body.event_id], 'a production app never sees sandbox events');
+
+ r = await pull(tokA(), `app.${keyB}.*`);
+ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'events.topic_not_allowed');
+ for (const bad of ['*', '*.created', 'app.*', `live.*,app.${keyB}.*`]) {
+ r = await pull(tokA(), bad);
+ assert.strictEqual(r.status, 403, bad);
+ }
+ r = await pull(tokA(), 'live.*');
+ assert.deepStrictEqual(r.body.events.map(e => e.event.event_id), [pub.body.event_id], 'public first-party only, never internal');
+
+ r = await request(h.base, 'GET', `/api/v1/events/${sbxB.body.event_id}`, { token: tokA() });
+ assert.strictEqual(r.status, 404, 'another project\'s event does not exist for this app');
+ r = await request(h.base, 'GET', `/api/v1/events/${internal.body.event_id}`, { token: tokA() });
+ assert.strictEqual(r.status, 404);
+ r = await request(h.base, 'GET', `/api/v1/events/${pub.body.event_id}`, { token: tokA() });
+ assert.strictEqual(r.status, 200);
+
+ // First-party readers: never sandbox; app events only through an app.* pattern.
+ r = await pull(reader, '*');
+ assert.ok(!r.body.events.some(e => e.event.event_type.startsWith('app.')), 'no app events through *');
+ r = await pull(reader, 'app.*');
+ assert.deepStrictEqual(r.body.events.map(e => e.event.event_id), [prodA.body.event_id], 'production app events only');
+ r = await request(h.base, 'GET', `/api/v1/events/${sbxB.body.event_id}`, { token: reader });
+ assert.strictEqual(r.status, 404, 'sandbox events are invisible to first-party readers');
+
+ // Checkpoints are per app and follow the same scope.
+ r = await request(h.base, 'PUT', '/api/v1/checkpoints', { token: tokA(), body: { topic: `app.${keyA}.*`, cursor: 3 } });
+ assert.strictEqual(r.status, 200); assert.strictEqual(r.body.consumer, `app:${appA}`);
+ r = await request(h.base, 'PUT', '/api/v1/checkpoints', { token: tokA(), body: { topic: `app.${keyB}.*`, cursor: 3 } });
+ assert.strictEqual(r.status, 403);
+ r = await request(h.base, 'GET', `/api/v1/checkpoints?topic=app.${keyA}.*`, { token: tokB() });
+ assert.strictEqual(r.status, 403);
+});
+
+let subA;
+t('subscribe: scoped patterns, public https endpoints only', async () => {
+ const sub = (token, body) => request(h.base, 'POST', '/api/v1/subscriptions', { token, body });
+ for (const endpoint of ['http://hooks.example.com/x', 'https://127.0.0.1/x', 'https://[::1]/x', 'https://10.1.2.3/x', 'https://169.254.169.254/latest',
+ 'https://evil.example.com/x', 'https://localhost/x', 'https://u:[email protected]/x', 'https://nowhere.example.com/x', 'https://intranet/x']) {
+ const r = await sub(tokA(), { topic_pattern: `app.${keyA}.*`, endpoint });
+ assert.strictEqual(r.status, 422, endpoint);
+ assert.strictEqual(r.body.code, 'events.endpoint_not_allowed', endpoint);
+ }
+ let r = await sub(tokA(), { topic_pattern: `app.${keyB}.*`, endpoint: 'https://hooks.example.com/a' });
+ assert.strictEqual(r.status, 403);
+ r = await sub(tokA(), { topic_pattern: '*', endpoint: 'https://hooks.example.com/a' });
+ assert.strictEqual(r.status, 403);
+ r = await sub(appToken({ appId: appA, projectId: prjA, cap: ['events.app.read'] }), { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks.example.com/a' });
+ assert.strictEqual(r.status, 403, 'events.app.subscribe is required');
+
+ r = await sub(tokA(), { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks.example.com/a' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.consumer, `app:${appA}`);
+ assert.strictEqual(r.body.project_id, prjA);
+ assert.strictEqual(r.body.env, 'sandbox');
+ subA = r.body;
+ r = await sub(tokA(), { topic_pattern: 'live.*', endpoint: 'https://hooks.example.com/live' });
+ assert.strictEqual(r.status, 201);
+ const list = await request(h.base, 'GET', '/api/v1/subscriptions', { token: tokA() });
+ assert.strictEqual(list.body.subscriptions.length, 2);
+ assert.strictEqual((await request(h.base, 'GET', '/api/v1/subscriptions', { token: tokB() })).body.subscriptions.length, 0);
+ assert.strictEqual((await request(h.base, 'GET', `/api/v1/subscriptions/${subA.id}`, { token: tokB() })).status, 404);
+ assert.strictEqual((await request(h.base, 'POST', `/api/v1/subscriptions/${subA.id}/disable`, { token: tokA2() })).status, 404, 'another app of the project cannot touch it');
+ // A first-party consumer never sees app subscriptions either.
+ const media = serviceToken('media', ['events.subscription.manage']);
+ assert.strictEqual((await request(h.base, 'GET', `/api/v1/subscriptions/${subA.id}`, { token: media })).status, 404);
+ // Project quota on subscriptions (sandbox: 3 in this test).
+ r = await sub(tokA(), { topic_pattern: `app.${keyA}.order.*`, endpoint: 'https://hooks.example.com/b' });
+ assert.strictEqual(r.status, 201);
+ r = await sub(tokA(), { topic_pattern: `app.${keyA}.order.paid`, endpoint: 'https://hooks.example.com/c' });
+ assert.strictEqual(r.status, 429); assert.strictEqual(r.body.code, 'events.quota_exceeded');
+});
+
+t('delivery: signed, scoped, sandbox never meets production', async () => {
+ // A production subscription of the same project, and one of another project.
+ const prodSub = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tokA2(), body: { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks.example.com/prod' } });
+ assert.strictEqual(prodSub.status, 201, prodSub.text);
+ const bSub = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tokB(), body: { topic_pattern: `app.${keyB}.*`, endpoint: 'https://hooks.example.com/b-project' } });
+ assert.strictEqual(bSub.status, 201);
+ const firstParty = await subscriber();
+ const svcSub = await request(h.base, 'POST', '/api/v1/subscriptions', { token: serviceToken('ops', ['events.subscription.manage']), body: { topic_pattern: 'app.*', endpoint: firstParty.url } });
+ assert.strictEqual(svcSub.status, 201);
+ seen.length = 0;
+
+ const e1 = await publish(tokA(), appEvent(appA, prjA, 'order.shipped'));
+ const pub = await publish(live, envelope('live', { event_type: 'live.stream.started', visibility: 'public' }));
+ await publish(live, envelope('live', { event_type: 'live.stream.ended', visibility: 'internal' }));
+ await h.worker.drain();
+
+ const byPath = (p) => seen.filter(g => g.url === p).map(g => g.headers['x-openvibe-event-id']);
+ assert.deepStrictEqual(byPath('/a'), [e1.body.event_id], 'own sandbox event reached the sandbox subscription');
+ assert.deepStrictEqual(byPath('/b'), [e1.body.event_id], 'app.<key>.order.* matches order.shipped');
+ assert.deepStrictEqual(byPath('/live'), [pub.body.event_id], 'public first-party events only, never internal');
+ assert.deepStrictEqual(byPath('/prod'), [], 'a sandbox event never reaches a production subscription');
+ assert.deepStrictEqual(byPath('/b-project'), [], 'never another project');
+ assert.strictEqual(firstParty.calls.length, 0, 'first-party subscriptions never get sandbox events');
+ const a = seen.find(g => g.url === '/a');
+ assert.ok(verifyDelivery(a.rawBody, a.headers['x-openvibe-signature'], subA.secret), 'HMAC-signed with the subscription secret');
+ assert.strictEqual(JSON.parse(a.rawBody).event.source, apps.appSource(appA));
+ assert.ok(pinned.length && pinned.every(ip => ip === '127.0.0.1'), 'the socket connected to the address the guard checked');
+
+ // A production app event reaches the production subscription and first-party app.* subscribers, not sandbox ones.
+ seen.length = 0;
+ const e2 = await publish(tokA2(), appEvent(appA2, prjA, 'order.created'));
+ await h.worker.drain();
+ assert.deepStrictEqual(byPath('/prod'), [e2.body.event_id]);
+ assert.deepStrictEqual(byPath('/a'), []);
+ assert.deepStrictEqual(firstParty.calls.map(c => c.headers['x-openvibe-event-id']), [e2.body.event_id]);
+ await firstParty.close();
+});
+
+t('delivery-time SSRF guard: re-resolved on every attempt, redirects never followed', async () => {
+ // Real guard (no test allowances): the socket's lookup refuses, so nothing is ever sent.
+ seen.length = 0;
+ const strict = createGuardedPost({ lookup: fakeLookup, requestImpl: (o, cb) => nodeHttp.request({ ...o, port: receiverPort }, cb) });
+ await assert.rejects(strict('https://evil.example.com/x', { headers: {}, body: '{}' }), e => e.permanent === true && /non-public/.test(e.message));
+ assert.strictEqual(seen.length, 0);
+ await assert.rejects(strict('https://10.0.0.1/x', { headers: {}, body: '{}' }), e => e.permanent === true);
+ await assert.rejects(strict('http://hooks.example.com/x', { headers: {}, body: '{}' }), e => e.permanent === true);
+
+ // DNS rebinding: public when subscribed, private when delivered.
+ const r = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tokB(), body: { topic_pattern: `app.${keyB}.rebind.*`, endpoint: 'https://rebind.example.com/hook' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const redir = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tokB(), body: { topic_pattern: `app.${keyB}.redirect.*`, endpoint: 'https://hooks.example.com/redirect' } });
+ assert.strictEqual(redir.status, 201, redir.text);
+ dnsTable.set('rebind.example.com', '10.0.0.9');
+ seen.length = 0;
+ const ev = await publish(tokB(), appEvent(appB, prjB, 'rebind.test'));
+ const ev2 = await publish(tokB(), appEvent(appB, prjB, 'redirect.test'));
+ await h.worker.drain();
+ const d = h.store.getDelivery(ev.body.event_id, r.body.id);
+ assert.strictEqual(d.status, 'dead', 'a private address at delivery time is refused for good');
+ assert.match(d.last_error, /non-public/);
+ const d2 = h.store.getDelivery(ev2.body.event_id, redir.body.id);
+ assert.strictEqual(d2.status, 'failed'); assert.strictEqual(d2.last_status, 302);
+ assert.deepStrictEqual(seen.map(s => s.url).filter(u => u !== '/b-project'), ['/redirect'], 'one request, the redirect was not followed, nothing reached the rebound host');
+});
+
+t('quotas: per-project publish rate and retained bytes', async () => {
+ const h2 = await boot({ appPost: testPost, dnsLookup: fakeLookup, env: { EVENTS_APP_PUBLISH_PER_MINUTE: '3', EVENTS_APP_SANDBOX_RETAINED_BYTES: '3000' } });
+ try {
+ const pub = (token, body) => request(h2.base, 'POST', '/api/v1/events', { token, body });
+ for (let i = 0; i < 3; i++) assert.strictEqual((await pub(tokA2(), appEvent(appA2, prjA))).status, 201);
+ let r = await pub(tokA2(), appEvent(appA2, prjA));
+ assert.strictEqual(r.status, 429); assert.strictEqual(r.body.code, 'events.quota_exceeded');
+ assert.strictEqual(r.body.quota, 'publish_rate'); assert.strictEqual(r.headers.get('retry-after'), '60');
+ r = await pub(appToken({ projectId: prjB, env: 'production', cap: ALL, appId: appB }), appEvent(appB, prjB));
+ assert.strictEqual(r.status, 201, 'another project has its own quota');
+ h2.clock.advance(61 * 1000);
+ assert.strictEqual((await pub(tokA2(), appEvent(appA2, prjA))).status, 201, 'the window moves on');
+
+ const big = { blob: 'x'.repeat(1200) };
+ assert.strictEqual((await pub(tokA(), appEvent(appA, prjA, 'big.one', { payload: big }))).status, 201);
+ assert.strictEqual((await pub(tokA(), appEvent(appA, prjA, 'big.two', { payload: big }))).status, 201);
+ r = await pub(tokA(), appEvent(appA, prjA, 'big.three', { payload: big }));
+ assert.strictEqual(r.status, 429); assert.strictEqual(r.body.quota, 'retained_bytes');
+ r = await pub(tokA(), { events: [appEvent(appA, prjA, 'small.one')] });
+ assert.strictEqual(r.status, 201, 'small events still fit');
+ } finally {
+ await h2.stop();
+ }
+});
+
+t('revocation from Network stops an app\'s subscriptions and old tokens', async () => {
+ const network = serviceToken('network', ['events.event.publish']);
+ const future = new Date(Date.now() + 2000).toISOString();
+ let r = await publish(network, envelope('network', {
+ event_type: 'network.grant.changed', actor: { type: 'system', id: 'network' }, timestamp: future,
+ subject: { type: 'app', id: appB }, payload: { project_id: prjB, capability: 'events.app.subscribe', audience: 'openvibe.events', from: 'approved', to: 'revoked' },
+ }));
+ assert.strictEqual(r.status, 201, r.text);
+ const subsB = (await request(h.base, 'GET', '/api/v1/subscriptions', { token: tokB() })).body.subscriptions;
+ assert.ok(subsB.length && subsB.every(s => !s.enabled), 'every subscription of the app is disabled');
+ r = await request(h.base, 'POST', `/api/v1/subscriptions/${subsB[0].id}/enable`, { token: tokB() });
+ assert.strictEqual(r.status, 403, 'a token issued before the revocation cannot re-enable');
+ r = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tokB(), body: { topic_pattern: `app.${keyB}.again`, endpoint: 'https://hooks.example.com/again' } });
+ assert.strictEqual(r.status, 403, '...nor create one');
+ assert.strictEqual((await publish(tokB(), appEvent(appB, prjB))).status, 201, 'publishing is a different grant');
+ const later = tokB({ iat: Math.floor(Date.now() / 1000) + 10 });
+ r = await request(h.base, 'POST', `/api/v1/subscriptions/${subsB[0].id}/enable`, { token: later });
+ assert.strictEqual(r.status, 200, 'a token issued after a re-approval may');
+
+ r = await publish(network, envelope('network', {
+ event_type: 'network.app.revoked', actor: { type: 'system', id: 'network' }, timestamp: future,
+ subject: { type: 'app', id: appA }, payload: { project_id: prjA, environment: 'sandbox', reason: 'test' },
+ }));
+ assert.strictEqual(r.status, 201, r.text);
+ assert.ok(h.store.listSubscriptions(`app:${appA}`).every(s => !s.enabled));
+ r = await pull(tokA(), `app.${keyA}.*`);
+ assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.revoked');
+ r = await publish(tokA(), appEvent(appA, prjA));
+ assert.strictEqual(r.status, 401);
+ // Only svc:network speaks for Network, and the hook ignores app-published look-alikes.
+ assert.strictEqual((await pull(tokA2(), `app.${keyA}.*`)).status, 200, 'other apps of the project are untouched');
+});
+
+t('realtime never streams app events, to anyone', async () => {
+ const { sse } = require('./helpers');
+ const svc = await sse(h.base, '/realtime/stream?topics=app.*,live.*', { headers: { Authorization: `Bearer ${serviceToken('ops', ['events.event.read'])}` } });
+ const anon = await sse(h.base, `/realtime/stream?topics=app.${keyA}.*,live.*`);
+ await publish(tokA2(), appEvent(appA2, prjA, 'order.realtime', { visibility: 'public' }));
+ const marker = await publish(live, envelope('live', { event_type: 'live.stream.started', visibility: 'public' }));
+ await svc.waitFor(c => c.events().some(e => e.event.event_id === marker.body.event_id));
+ await anon.waitFor(c => c.events().some(e => e.event.event_id === marker.body.event_id));
+ assert.ok(!svc.events().some(e => e.event.event_type.startsWith('app.')));
+ assert.ok(!anon.events().some(e => e.event.event_type.startsWith('app.')));
+ svc.close(); anon.close();
+});
+
+t('stop', async () => {
+ await h.stop();
+ await new Promise(r => receiver.close(r));
+ await sleep(10);
+});
+
+t.run();
@@ -29,6 +29,16 @@ function serviceToken(slug, cap, { aud = 'openvibe.events', exp = Math.floor(Dat
@@ -29,6 +29,16 @@ function serviceToken(slug, cap, { aud = 'openvibe.events', exp = Math.floor(Dat
}, key);
}, key);
}
}
+/** A developer-app token as Network mints it (ADR-014): sub app:app_…, project_id, env, ns [project_id]. */
+function appToken({ appId = ids.newId('app'), projectId = `prj_${ids.ulid()}`, env = 'sandbox', cap = [], onBehalfOf, iat, exp, key = privateKey } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({
+ iss: ISSUER, sub: `app:${appId}`, actor_type: 'app', aud: ['openvibe.events'], cap, ns: [projectId], project_id: projectId, env,
+ ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),
+ iat: iat ?? now, exp: exp ?? now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
+ }, key);
+}
+
function userToken({ subjectId = ids.newId('user'), aud = ['openvibe.live', 'openvibe.network'], exp = Math.floor(Date.now() / 1000) + 3600, key = privateKey } = {}) {
function userToken({ subjectId = ids.newId('user'), aud = ['openvibe.live', 'openvibe.network'], exp = Math.floor(Date.now() / 1000) + 3600, key = privateKey } = {}) {
return serviceAuth.signServiceToken({
return serviceAuth.signServiceToken({
sub: 57, id: 57, subject_id: subjectId, username: 'viewer', role: 'user', iss: ISSUER, aud, iat: Math.floor(Date.now() / 1000), exp,
sub: 57, id: 57, subject_id: subjectId, username: 'viewer', role: 'user', iss: ISSUER, aud, iat: Math.floor(Date.now() / 1000), exp,
@@ -50,7 +60,7 @@ function tmpDir() {
@@ -50,7 +60,7 @@ function tmpDir() {
}
}
/** Boot the service. env overrides go through config.load(); `worker: 'manual'` keeps the loop off. */
/** Boot the service. env overrides go through config.load(); `worker: 'manual'` keeps the loop off. */
-async function boot({ env = {}, clock = manualClock(), worker = 'manual', fetchImpl, deliveryFetch } = {}) {
+async function boot({ env = {}, clock = manualClock(), worker = 'manual', fetchImpl, deliveryFetch, appPost, dnsLookup } = {}) {
const dir = tmpDir();
const dir = tmpDir();
const config = load({
const config = load({
NODE_ENV: 'test',
NODE_ENV: 'test',
@@ -60,7 +70,7 @@ async function boot({ env = {}, clock = manualClock(), worker = 'manual', fetchI
@@ -60,7 +70,7 @@ async function boot({ env = {}, clock = manualClock(), worker = 'manual', fetchI
EVENTS_WORKER: worker === 'manual' ? 'off' : 'on',
EVENTS_WORKER: worker === 'manual' ? 'off' : 'on',
...env,
...env,
});
});
- const h = await start({ config, clock, log: silent, fetchImpl, deliveryFetch });
+ const h = await start({ config, clock, log: silent, fetchImpl, deliveryFetch, appPost, dnsLookup });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
return {
return {
...h, base, clock, dir,
...h, base, clock, dir,
@@ -189,6 +199,6 @@ function suite(name) {
@@ -189,6 +199,6 @@ function suite(name) {
}
}
module.exports = {
module.exports = {
- ISSUER, privateKey, publicKey, silent, serviceToken, userToken, manualClock, tmpDir, boot, request,
+ ISSUER, privateKey, publicKey, silent, serviceToken, appToken, userToken, manualClock, tmpDir, boot, request,
envelope, subscriber, sse, sleep, suite,
envelope, subscriber, sse, sleep, suite,
};
};
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
const assert = require('assert');
const assert = require('assert');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const { boot, request, serviceToken, envelope, suite } = require('./helpers');
+const { boot, request, serviceToken, appToken, envelope, suite } = require('./helpers');
const t = suite('publish');
const t = suite('publish');
let h;
let h;
@@ -78,11 +78,18 @@ t('unknown source (not in the manifest map) -> 403', async () => {
@@ -78,11 +78,18 @@ t('unknown source (not in the manifest map) -> 403', async () => {
assert.strictEqual(r.body.code, 'events.unknown_source');
assert.strictEqual(r.body.code, 'events.unknown_source');
});
});
-t('app principals cannot publish', async () => {
- const r = await request(h.base, 'POST', '/api/v1/events', {
- token: serviceToken('x', ['events.event.publish'], { sub: `app:${ids.newId('app')}` }), body: envelope('live'),
+t('app principals cannot publish as a service', async () => {
+ // A first-party capability in an app token is never honoured (apps publish with events.app.publish).
+ let r = await request(h.base, 'POST', '/api/v1/events', {
+ token: appToken({ cap: ['events.event.publish'], env: 'production' }), body: envelope('live'),
});
});
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
+ // An app token without a project is not a usable app token at all.
+ r = await request(h.base, 'POST', '/api/v1/events', {
+ token: serviceToken('x', ['events.event.publish'], { sub: `app:${ids.newId('app')}` }), body: envelope('live'),
+ });
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.body.code, 'token.invalid_claims');
});
});
t('idempotent repeat: same event_id -> 200, same seq, stored once', async () => {
t('idempotent repeat: same event_id -> 200, same seq, stored once', async () => {