openvibers/openvibe.contracts Commits
v0.61.0: a person's realtime topic and realtime tickets (roadmap WS-E task 3, WS-F task 1; ADR-005 amendment 2). New event type network.notification.created (payload network.notification.created@1): Network's outbox, in the transaction that stores a notification; subject the recipient, visibility subject, actor system:network; id, type, category, priority, service, created_at and the unread count, never the title, message, link or sender. "user:<id>" is that event type with subject visibility: the badge subscribes to network.notification.* and gets its own only. New identity.realtime-ticket-claims@1 (a two-minute single-use RS256 ticket for Events' /realtime/stream?ticket=; issuer <network>/realtime, typ and purpose realtime, audience openvibe.events only, so it never passes for a session) and network.realtime-ticket-result@1 (POST /api/v1/realtime/ticket). Network manifest produces the event; fixtures; npm test checks
Changes
@@@ -6,7 +6,17 @@ the notes were in the tag and commit messages (`git tag -n1`).## Unreleased--- ADR-007 amendment 2026-09-26: SQLite recorded as the reviewed store for every service, with Events', Chat's and Billing's measured production load and the thresholds that would move a service to PostgreSQL (WS-S task 1). Documentation only.++## 0.61.0 — 2026-09-26++++**A person's realtime topic and realtime tickets** (roadmap WS-E task 3, WS-F task 1; ADR-005 amendment 2).++- **New event type `network.notification.created`**, payload `network.notification.created@1`. Network writes it to its outbox in the transaction that stores a notification. Envelope: subject `{ type: user, id: <recipient usr_> }`, visibility `subject`, actor `system:network`. The payload is the id, type, category, priority, service, `created_at` and the recipient's unread count; never the title, message, link or sender. The Network manifest produces it. "`user:<id>`" in the roadmap is this event type plus subject visibility: a browser subscribes to `network.notification.*` and receives its own events only.++- **New `identity.realtime-ticket-claims@1`**: the two-minute, single-use RS256 ticket with which a browser opens Events' `/realtime/stream?ticket=…`. Its claims are `iss <network issuer>/realtime`, `sub <usr_>`, `aud [openvibe.events]`, `typ realtime`, `purpose realtime`, `iat`, `exp` and `jti rtk_…`. Three rules each keep it from passing for a session: the issuer, `typ` and the audience.++- **New `network.realtime-ticket-result@1`**: what `POST /api/v1/realtime/ticket` answers. That is the ticket, `expires_at`, `expires_in` (at most 300), `stream_url`, `topics` (Events patterns only, never `user:…`) and `subject`.++- The Events manifest's notes name the ticket. There are fixtures for all three contracts, and `npm test` checks that the event never carries notification text or the sender, and that a ticket never has a session's issuer or claims.++++Additive.++++Also in this release, ADR-007 amendment 2026-09-26: SQLite recorded as the reviewed store for every service, with Events', Chat's and Billing's measured production load and the thresholds that would move a service to PostgreSQL (WS-S task 1). Documentation only.## 0.60.0 — 2026-09-26@@@ -31,6 +31,16 @@"status": "active","adr": "ADR-003"},++ {++ "id": "identity.realtime-ticket-claims",++ "version": "1.0.0",++ "owner": "network",++ "schema": "identity/realtime-ticket-claims.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-005"++ },{"id": "identity.resolve-request","version": "1.0.0",@@@ -2393,6 +2403,16 @@"compatibility": "backward","status": "active"},++ {++ "id": "network.notification.created",++ "version": "1.0.0",++ "owner": "network",++ "schema": "events/payloads/network.notification.created.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-005"++ },{"id": "network.blocks-result","version": "1.0.0",@@@ -2465,6 +2485,16 @@"compatibility": "backward","status": "active"},++ {++ "id": "network.realtime-ticket-result",++ "version": "1.0.0",++ "owner": "network",++ "schema": "network/realtime-ticket-result.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active",++ "adr": "ADR-005"++ },{"id": "network.staff-list-result","version": "1.0.0",nnew file mode 100644@@@ -0,0 +1,60 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/network.notification.created.v1.json",++ "title": "NetworkNotificationCreatedPayload",++ "description": "network.notification.created v1 (OpenVibe.Network server/notifications/notification-service.js; roadmap WS-E task 3, ADR-005 amendment 2). A notification was added to a person's inbox (Network is the only notification store, ADR-020). Written to Network's outbox in the transaction that stores the notification, so it exists if and only if the notification does; a muted category, a blocked sender or a go-live dedupe creates neither. Envelope: subject { type: user, id: <recipient usr_> }, visibility subject, actor system:network (never the sender: Events streams a subject event to its actor too), priority low. It is the person's realtime topic (\"user:<id>\" in the roadmap): a browser subscribes to `network.notification.*` on Events' /realtime/stream with a realtime ticket (identity.realtime-ticket-claims@1) and receives only its own; anyone else's subscription to the same pattern yields nothing. It carries what the notification badge needs and nothing it shows: never the title, the message, the link or the sender. The badge re-reads the unread count and the newest items from Network's API, which stays authoritative (reads, dismissals, other devices). Guests (anonymous accounts) and accounts without a usr_ subject get no event.",++ "type": "object",++ "required": [++ "notification_id",++ "type",++ "category",++ "priority",++ "created_at",++ "unread_count"++ ],++ "additionalProperties": false,++ "properties": {++ "notification_id": {++ "type": "string",++ "pattern": "^[A-Za-z0-9_-]{1,64}$",++ "description": "The notification's id in Network's store (a UUID today): the id GET /api/notifications returns and POST /api/notifications/:id/read takes."++ },++ "type": {++ "type": "string",++ "pattern": "^[A-Z][A-Z0-9_]{1,63}$",++ "description": "The notification type (openvibe-shared notifications TYPES, e.g. STREAM_LIVE, CONTENT_REPLY, DEAL_WATCH_MATCH); GENERIC when the stored type has another shape."++ },++ "category": {++ "type": "string",++ "pattern": "^[a-z][a-z0-9_]{1,31}$",++ "description": "The preference category it was filed under (social, chat, stream, service, moderation, system, ...)."++ },++ "priority": {++ "enum": [++ "low",++ "normal",++ "high",++ "critical"++ ],++ "description": "The notification's priority (the badge may ring or toast on high and critical)."++ },++ "service": {++ "type": [++ "string",++ "null"++ ],++ "pattern": "^[a-z][a-z0-9-]{1,39}$",++ "description": "The service it is about (live, deals, trade, ...), or null."++ },++ "created_at": {++ "type": "string",++ "format": "date-time"++ },++ "unread_count": {++ "type": "integer",++ "minimum": 0,++ "maximum": 9007199254740991,++ "description": "The recipient's unread count right after this notification was stored, so a badge can update without a request. A later read elsewhere is not an event: the badge re-reads the count on focus and on its fallback poll."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,61 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/identity/realtime-ticket-claims.v1.json",++ "title": "RealtimeTicketClaims",++ "description": "Claims of a realtime ticket (ADR-005 amendment 2; roadmap WS-E task 3, WS-F task 1): a two-minute RS256 JWT that OpenVibe.Network signs for the signed-in person (POST /api/v1/realtime/ticket) so a browser on any OpenVibe site can open Events' /realtime/stream?ticket=... as that person. A browser cannot put a header on an EventSource, and the ov_token cookie of events.openvibe.network is third-party on every other site, so the ticket travels in the URL; that is why it is short-lived, single-purpose and single-use. It is never a session token, by three independent rules: its issuer is Network's issuer followed by /realtime (every service checks the issuer of a session token), it carries typ (services refuse a session token that has one, as for FedCM assertions), and its only audience is openvibe.events. Events accepts it only as ?ticket= on /realtime/stream, verifies the signature with Network's key, iss, aud, typ, purpose and expiry (lifetime at most 300 s), refuses a jti it has already seen while the ticket is valid, and never logs it. The connection then sees what the person's session would: public events and subject events addressed to `sub`.",++ "type": "object",++ "required": [++ "iss",++ "sub",++ "aud",++ "typ",++ "purpose",++ "iat",++ "exp",++ "jti"++ ],++ "additionalProperties": false,++ "properties": {++ "iss": {++ "type": "string",++ "format": "uri",++ "pattern": "^https?://[^/?#]+/realtime$",++ "description": "Network's issuer followed by /realtime (https://openvibe.network/realtime in production): not the issuer of a session."++ },++ "sub": {++ "type": "string",++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",++ "description": "The person's subject id; subject-visibility events for this subject reach the connection."++ },++ "aud": {++ "type": "array",++ "minItems": 1,++ "maxItems": 1,++ "items": {++ "const": "openvibe.events"++ }++ },++ "typ": {++ "const": "realtime",++ "description": "Token class. A service that finds typ on a Network token does not treat it as a session."++ },++ "purpose": {++ "const": "realtime",++ "description": "Single purpose: opening one realtime stream."++ },++ "iat": {++ "type": "integer",++ "minimum": 0++ },++ "exp": {++ "type": "integer",++ "minimum": 0,++ "description": "iat + 120 as Network mints it; Events refuses a lifetime over 300 s."++ },++ "jti": {++ "type": "string",++ "pattern": "^rtk_[0-9a-f]{24}$",++ "description": "Unique per ticket; Events accepts each once."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,55 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/network/realtime-ticket-result.v1.json",++ "title": "NetworkRealtimeTicketResult",++ "description": "What POST /api/v1/realtime/ticket answers (OpenVibe.Network; ADR-005 amendment 2): a realtime ticket (identity.realtime-ticket-claims@1) for the signed-in person, and where to use it. The caller is the person's own session (Bearer Network JWT, or the ov_token cookie on openvibe.network); guests are refused (403 realtime.guest), and 503 realtime.disabled means the operator turned browser realtime off (REALTIME_TICKETS=off): the client stays on polling. The client opens `${stream_url}?topics=${topics}&ticket=${ticket}` (plus last_event_id when it resumes) at once, and asks for a fresh ticket for every reconnect: a ticket opens one stream.",++ "type": "object",++ "required": [++ "ticket",++ "expires_at",++ "expires_in",++ "stream_url",++ "topics",++ "subject"++ ],++ "additionalProperties": false,++ "properties": {++ "ticket": {++ "type": "string",++ "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$",++ "maxLength": 4096,++ "description": "The signed ticket (a compact JWS). Never logged, never stored."++ },++ "expires_at": {++ "type": "string",++ "format": "date-time"++ },++ "expires_in": {++ "type": "integer",++ "minimum": 1,++ "maximum": 300,++ "description": "Seconds until the ticket expires (120)."++ },++ "stream_url": {++ "type": "string",++ "format": "uri",++ "pattern": "^https?://[^?#]+/realtime/stream$",++ "description": "Events' realtime stream (https://events.openvibe.network/realtime/stream)."++ },++ "topics": {++ "type": "array",++ "minItems": 1,++ "maxItems": 20,++ "items": {++ "type": "string",++ "pattern": "^(\\*|[a-z0-9_]+)(\\.(\\*|[a-z0-9_]+))*$"++ },++ "description": "The topic patterns the notification badge subscribes to: [\"network.notification.*\"]."++ },++ "subject": {++ "type": "string",++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",++ "description": "Whose ticket it is; a client ignores any event whose subject is someone else."++ }++ }++}@@@ -47,3 +47,34 @@ Guessed private topic yields nothing; reconnect from a cursor gets the complete**Consequences.** A product that shows "online" (a profile, a DM list) calls Chat's read and caches it for seconds at most. When realtime moves out of Events (the original decision's revisit clause), presence stays with whatever holds chat connections.**Acceptance.** A connected person reads `online`. A hidden person reads `offline` while connected. A person whose preference is unknown reads `offline`. No `chat.presence*` event type exists in the catalog (Chat `test/chat-modules.test.js`, Contracts `npm test`).++++## Amendment 2 (2026-09-26): a person's topic, and realtime tickets++++**Status:** Accepted (roadmap WS-E task 3, WS-F task 1).++++**Decision.**++- **"`user:<id>`" is not a topic name.** A topic is an event type pattern (`topics.js` in Events), and `user:…` is not a valid pattern (400 `realtime.bad_topic`). A person's topic is the pair *event type + subject visibility*. Network publishes **`network.notification.created`** (`network.notification.created@1`) through its outbox, in the transaction that stores the notification. The event has subject `{ type: user, id: <recipient usr_> }`, visibility `subject` and actor `system:network`. Events streams a subject event only to the viewer whose subject is the event's subject or actor, so the actor is never the sender. The notification badge subscribes to `network.notification.*` and receives its own notifications and nobody else's. Another person's stream on the same pattern, a broader pattern (`*`) or a guessed one yields nothing. Later per-person types (`network.notification.read`, a digest) join the same pattern.++- **The payload is what the badge needs, never what it shows.** It carries the id, type, category, priority, service, `created_at` and the unread count after the insert. It never carries the title, message, link or sender. The badge re-reads the count and the newest items from Network's API, which stays authoritative. Events keeps the event for its retention period, so nothing private is put in it.++- **Browsers authenticate with a realtime ticket** (`identity.realtime-ticket-claims@1`). An EventSource cannot send a header, and the `ov_token` cookie of events.openvibe.network is third-party on every other site, where browsers increasingly drop it. So Network mints a ticket for the signed-in person: `POST /api/v1/realtime/ticket` answers `network.realtime-ticket-result@1`. The caller authenticates with a Bearer Network JWT, or the `ov_token` cookie on openvibe.network. The ticket is an RS256 JWT with `iss <network issuer>/realtime`, `sub <usr_>`, `aud [openvibe.events]`, `typ realtime`, `purpose realtime`, a lifetime of 120 s and `jti rtk_…`. The browser opens `/realtime/stream?topics=…&ticket=…`.++- **A ticket is never a session.** Three rules each refuse it as one: its issuer is not the session issuer, it carries `typ`, and its only audience is Events. Events accepts it only as `?ticket=` on the stream, never as Bearer or cookie, and accepts each `jti` once while the ticket is valid. It never logs the ticket. A reconnect asks for a fresh ticket and resumes with `last_event_id`, so a ticket that leaked into a proxy log is spent or expired. Guests get no ticket (403 `realtime.guest`). `REALTIME_TICKETS=off` on Network answers 503 `realtime.disabled`, and every badge stays on polling.++- **The badge** (openvibe-shared `notification-live.js`, loaded by `notification-ui.js` when a site sets `notificationsRealtime: true`) behaves as follows:++ - On a `network.notification.*` event whose subject is its own, it re-reads the count and toasts what is new. On `event: gap` it re-reads the count and the open lists.++ - Errors back off from 2 s to 15 min, with jitter. A tab hidden for 5 min closes the stream and resumes from its cursor when shown.++ - A Content-Security-Policy refusal, or eight failures in a row, leaves polling only.++ - Polling stays the fallback: every 15 s without a stream, and every 2 min while the stream is open, as a safety net for reads made on other sites.++++**Alternatives considered.**++- A `user:<id>` topic namespace in Events: rejected. It duplicates what subject visibility already enforces, and it invites exactly the guessing the visibility rule makes useless.++- The session JWT in the URL (`?access_token=`): rejected. It is valid for hours, is accepted as a session everywhere, and URLs end up in logs.++- Cookies on events.openvibe.network with `withCredentials`: kept for pages on openvibe.network itself and for Bearer clients, but not relied on across sites (third-party cookie blocking).++- The notification text in the event: rejected (ADR-020: Network renders, and Events retains).++++**Consequences.** Every notification is one more event in Events' log (a go-live to N followers is N events). The outbox relays them in batches of 50, and Events' retention and redaction apply. Where Network's relay is off, the rows wait in `network_event_outbox`. The badge still works by polling.++++**Acceptance.**++- A ticket for A never receives B's `network.notification.created`, on any pattern.++- An expired ticket, a ticket for another audience or purpose, a reused ticket and a session JWT passed as a ticket are refused. A ticket passed as Bearer is refused.++- A disconnect resumes from `last_event_id` with the missed events, and a cursor beyond retention gets `event: gap`.++- A stored notification and its event commit together or not at all.++++These are Events `test/realtime-tickets.test.js`, Network `test/notification-events.test.js` and Shared `test/notification-live.test.js`.@@@ -8,7 +8,7 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each| [ADR-002](ADR-002-contract-repository.md) | Contract repository and compatibility policy | Accepted, implemented 2026-09-22 (openvibe-contracts v0.1.0+) || [ADR-003](ADR-003-service-principals.md) | Service/app principal authentication and capability grants | Accepted, implemented 2026-09-22; amended 2026-09-24 (delegated authorization and approval UX) || [ADR-004](ADR-004-durable-events.md) | Durable event store, outbox/inbox and delivery semantics | Accepted 2026-09-22; implementation in progress (OpenVibe.Events) |--| [ADR-005](ADR-005-realtime.md) | Realtime topics, cursor/resume and whether OpenVibe.Realtime exists | Accepted 2026-09-22: realtime runs inside OpenVibe.Events; amendment 1 (2026-09-26): presence is ephemeral, in Chat's delivery plane |++| [ADR-005](ADR-005-realtime.md) | Realtime topics, cursor/resume and whether OpenVibe.Realtime exists | Accepted 2026-09-22: realtime runs inside OpenVibe.Events; amendment 1 (2026-09-26): presence is ephemeral, in Chat's delivery plane; amendment 2 (2026-09-26): a person's topic is `network.notification.*` with subject visibility, and browsers authenticate with two-minute realtime tickets || [ADR-006](ADR-006-media-objects.md) | Media object, location and namespace model | Accepted 2026-09-22; implementation in progress (OpenVibe.Media Wave 4); amended 2026-09-24 (placement classes, local provider, infrequent access deferred) || [ADR-007](ADR-007-data-ownership.md) | Logical data ownership and database technology | Accepted 2026-09-22; amended 2026-09-24 (PgBouncer, replicas, SERIALIZABLE money paths, Redis non-authoritative) || [ADR-008](ADR-008-shared-packages.md) | Shared package publication and release manifests | Accepted 2026-09-22; implementation in progress (OpenVibe.Shared) |nnew file mode 100644@@@ -0,0 +1,9 @@++{++ "iss": "https://openvibe.network/realtime",++ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",++ "aud": ["openvibe.events"],++ "purpose": "realtime",++ "iat": 1790000000,++ "exp": 1790000120,++ "jti": "rtk_0123456789abcdef01234567"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "iss": "https://openvibe.network/realtime",++ "sub": "57",++ "aud": ["openvibe.events"],++ "typ": "realtime",++ "purpose": "realtime",++ "iat": 1790000000,++ "exp": 1790000120,++ "jti": "rtk_0123456789abcdef01234567"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "iss": "https://openvibe.network/realtime",++ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",++ "aud": ["openvibe.events", "openvibe.live"],++ "typ": "realtime",++ "purpose": "realtime",++ "iat": 1790000000,++ "exp": 1790000120,++ "jti": "rtk_0123456789abcdef01234567"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "iss": "https://openvibe.network/realtime",++ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",++ "aud": ["openvibe.events"],++ "typ": "realtime",++ "purpose": "session",++ "iat": 1790000000,++ "exp": 1790000120,++ "jti": "rtk_0123456789abcdef01234567"++}nnew file mode 100644@@@ -0,0 +1,12 @@++{++ "iss": "https://openvibe.network/realtime",++ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",++ "aud": ["openvibe.events"],++ "typ": "realtime",++ "purpose": "realtime",++ "iat": 1790000000,++ "exp": 1790000120,++ "jti": "rtk_0123456789abcdef01234567",++ "username": "alice",++ "role": "admin"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "iss": "https://openvibe.network",++ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",++ "aud": ["openvibe.events"],++ "typ": "realtime",++ "purpose": "realtime",++ "iat": 1790000000,++ "exp": 1790000120,++ "jti": "rtk_0123456789abcdef01234567"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "iss": "https://openvibe.network/realtime",++ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",++ "aud": ["openvibe.events"],++ "typ": "realtime",++ "purpose": "realtime",++ "iat": 1790000000,++ "exp": 1790000120,++ "jti": "rtk_0123456789abcdef01234567"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",++ "type": "CONTENT_REPLY",++ "category": "social",++ "priority": "normal",++ "service": "live",++ "created_at": "2026-09-26T18:03:08.000Z",++ "unread_count": 1,++ "sender_id": 20++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",++ "type": "STREAM_LIVE",++ "category": "stream",++ "priority": "normal",++ "service": "live",++ "created_at": "2026-09-26T18:03:08.000Z",++ "unread_count": 4,++ "title": "carol is live!"++}nnew file mode 100644@@@ -0,0 +1,9 @@++{++ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",++ "type": "stream_live",++ "category": "stream",++ "priority": "normal",++ "service": "live",++ "created_at": "2026-09-26T18:03:08.000Z",++ "unread_count": 4++}nnew file mode 100644@@@ -0,0 +1,8 @@++{++ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",++ "type": "STREAM_LIVE",++ "category": "stream",++ "priority": "normal",++ "service": "live",++ "created_at": "2026-09-26T18:03:08.000Z"++}nnew file mode 100644@@@ -0,0 +1,9 @@++{++ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",++ "type": "STREAM_LIVE",++ "category": "stream",++ "priority": "urgent",++ "service": "live",++ "created_at": "2026-09-26T18:03:08.000Z",++ "unread_count": 4++}nnew file mode 100644@@@ -0,0 +1,9 @@++{++ "notification_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",++ "type": "WELCOME",++ "category": "system",++ "priority": "low",++ "service": null,++ "created_at": "2026-09-26T18:03:08.000Z",++ "unread_count": 0++}nnew file mode 100644@@@ -0,0 +1,9 @@++{++ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",++ "type": "STREAM_LIVE",++ "category": "stream",++ "priority": "normal",++ "service": "live",++ "created_at": "2026-09-26T18:03:08.000Z",++ "unread_count": 4++}nnew file mode 100644@@@ -0,0 +1,8 @@++{++ "ticket": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXAiOiJyZWFsdGltZSJ9.c2lnbmF0dXJl",++ "expires_at": "2026-09-27T18:03:08.000Z",++ "expires_in": 86400,++ "stream_url": "https://events.openvibe.network/realtime/stream",++ "topics": ["network.notification.*"],++ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"++}nnew file mode 100644@@@ -0,0 +1,8 @@++{++ "ticket": "not a token",++ "expires_at": "2026-09-26T18:05:08.000Z",++ "expires_in": 120,++ "stream_url": "https://events.openvibe.network/realtime/stream",++ "topics": ["network.notification.*"],++ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"++}nnew file mode 100644@@@ -0,0 +1,8 @@++{++ "ticket": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXAiOiJyZWFsdGltZSJ9.c2lnbmF0dXJl",++ "expires_at": "2026-09-26T18:05:08.000Z",++ "expires_in": 120,++ "stream_url": "https://events.openvibe.network/realtime/stream?ticket=x",++ "topics": ["network.notification.*"],++ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"++}nnew file mode 100644@@@ -0,0 +1,8 @@++{++ "ticket": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXAiOiJyZWFsdGltZSJ9.c2lnbmF0dXJl",++ "expires_at": "2026-09-26T18:05:08.000Z",++ "expires_in": 120,++ "stream_url": "https://events.openvibe.network/realtime/stream",++ "topics": ["user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"],++ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"++}nnew file mode 100644@@@ -0,0 +1,8 @@++{++ "ticket": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXAiOiJyZWFsdGltZSJ9.c2lnbmF0dXJl",++ "expires_at": "2026-09-26T18:05:08.000Z",++ "expires_in": 120,++ "stream_url": "https://events.openvibe.network/realtime/stream",++ "topics": ["network.notification.*"],++ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"++}@@@ -272,6 +272,72 @@]}},++ "identity.realtime-ticket-claims": {++ "version": "1.0.0",++ "owner": "network",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/identity/realtime-ticket-claims.v1.json",++ "title": "RealtimeTicketClaims",++ "description": "Claims of a realtime ticket (ADR-005 amendment 2; roadmap WS-E task 3, WS-F task 1): a two-minute RS256 JWT that OpenVibe.Network signs for the signed-in person (POST /api/v1/realtime/ticket) so a browser on any OpenVibe site can open Events' /realtime/stream?ticket=... as that person. A browser cannot put a header on an EventSource, and the ov_token cookie of events.openvibe.network is third-party on every other site, so the ticket travels in the URL; that is why it is short-lived, single-purpose and single-use. It is never a session token, by three independent rules: its issuer is Network's issuer followed by /realtime (every service checks the issuer of a session token), it carries typ (services refuse a session token that has one, as for FedCM assertions), and its only audience is openvibe.events. Events accepts it only as ?ticket= on /realtime/stream, verifies the signature with Network's key, iss, aud, typ, purpose and expiry (lifetime at most 300 s), refuses a jti it has already seen while the ticket is valid, and never logs it. The connection then sees what the person's session would: public events and subject events addressed to `sub`.",++ "type": "object",++ "required": [++ "iss",++ "sub",++ "aud",++ "typ",++ "purpose",++ "iat",++ "exp",++ "jti"++ ],++ "additionalProperties": false,++ "properties": {++ "iss": {++ "type": "string",++ "format": "uri",++ "pattern": "^https?://[^/?#]+/realtime$",++ "description": "Network's issuer followed by /realtime (https://openvibe.network/realtime in production): not the issuer of a session."++ },++ "sub": {++ "type": "string",++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",++ "description": "The person's subject id; subject-visibility events for this subject reach the connection."++ },++ "aud": {++ "type": "array",++ "minItems": 1,++ "maxItems": 1,++ "items": {++ "const": "openvibe.events"++ }++ },++ "typ": {++ "const": "realtime",++ "description": "Token class. A service that finds typ on a Network token does not treat it as a session."++ },++ "purpose": {++ "const": "realtime",++ "description": "Single purpose: opening one realtime stream."++ },++ "iat": {++ "type": "integer",++ "minimum": 0++ },++ "exp": {++ "type": "integer",++ "minimum": 0,++ "description": "iat + 120 as Network mints it; Events refuses a lifetime over 300 s."++ },++ "jti": {++ "type": "string",++ "pattern": "^rtk_[0-9a-f]{24}$",++ "description": "Unique per ticket; Events accepts each once."++ }++ }++ }++ },"identity.resolve-request": {"version": "1.0.0","owner": "network",@@@ -25308,6 +25374,71 @@}}},++ "network.notification.created": {++ "version": "1.0.0",++ "owner": "network",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/network.notification.created.v1.json",++ "title": "NetworkNotificationCreatedPayload",++ "description": "network.notification.created v1 (OpenVibe.Network server/notifications/notification-service.js; roadmap WS-E task 3, ADR-005 amendment 2). A notification was added to a person's inbox (Network is the only notification store, ADR-020). Written to Network's outbox in the transaction that stores the notification, so it exists if and only if the notification does; a muted category, a blocked sender or a go-live dedupe creates neither. Envelope: subject { type: user, id: <recipient usr_> }, visibility subject, actor system:network (never the sender: Events streams a subject event to its actor too), priority low. It is the person's realtime topic (\"user:<id>\" in the roadmap): a browser subscribes to `network.notification.*` on Events' /realtime/stream with a realtime ticket (identity.realtime-ticket-claims@1) and receives only its own; anyone else's subscription to the same pattern yields nothing. It carries what the notification badge needs and nothing it shows: never the title, the message, the link or the sender. The badge re-reads the unread count and the newest items from Network's API, which stays authoritative (reads, dismissals, other devices). Guests (anonymous accounts) and accounts without a usr_ subject get no event.",++ "type": "object",++ "required": [++ "notification_id",++ "type",++ "category",++ "priority",++ "created_at",++ "unread_count"++ ],++ "additionalProperties": false,++ "properties": {++ "notification_id": {++ "type": "string",++ "pattern": "^[A-Za-z0-9_-]{1,64}$",++ "description": "The notification's id in Network's store (a UUID today): the id GET /api/notifications returns and POST /api/notifications/:id/read takes."++ },++ "type": {++ "type": "string",++ "pattern": "^[A-Z][A-Z0-9_]{1,63}$",++ "description": "The notification type (openvibe-shared notifications TYPES, e.g. STREAM_LIVE, CONTENT_REPLY, DEAL_WATCH_MATCH); GENERIC when the stored type has another shape."++ },++ "category": {++ "type": "string",++ "pattern": "^[a-z][a-z0-9_]{1,31}$",++ "description": "The preference category it was filed under (social, chat, stream, service, moderation, system, ...)."++ },++ "priority": {++ "enum": [++ "low",++ "normal",++ "high",++ "critical"++ ],++ "description": "The notification's priority (the badge may ring or toast on high and critical)."++ },++ "service": {++ "type": [++ "string",++ "null"++ ],++ "pattern": "^[a-z][a-z0-9-]{1,39}$",++ "description": "The service it is about (live, deals, trade, ...), or null."++ },++ "created_at": {++ "type": "string",++ "format": "date-time"++ },++ "unread_count": {++ "type": "integer",++ "minimum": 0,++ "maximum": 9007199254740991,++ "description": "The recipient's unread count right after this notification was stored, so a badge can update without a request. A later read elsewhere is not an event: the badge re-reads the count on focus and on its fallback poll."++ }++ }++ }++ },"network.blocks-result": {"version": "1.0.0","owner": "network",@@@ -25879,6 +26010,66 @@]}},++ "network.realtime-ticket-result": {++ "version": "1.0.0",++ "owner": "network",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/network/realtime-ticket-result.v1.json",++ "title": "NetworkRealtimeTicketResult",++ "description": "What POST /api/v1/realtime/ticket answers (OpenVibe.Network; ADR-005 amendment 2): a realtime ticket (identity.realtime-ticket-claims@1) for the signed-in person, and where to use it. The caller is the person's own session (Bearer Network JWT, or the ov_token cookie on openvibe.network); guests are refused (403 realtime.guest), and 503 realtime.disabled means the operator turned browser realtime off (REALTIME_TICKETS=off): the client stays on polling. The client opens `${stream_url}?topics=${topics}&ticket=${ticket}` (plus last_event_id when it resumes) at once, and asks for a fresh ticket for every reconnect: a ticket opens one stream.",++ "type": "object",++ "required": [++ "ticket",++ "expires_at",++ "expires_in",++ "stream_url",++ "topics",++ "subject"++ ],++ "additionalProperties": false,++ "properties": {++ "ticket": {++ "type": "string",++ "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$",++ "maxLength": 4096,++ "description": "The signed ticket (a compact JWS). Never logged, never stored."++ },++ "expires_at": {++ "type": "string",++ "format": "date-time"++ },++ "expires_in": {++ "type": "integer",++ "minimum": 1,++ "maximum": 300,++ "description": "Seconds until the ticket expires (120)."++ },++ "stream_url": {++ "type": "string",++ "format": "uri",++ "pattern": "^https?://[^?#]+/realtime/stream$",++ "description": "Events' realtime stream (https://events.openvibe.network/realtime/stream)."++ },++ "topics": {++ "type": "array",++ "minItems": 1,++ "maxItems": 20,++ "items": {++ "type": "string",++ "pattern": "^(\\*|[a-z0-9_]+)(\\.(\\*|[a-z0-9_]+))*$"++ },++ "description": "The topic patterns the notification badge subscribes to: [\"network.notification.*\"]."++ },++ "subject": {++ "type": "string",++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",++ "description": "Whose ticket it is; a client ignores any event whose subject is someone else."++ }++ }++ }++ },"network.staff-list-result": {"version": "1.0.0","owner": "network",@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.AI API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.AI as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.AI as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Billing API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Billing as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Billing as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Blog API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Blog as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Blog as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Chat API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Chat as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Chat as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Codes API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Codes as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Codes as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Community API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Community as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Community as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Coupons API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Coupons as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Coupons as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Deals API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Deals as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Deals as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Events API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Events as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Events as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Games API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Games as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Games as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Host API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Host as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Host as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Live API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Live as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Live as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Media API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Media as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Media as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Network API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Network as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Network as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.News API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.News as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.News as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenRe.Stream API",-- "version": "0.60.0",-- "description": "Routes of OpenRe.Stream as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenRe.Stream as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Reviews API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Reviews as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Reviews as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Search API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Search as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Search as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Sources API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Sources as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Sources as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Tips API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Tips as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Tips as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Tools API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Tools as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Tools as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Trade API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Trade as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Trade as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.VIP API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.VIP as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.VIP as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -3,8 +3,8 @@"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema","info": {"title": "OpenVibe.Wiki API",-- "version": "0.60.0",-- "description": "Routes of OpenVibe.Wiki as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",++ "version": "0.61.0",++ "description": "Routes of OpenVibe.Wiki as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.","license": {"name": "MIT","identifier": "MIT"@@@ -90,6 +90,43 @@ export type ServiceTokenClaims = {[k: string]: unknown | undefined;};++/** [email protected] (owner: network) */++/**++ * Claims of a realtime ticket (ADR-005 amendment 2; roadmap WS-E task 3, WS-F task 1): a two-minute RS256 JWT that OpenVibe.Network signs for the signed-in person (POST /api/v1/realtime/ticket) so a browser on any OpenVibe site can open Events' /realtime/stream?ticket=... as that person. A browser cannot put a header on an EventSource, and the ov_token cookie of events.openvibe.network is third-party on every other site, so the ticket travels in the URL; that is why it is short-lived, single-purpose and single-use. It is never a session token, by three independent rules: its issuer is Network's issuer followed by /realtime (every service checks the issuer of a session token), it carries typ (services refuse a session token that has one, as for FedCM assertions), and its only audience is openvibe.events. Events accepts it only as ?ticket= on /realtime/stream, verifies the signature with Network's key, iss, aud, typ, purpose and expiry (lifetime at most 300 s), refuses a jti it has already seen while the ticket is valid, and never logs it. The connection then sees what the person's session would: public events and subject events addressed to `sub`.++ */++export interface RealtimeTicketClaims {++ /**++ * Network's issuer followed by /realtime (https://openvibe.network/realtime in production): not the issuer of a session.++ */++ iss: string;++ /**++ * The person's subject id; subject-visibility events for this subject reach the connection.++ */++ sub: string;++ /**++ * @minItems 1++ * @maxItems 1++ */++ aud: ["openvibe.events"];++ /**++ * Token class. A service that finds typ on a Network token does not treat it as a session.++ */++ typ: "realtime";++ /**++ * Single purpose: opening one realtime stream.++ */++ purpose: "realtime";++ iat: number;++ /**++ * iat + 120 as Network mints it; Events refuses a lifetime over 300 s.++ */++ exp: number;++ /**++ * Unique per ticket; Events accepts each once.++ */++ jti: string;++}++/** [email protected] (owner: network) *//*** identity.resolve-request@1: the request of identity.subject.resolve on OpenVibe.Network. GET /internal/identity/resolve?subject_id= or ?system=&id=[&type=user] takes no body. POST /internal/identity/resolve-batch: { subject_ids } or { system, type?, ids } (at most 500; 413 beyond).@@@ -25769,6 +25806,38 @@ export interface NetworkUserTokenValidAfterPayload {"password_changed" | "password_reset" | "signed_out_everywhere" | "banned" | "account_deleted" | "staff_revoked";}++/** [email protected] (owner: network) */++/**++ * network.notification.created v1 (OpenVibe.Network server/notifications/notification-service.js; roadmap WS-E task 3, ADR-005 amendment 2). A notification was added to a person's inbox (Network is the only notification store, ADR-020). Written to Network's outbox in the transaction that stores the notification, so it exists if and only if the notification does; a muted category, a blocked sender or a go-live dedupe creates neither. Envelope: subject { type: user, id: <recipient usr_> }, visibility subject, actor system:network (never the sender: Events streams a subject event to its actor too), priority low. It is the person's realtime topic ("user:<id>" in the roadmap): a browser subscribes to `network.notification.*` on Events' /realtime/stream with a realtime ticket (identity.realtime-ticket-claims@1) and receives only its own; anyone else's subscription to the same pattern yields nothing. It carries what the notification badge needs and nothing it shows: never the title, the message, the link or the sender. The badge re-reads the unread count and the newest items from Network's API, which stays authoritative (reads, dismissals, other devices). Guests (anonymous accounts) and accounts without a usr_ subject get no event.++ */++export interface NetworkNotificationCreatedPayload {++ /**++ * The notification's id in Network's store (a UUID today): the id GET /api/notifications returns and POST /api/notifications/:id/read takes.++ */++ notification_id: string;++ /**++ * The notification type (openvibe-shared notifications TYPES, e.g. STREAM_LIVE, CONTENT_REPLY, DEAL_WATCH_MATCH); GENERIC when the stored type has another shape.++ */++ type: string;++ /**++ * The preference category it was filed under (social, chat, stream, service, moderation, system, ...).++ */++ category: string;++ /**++ * The notification's priority (the badge may ring or toast on high and critical).++ */++ priority: "low" | "normal" | "high" | "critical";++ /**++ * The service it is about (live, deals, trade, ...), or null.++ */++ service?: string | null;++ created_at: string;++ /**++ * The recipient's unread count right after this notification was stored, so a badge can update without a request. A later read elsewhere is not an event: the badge re-reads the count on focus and on its fallback poll.++ */++ unread_count: number;++}++/** [email protected] (owner: network) *//*** network.blocks-result@1: the answer of network.blocks.read on OpenVibe.Network. GET /internal/blocks?subject=usr_… (a service token holding network.blocks.read; the shared internal key is refused) → { subject, blocks, blocked_by }: the people the subject has blocked and the people who have blocked the subject, as usr_ subjects in subject order, active blocks only (a person may hold at most 5000). Answered with Cache-Control: no-store. A subject that is not a usr_ id is refused with 400 blocks.bad_subject (problem+json).@@@ -26042,6 +26111,168 @@ export type NetworkNotificationPushResult =total: number;};++/** [email protected] (owner: network) */++/**++ * What POST /api/v1/realtime/ticket answers (OpenVibe.Network; ADR-005 amendment 2): a realtime ticket (identity.realtime-ticket-claims@1) for the signed-in person, and where to use it. The caller is the person's own session (Bearer Network JWT, or the ov_token cookie on openvibe.network); guests are refused (403 realtime.guest), and 503 realtime.disabled means the operator turned browser realtime off (REALTIME_TICKETS=off): the client stays on polling. The client opens `${stream_url}?topics=${topics}&ticket=${ticket}` (plus last_event_id when it resumes) at once, and asks for a fresh ticket for every reconnect: a ticket opens one stream.++ */++export interface NetworkRealtimeTicketResult {++ /**++ * The signed ticket (a compact JWS). Never logged, never stored.++ */++ ticket: string;++ expires_at: string;++ /**++ * Seconds until the ticket expires (120).++ */++ expires_in: number;++ /**++ * Events' realtime stream (https://events.openvibe.network/realtime/stream).++ */++ stream_url: string;++ /**++ * The topic patterns the notification badge subscribes to: ["network.notification.*"].++ *++ * @minItems 1++ * @maxItems 20++ */++ topics:++ | [string]++ | [string, string]++ | [string, string, string]++ | [string, string, string, string]++ | [string, string, string, string, string]++ | [string, string, string, string, string, string]++ | [string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]++ | [++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string++ ]++ | [++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string++ ]++ | [++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string++ ]++ | [++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string++ ]++ | [++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string++ ]++ | [++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string,++ string++ ];++ /**++ * Whose ticket it is; a client ignores any event whose subject is someone else.++ */++ subject: string;++}++/** [email protected] (owner: network) *//*** network.staff-list-result@1: the answer of network.staff.read on OpenVibe.Network. GET /api/v1/staff/moderators[?service=chat|live|community|pastes|calls] (a service token holding network.staff.read, or a signed-in person holding staff.moderation.logs) → { service, staff }: the network's global moderators, admins and the owner who are not banned, with the staff capabilities their role holds in the contracts staff map; ?service keeps those holding the capability that moderates that service (another service name keeps those holding staff.content.moderate; an invalid value is ignored and answered as null). Answered with Cache-Control: private, no-store; a person without staff.moderation.logs gets 403 { error: forbidden }.@@@ -76,5 +76,5 @@"none": "no claims: one process owns events.db (better-sqlite3, single writer); one delivery per subscription is in flight at a time, tracked in memory"}},-- "notes": "Durable events, subscriptions, signed delivery with retry, dead letters and replay; SSE realtime gateway at /realtime/stream until ADR-005 decides where Realtime lives."++ "notes": "Durable events, subscriptions, signed delivery with retry, dead letters and replay; SSE realtime gateway at /realtime/stream (ADR-005); browsers on any OpenVibe site authenticate a stream with a two-minute, single-use realtime ticket from Network (?ticket=, identity.realtime-ticket-claims@1; ADR-005 amendment 2)."}@@@ -54,6 +54,7 @@"network.credential.rotated","network.grant.changed","network.module.updated",++ "network.notification.created","network.principal_grant.changed","network.user.token_valid_after","network.user.updated"@@@ -1,6 +1,6 @@{"name": "openvibe-contracts",-- "version": "0.60.0",++ "version": "0.61.0","description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.","main": "index.js","types": "generated/typescript/index.d.ts",@@@ -763,6 +763,34 @@ await assert.rejects(failing.getToken(), /401: invalid_client/);ok(contracts.schema('registry.release-manifest').properties.release.pattern === contracts.schema('host.release.published').properties.release.pattern, 'the release pattern is the release manifest\'s');}++// ── A person's realtime topic (WS-E task 3, WS-F task 1; ADR-005 amendment 2) ──++// network.notification.created carries what a badge needs and nothing it shows; a realtime ticket can++// never pass for a session (issuer, typ, audience); the badge's topics are Events patterns, never user:<id>.++{++ const cat = (id) => contracts.catalog.find(c => c.id === id);++ for (const id of ['network.notification.created', 'identity.realtime-ticket-claims', 'network.realtime-ticket-result']) {++ ok(cat(id) && cat(id).owner === 'network' && cat(id).visibility === 'first-party' && cat(id).adr === 'ADR-005', `${id} is a first-party Network contract under ADR-005`);++ }++ ok(services.get('network').eventsProduced.includes('network.notification.created'), 'the network manifest produces network.notification.created');++ const N = (p) => contracts.validate('network.notification.created@1', p).valid;++ const n = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/network.notification.created/valid/stream-live.json'), 'utf8'));++ ok(N(n), 'a go-live notification event');++ for (const k of ['title', 'message', 'url', 'icon', 'sender_id', 'sender_name', 'sender_avatar', 'rich_content', 'user_id', 'subject']) ok(!N({ ...n, [k]: 'x' }), `the event never carries ${k}`);++ for (const k of ['notification_id', 'type', 'category', 'priority', 'created_at', 'unread_count']) ok(!N({ ...n, [k]: undefined }), `the event needs ${k}`);++ const T = (c) => contracts.validate('identity.realtime-ticket-claims@1', c).valid;++ const t = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/identity.realtime-ticket-claims/valid/badge-ticket.json'), 'utf8'));++ ok(T(t), 'a realtime ticket');++ ok(!T({ ...t, iss: 'https://openvibe.network' }) && !T({ ...t, typ: undefined }) && !T({ ...t, aud: ['openvibe.network'] }), 'a ticket never has a session\'s issuer, lacks typ, or names another audience');++ ok(!T({ ...t, subject_id: t.sub }) && !T({ ...t, id: 57 }) && !T({ ...t, cap: ['events.event.read'] }), 'a ticket carries no session or principal claims');++ const R = (r) => contracts.validate('network.realtime-ticket-result@1', r).valid;++ const r = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/network.realtime-ticket-result/valid/badge.json'), 'utf8'));++ ok(R(r) && !R({ ...r, topics: ['user:' + t.sub] }) && !R({ ...r, topics: ['network.notification:x'] }), 'the badge subscribes to Events patterns, never user:<id>');++ // The badge's pattern matches the event type (Events topics.js: `*` is one or more whole segments).++ const match = (p, type) => new RegExp(`^${p.split('.').map(s => (s === '*' ? '[a-z0-9_]+(?:\\.[a-z0-9_]+)*' : s)).join('\\.')}$`).test(type);++ ok(r.topics.every(p => match(p, 'network.notification.created')), 'the ticket\'s topics match network.notification.created');++ ok(R({ ...r, expires_in: 300 }) && !R({ ...r, expires_in: 301 }), 'a ticket lives at most 300 s');++}++// ── Capability schemas (WS-C task 4): a ratchet over compatibility/capability-schema-gaps.json ──execFileSync(process.execPath, [path.join(__dirname, 'capability-schemas.test.js')], { stdio: 'inherit' });
@@ -6,7 +6,17 @@ the notes were in the tag and commit messages (`git tag -n1`).
@@ -6,7 +6,17 @@ the notes were in the tag and commit messages (`git tag -n1`).
## Unreleased
## Unreleased
-- ADR-007 amendment 2026-09-26: SQLite recorded as the reviewed store for every service, with Events', Chat's and Billing's measured production load and the thresholds that would move a service to PostgreSQL (WS-S task 1). Documentation only.
+## 0.61.0 — 2026-09-26
+
+**A person's realtime topic and realtime tickets** (roadmap WS-E task 3, WS-F task 1; ADR-005 amendment 2).
+- **New event type `network.notification.created`**, payload `network.notification.created@1`. Network writes it to its outbox in the transaction that stores a notification. Envelope: subject `{ type: user, id: <recipient usr_> }`, visibility `subject`, actor `system:network`. The payload is the id, type, category, priority, service, `created_at` and the recipient's unread count; never the title, message, link or sender. The Network manifest produces it. "`user:<id>`" in the roadmap is this event type plus subject visibility: a browser subscribes to `network.notification.*` and receives its own events only.
+- **New `identity.realtime-ticket-claims@1`**: the two-minute, single-use RS256 ticket with which a browser opens Events' `/realtime/stream?ticket=…`. Its claims are `iss <network issuer>/realtime`, `sub <usr_>`, `aud [openvibe.events]`, `typ realtime`, `purpose realtime`, `iat`, `exp` and `jti rtk_…`. Three rules each keep it from passing for a session: the issuer, `typ` and the audience.
+- **New `network.realtime-ticket-result@1`**: what `POST /api/v1/realtime/ticket` answers. That is the ticket, `expires_at`, `expires_in` (at most 300), `stream_url`, `topics` (Events patterns only, never `user:…`) and `subject`.
+- The Events manifest's notes name the ticket. There are fixtures for all three contracts, and `npm test` checks that the event never carries notification text or the sender, and that a ticket never has a session's issuer or claims.
+
+Additive.
+
+Also in this release, ADR-007 amendment 2026-09-26: SQLite recorded as the reviewed store for every service, with Events', Chat's and Billing's measured production load and the thresholds that would move a service to PostgreSQL (WS-S task 1). Documentation only.
## 0.60.0 — 2026-09-26
## 0.60.0 — 2026-09-26
@@ -31,6 +31,16 @@
@@ -31,6 +31,16 @@
"status": "active",
"status": "active",
"adr": "ADR-003"
"adr": "ADR-003"
},
},
+ {
+ "id": "identity.realtime-ticket-claims",
+ "version": "1.0.0",
+ "owner": "network",
+ "schema": "identity/realtime-ticket-claims.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-005"
+ },
{
{
"id": "identity.resolve-request",
"id": "identity.resolve-request",
"version": "1.0.0",
"version": "1.0.0",
@@ -2393,6 +2403,16 @@
@@ -2393,6 +2403,16 @@
"compatibility": "backward",
"compatibility": "backward",
"status": "active"
"status": "active"
},
},
+ {
+ "id": "network.notification.created",
+ "version": "1.0.0",
+ "owner": "network",
+ "schema": "events/payloads/network.notification.created.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-005"
+ },
{
{
"id": "network.blocks-result",
"id": "network.blocks-result",
"version": "1.0.0",
"version": "1.0.0",
@@ -2465,6 +2485,16 @@
@@ -2465,6 +2485,16 @@
"compatibility": "backward",
"compatibility": "backward",
"status": "active"
"status": "active"
},
},
+ {
+ "id": "network.realtime-ticket-result",
+ "version": "1.0.0",
+ "owner": "network",
+ "schema": "network/realtime-ticket-result.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active",
+ "adr": "ADR-005"
+ },
{
{
"id": "network.staff-list-result",
"id": "network.staff-list-result",
"version": "1.0.0",
"version": "1.0.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/network.notification.created.v1.json",
+ "title": "NetworkNotificationCreatedPayload",
+ "description": "network.notification.created v1 (OpenVibe.Network server/notifications/notification-service.js; roadmap WS-E task 3, ADR-005 amendment 2). A notification was added to a person's inbox (Network is the only notification store, ADR-020). Written to Network's outbox in the transaction that stores the notification, so it exists if and only if the notification does; a muted category, a blocked sender or a go-live dedupe creates neither. Envelope: subject { type: user, id: <recipient usr_> }, visibility subject, actor system:network (never the sender: Events streams a subject event to its actor too), priority low. It is the person's realtime topic (\"user:<id>\" in the roadmap): a browser subscribes to `network.notification.*` on Events' /realtime/stream with a realtime ticket (identity.realtime-ticket-claims@1) and receives only its own; anyone else's subscription to the same pattern yields nothing. It carries what the notification badge needs and nothing it shows: never the title, the message, the link or the sender. The badge re-reads the unread count and the newest items from Network's API, which stays authoritative (reads, dismissals, other devices). Guests (anonymous accounts) and accounts without a usr_ subject get no event.",
+ "type": "object",
+ "required": [
+ "notification_id",
+ "type",
+ "category",
+ "priority",
+ "created_at",
+ "unread_count"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "notification_id": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9_-]{1,64}$",
+ "description": "The notification's id in Network's store (a UUID today): the id GET /api/notifications returns and POST /api/notifications/:id/read takes."
+ },
+ "type": {
+ "type": "string",
+ "pattern": "^[A-Z][A-Z0-9_]{1,63}$",
+ "description": "The notification type (openvibe-shared notifications TYPES, e.g. STREAM_LIVE, CONTENT_REPLY, DEAL_WATCH_MATCH); GENERIC when the stored type has another shape."
+ },
+ "category": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9_]{1,31}$",
+ "description": "The preference category it was filed under (social, chat, stream, service, moderation, system, ...)."
+ },
+ "priority": {
+ "enum": [
+ "low",
+ "normal",
+ "high",
+ "critical"
+ ],
+ "description": "The notification's priority (the badge may ring or toast on high and critical)."
+ },
+ "service": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^[a-z][a-z0-9-]{1,39}$",
+ "description": "The service it is about (live, deals, trade, ...), or null."
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "unread_count": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991,
+ "description": "The recipient's unread count right after this notification was stored, so a badge can update without a request. A later read elsewhere is not an event: the badge re-reads the count on focus and on its fallback poll."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/identity/realtime-ticket-claims.v1.json",
+ "title": "RealtimeTicketClaims",
+ "description": "Claims of a realtime ticket (ADR-005 amendment 2; roadmap WS-E task 3, WS-F task 1): a two-minute RS256 JWT that OpenVibe.Network signs for the signed-in person (POST /api/v1/realtime/ticket) so a browser on any OpenVibe site can open Events' /realtime/stream?ticket=... as that person. A browser cannot put a header on an EventSource, and the ov_token cookie of events.openvibe.network is third-party on every other site, so the ticket travels in the URL; that is why it is short-lived, single-purpose and single-use. It is never a session token, by three independent rules: its issuer is Network's issuer followed by /realtime (every service checks the issuer of a session token), it carries typ (services refuse a session token that has one, as for FedCM assertions), and its only audience is openvibe.events. Events accepts it only as ?ticket= on /realtime/stream, verifies the signature with Network's key, iss, aud, typ, purpose and expiry (lifetime at most 300 s), refuses a jti it has already seen while the ticket is valid, and never logs it. The connection then sees what the person's session would: public events and subject events addressed to `sub`.",
+ "type": "object",
+ "required": [
+ "iss",
+ "sub",
+ "aud",
+ "typ",
+ "purpose",
+ "iat",
+ "exp",
+ "jti"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "iss": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^https?://[^/?#]+/realtime$",
+ "description": "Network's issuer followed by /realtime (https://openvibe.network/realtime in production): not the issuer of a session."
+ },
+ "sub": {
+ "type": "string",
+ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
+ "description": "The person's subject id; subject-visibility events for this subject reach the connection."
+ },
+ "aud": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 1,
+ "items": {
+ "const": "openvibe.events"
+ }
+ },
+ "typ": {
+ "const": "realtime",
+ "description": "Token class. A service that finds typ on a Network token does not treat it as a session."
+ },
+ "purpose": {
+ "const": "realtime",
+ "description": "Single purpose: opening one realtime stream."
+ },
+ "iat": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "exp": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "iat + 120 as Network mints it; Events refuses a lifetime over 300 s."
+ },
+ "jti": {
+ "type": "string",
+ "pattern": "^rtk_[0-9a-f]{24}$",
+ "description": "Unique per ticket; Events accepts each once."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/network/realtime-ticket-result.v1.json",
+ "title": "NetworkRealtimeTicketResult",
+ "description": "What POST /api/v1/realtime/ticket answers (OpenVibe.Network; ADR-005 amendment 2): a realtime ticket (identity.realtime-ticket-claims@1) for the signed-in person, and where to use it. The caller is the person's own session (Bearer Network JWT, or the ov_token cookie on openvibe.network); guests are refused (403 realtime.guest), and 503 realtime.disabled means the operator turned browser realtime off (REALTIME_TICKETS=off): the client stays on polling. The client opens `${stream_url}?topics=${topics}&ticket=${ticket}` (plus last_event_id when it resumes) at once, and asks for a fresh ticket for every reconnect: a ticket opens one stream.",
+ "type": "object",
+ "required": [
+ "ticket",
+ "expires_at",
+ "expires_in",
+ "stream_url",
+ "topics",
+ "subject"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "ticket": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$",
+ "maxLength": 4096,
+ "description": "The signed ticket (a compact JWS). Never logged, never stored."
+ },
+ "expires_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "expires_in": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 300,
+ "description": "Seconds until the ticket expires (120)."
+ },
+ "stream_url": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^https?://[^?#]+/realtime/stream$",
+ "description": "Events' realtime stream (https://events.openvibe.network/realtime/stream)."
+ },
+ "topics": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 20,
+ "items": {
+ "type": "string",
+ "pattern": "^(\\*|[a-z0-9_]+)(\\.(\\*|[a-z0-9_]+))*$"
+ },
+ "description": "The topic patterns the notification badge subscribes to: [\"network.notification.*\"]."
+ },
+ "subject": {
+ "type": "string",
+ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
+ "description": "Whose ticket it is; a client ignores any event whose subject is someone else."
+ }
+ }
+}
@@ -47,3 +47,34 @@ Guessed private topic yields nothing; reconnect from a cursor gets the complete
@@ -47,3 +47,34 @@ Guessed private topic yields nothing; reconnect from a cursor gets the complete
**Consequences.** A product that shows "online" (a profile, a DM list) calls Chat's read and caches it for seconds at most. When realtime moves out of Events (the original decision's revisit clause), presence stays with whatever holds chat connections.
**Consequences.** A product that shows "online" (a profile, a DM list) calls Chat's read and caches it for seconds at most. When realtime moves out of Events (the original decision's revisit clause), presence stays with whatever holds chat connections.
**Acceptance.** A connected person reads `online`. A hidden person reads `offline` while connected. A person whose preference is unknown reads `offline`. No `chat.presence*` event type exists in the catalog (Chat `test/chat-modules.test.js`, Contracts `npm test`).
**Acceptance.** A connected person reads `online`. A hidden person reads `offline` while connected. A person whose preference is unknown reads `offline`. No `chat.presence*` event type exists in the catalog (Chat `test/chat-modules.test.js`, Contracts `npm test`).
+
+## Amendment 2 (2026-09-26): a person's topic, and realtime tickets
+
+**Status:** Accepted (roadmap WS-E task 3, WS-F task 1).
+
+**Decision.**
+- **"`user:<id>`" is not a topic name.** A topic is an event type pattern (`topics.js` in Events), and `user:…` is not a valid pattern (400 `realtime.bad_topic`). A person's topic is the pair *event type + subject visibility*. Network publishes **`network.notification.created`** (`network.notification.created@1`) through its outbox, in the transaction that stores the notification. The event has subject `{ type: user, id: <recipient usr_> }`, visibility `subject` and actor `system:network`. Events streams a subject event only to the viewer whose subject is the event's subject or actor, so the actor is never the sender. The notification badge subscribes to `network.notification.*` and receives its own notifications and nobody else's. Another person's stream on the same pattern, a broader pattern (`*`) or a guessed one yields nothing. Later per-person types (`network.notification.read`, a digest) join the same pattern.
+- **The payload is what the badge needs, never what it shows.** It carries the id, type, category, priority, service, `created_at` and the unread count after the insert. It never carries the title, message, link or sender. The badge re-reads the count and the newest items from Network's API, which stays authoritative. Events keeps the event for its retention period, so nothing private is put in it.
+- **Browsers authenticate with a realtime ticket** (`identity.realtime-ticket-claims@1`). An EventSource cannot send a header, and the `ov_token` cookie of events.openvibe.network is third-party on every other site, where browsers increasingly drop it. So Network mints a ticket for the signed-in person: `POST /api/v1/realtime/ticket` answers `network.realtime-ticket-result@1`. The caller authenticates with a Bearer Network JWT, or the `ov_token` cookie on openvibe.network. The ticket is an RS256 JWT with `iss <network issuer>/realtime`, `sub <usr_>`, `aud [openvibe.events]`, `typ realtime`, `purpose realtime`, a lifetime of 120 s and `jti rtk_…`. The browser opens `/realtime/stream?topics=…&ticket=…`.
+- **A ticket is never a session.** Three rules each refuse it as one: its issuer is not the session issuer, it carries `typ`, and its only audience is Events. Events accepts it only as `?ticket=` on the stream, never as Bearer or cookie, and accepts each `jti` once while the ticket is valid. It never logs the ticket. A reconnect asks for a fresh ticket and resumes with `last_event_id`, so a ticket that leaked into a proxy log is spent or expired. Guests get no ticket (403 `realtime.guest`). `REALTIME_TICKETS=off` on Network answers 503 `realtime.disabled`, and every badge stays on polling.
+- **The badge** (openvibe-shared `notification-live.js`, loaded by `notification-ui.js` when a site sets `notificationsRealtime: true`) behaves as follows:
+ - On a `network.notification.*` event whose subject is its own, it re-reads the count and toasts what is new. On `event: gap` it re-reads the count and the open lists.
+ - Errors back off from 2 s to 15 min, with jitter. A tab hidden for 5 min closes the stream and resumes from its cursor when shown.
+ - A Content-Security-Policy refusal, or eight failures in a row, leaves polling only.
+ - Polling stays the fallback: every 15 s without a stream, and every 2 min while the stream is open, as a safety net for reads made on other sites.
+
+**Alternatives considered.**
+- A `user:<id>` topic namespace in Events: rejected. It duplicates what subject visibility already enforces, and it invites exactly the guessing the visibility rule makes useless.
+- The session JWT in the URL (`?access_token=`): rejected. It is valid for hours, is accepted as a session everywhere, and URLs end up in logs.
+- Cookies on events.openvibe.network with `withCredentials`: kept for pages on openvibe.network itself and for Bearer clients, but not relied on across sites (third-party cookie blocking).
+- The notification text in the event: rejected (ADR-020: Network renders, and Events retains).
+
+**Consequences.** Every notification is one more event in Events' log (a go-live to N followers is N events). The outbox relays them in batches of 50, and Events' retention and redaction apply. Where Network's relay is off, the rows wait in `network_event_outbox`. The badge still works by polling.
+
+**Acceptance.**
+- A ticket for A never receives B's `network.notification.created`, on any pattern.
+- An expired ticket, a ticket for another audience or purpose, a reused ticket and a session JWT passed as a ticket are refused. A ticket passed as Bearer is refused.
+- A disconnect resumes from `last_event_id` with the missed events, and a cursor beyond retention gets `event: gap`.
+- A stored notification and its event commit together or not at all.
+
+These are Events `test/realtime-tickets.test.js`, Network `test/notification-events.test.js` and Shared `test/notification-live.test.js`.
@@ -8,7 +8,7 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each
@@ -8,7 +8,7 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each
| [ADR-002](ADR-002-contract-repository.md) | Contract repository and compatibility policy | Accepted, implemented 2026-09-22 (openvibe-contracts v0.1.0+) |
| [ADR-002](ADR-002-contract-repository.md) | Contract repository and compatibility policy | Accepted, implemented 2026-09-22 (openvibe-contracts v0.1.0+) |
| [ADR-003](ADR-003-service-principals.md) | Service/app principal authentication and capability grants | Accepted, implemented 2026-09-22; amended 2026-09-24 (delegated authorization and approval UX) |
| [ADR-003](ADR-003-service-principals.md) | Service/app principal authentication and capability grants | Accepted, implemented 2026-09-22; amended 2026-09-24 (delegated authorization and approval UX) |
| [ADR-004](ADR-004-durable-events.md) | Durable event store, outbox/inbox and delivery semantics | Accepted 2026-09-22; implementation in progress (OpenVibe.Events) |
| [ADR-004](ADR-004-durable-events.md) | Durable event store, outbox/inbox and delivery semantics | Accepted 2026-09-22; implementation in progress (OpenVibe.Events) |
-| [ADR-005](ADR-005-realtime.md) | Realtime topics, cursor/resume and whether OpenVibe.Realtime exists | Accepted 2026-09-22: realtime runs inside OpenVibe.Events; amendment 1 (2026-09-26): presence is ephemeral, in Chat's delivery plane |
+| [ADR-005](ADR-005-realtime.md) | Realtime topics, cursor/resume and whether OpenVibe.Realtime exists | Accepted 2026-09-22: realtime runs inside OpenVibe.Events; amendment 1 (2026-09-26): presence is ephemeral, in Chat's delivery plane; amendment 2 (2026-09-26): a person's topic is `network.notification.*` with subject visibility, and browsers authenticate with two-minute realtime tickets |
| [ADR-006](ADR-006-media-objects.md) | Media object, location and namespace model | Accepted 2026-09-22; implementation in progress (OpenVibe.Media Wave 4); amended 2026-09-24 (placement classes, local provider, infrequent access deferred) |
| [ADR-006](ADR-006-media-objects.md) | Media object, location and namespace model | Accepted 2026-09-22; implementation in progress (OpenVibe.Media Wave 4); amended 2026-09-24 (placement classes, local provider, infrequent access deferred) |
| [ADR-007](ADR-007-data-ownership.md) | Logical data ownership and database technology | Accepted 2026-09-22; amended 2026-09-24 (PgBouncer, replicas, SERIALIZABLE money paths, Redis non-authoritative) |
| [ADR-007](ADR-007-data-ownership.md) | Logical data ownership and database technology | Accepted 2026-09-22; amended 2026-09-24 (PgBouncer, replicas, SERIALIZABLE money paths, Redis non-authoritative) |
| [ADR-008](ADR-008-shared-packages.md) | Shared package publication and release manifests | Accepted 2026-09-22; implementation in progress (OpenVibe.Shared) |
| [ADR-008](ADR-008-shared-packages.md) | Shared package publication and release manifests | Accepted 2026-09-22; implementation in progress (OpenVibe.Shared) |
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+{
+ "iss": "https://openvibe.network/realtime",
+ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
+ "aud": ["openvibe.events"],
+ "purpose": "realtime",
+ "iat": 1790000000,
+ "exp": 1790000120,
+ "jti": "rtk_0123456789abcdef01234567"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "iss": "https://openvibe.network/realtime",
+ "sub": "57",
+ "aud": ["openvibe.events"],
+ "typ": "realtime",
+ "purpose": "realtime",
+ "iat": 1790000000,
+ "exp": 1790000120,
+ "jti": "rtk_0123456789abcdef01234567"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "iss": "https://openvibe.network/realtime",
+ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
+ "aud": ["openvibe.events", "openvibe.live"],
+ "typ": "realtime",
+ "purpose": "realtime",
+ "iat": 1790000000,
+ "exp": 1790000120,
+ "jti": "rtk_0123456789abcdef01234567"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "iss": "https://openvibe.network/realtime",
+ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
+ "aud": ["openvibe.events"],
+ "typ": "realtime",
+ "purpose": "session",
+ "iat": 1790000000,
+ "exp": 1790000120,
+ "jti": "rtk_0123456789abcdef01234567"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+{
+ "iss": "https://openvibe.network/realtime",
+ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
+ "aud": ["openvibe.events"],
+ "typ": "realtime",
+ "purpose": "realtime",
+ "iat": 1790000000,
+ "exp": 1790000120,
+ "jti": "rtk_0123456789abcdef01234567",
+ "username": "alice",
+ "role": "admin"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "iss": "https://openvibe.network",
+ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
+ "aud": ["openvibe.events"],
+ "typ": "realtime",
+ "purpose": "realtime",
+ "iat": 1790000000,
+ "exp": 1790000120,
+ "jti": "rtk_0123456789abcdef01234567"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "iss": "https://openvibe.network/realtime",
+ "sub": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
+ "aud": ["openvibe.events"],
+ "typ": "realtime",
+ "purpose": "realtime",
+ "iat": 1790000000,
+ "exp": 1790000120,
+ "jti": "rtk_0123456789abcdef01234567"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",
+ "type": "CONTENT_REPLY",
+ "category": "social",
+ "priority": "normal",
+ "service": "live",
+ "created_at": "2026-09-26T18:03:08.000Z",
+ "unread_count": 1,
+ "sender_id": 20
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",
+ "type": "STREAM_LIVE",
+ "category": "stream",
+ "priority": "normal",
+ "service": "live",
+ "created_at": "2026-09-26T18:03:08.000Z",
+ "unread_count": 4,
+ "title": "carol is live!"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+{
+ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",
+ "type": "stream_live",
+ "category": "stream",
+ "priority": "normal",
+ "service": "live",
+ "created_at": "2026-09-26T18:03:08.000Z",
+ "unread_count": 4
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+{
+ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",
+ "type": "STREAM_LIVE",
+ "category": "stream",
+ "priority": "normal",
+ "service": "live",
+ "created_at": "2026-09-26T18:03:08.000Z"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+{
+ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",
+ "type": "STREAM_LIVE",
+ "category": "stream",
+ "priority": "urgent",
+ "service": "live",
+ "created_at": "2026-09-26T18:03:08.000Z",
+ "unread_count": 4
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+{
+ "notification_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
+ "type": "WELCOME",
+ "category": "system",
+ "priority": "low",
+ "service": null,
+ "created_at": "2026-09-26T18:03:08.000Z",
+ "unread_count": 0
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+{
+ "notification_id": "3f1c2b9a-6d4e-4f8a-9b1c-2d3e4f5a6b7c",
+ "type": "STREAM_LIVE",
+ "category": "stream",
+ "priority": "normal",
+ "service": "live",
+ "created_at": "2026-09-26T18:03:08.000Z",
+ "unread_count": 4
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+{
+ "ticket": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXAiOiJyZWFsdGltZSJ9.c2lnbmF0dXJl",
+ "expires_at": "2026-09-27T18:03:08.000Z",
+ "expires_in": 86400,
+ "stream_url": "https://events.openvibe.network/realtime/stream",
+ "topics": ["network.notification.*"],
+ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+{
+ "ticket": "not a token",
+ "expires_at": "2026-09-26T18:05:08.000Z",
+ "expires_in": 120,
+ "stream_url": "https://events.openvibe.network/realtime/stream",
+ "topics": ["network.notification.*"],
+ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+{
+ "ticket": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXAiOiJyZWFsdGltZSJ9.c2lnbmF0dXJl",
+ "expires_at": "2026-09-26T18:05:08.000Z",
+ "expires_in": 120,
+ "stream_url": "https://events.openvibe.network/realtime/stream?ticket=x",
+ "topics": ["network.notification.*"],
+ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+{
+ "ticket": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXAiOiJyZWFsdGltZSJ9.c2lnbmF0dXJl",
+ "expires_at": "2026-09-26T18:05:08.000Z",
+ "expires_in": 120,
+ "stream_url": "https://events.openvibe.network/realtime/stream",
+ "topics": ["user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"],
+ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+{
+ "ticket": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXAiOiJyZWFsdGltZSJ9.c2lnbmF0dXJl",
+ "expires_at": "2026-09-26T18:05:08.000Z",
+ "expires_in": 120,
+ "stream_url": "https://events.openvibe.network/realtime/stream",
+ "topics": ["network.notification.*"],
+ "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
+}
@@ -272,6 +272,72 @@
@@ -272,6 +272,72 @@
]
]
}
}
},
},
+ "identity.realtime-ticket-claims": {
+ "version": "1.0.0",
+ "owner": "network",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/identity/realtime-ticket-claims.v1.json",
+ "title": "RealtimeTicketClaims",
+ "description": "Claims of a realtime ticket (ADR-005 amendment 2; roadmap WS-E task 3, WS-F task 1): a two-minute RS256 JWT that OpenVibe.Network signs for the signed-in person (POST /api/v1/realtime/ticket) so a browser on any OpenVibe site can open Events' /realtime/stream?ticket=... as that person. A browser cannot put a header on an EventSource, and the ov_token cookie of events.openvibe.network is third-party on every other site, so the ticket travels in the URL; that is why it is short-lived, single-purpose and single-use. It is never a session token, by three independent rules: its issuer is Network's issuer followed by /realtime (every service checks the issuer of a session token), it carries typ (services refuse a session token that has one, as for FedCM assertions), and its only audience is openvibe.events. Events accepts it only as ?ticket= on /realtime/stream, verifies the signature with Network's key, iss, aud, typ, purpose and expiry (lifetime at most 300 s), refuses a jti it has already seen while the ticket is valid, and never logs it. The connection then sees what the person's session would: public events and subject events addressed to `sub`.",
+ "type": "object",
+ "required": [
+ "iss",
+ "sub",
+ "aud",
+ "typ",
+ "purpose",
+ "iat",
+ "exp",
+ "jti"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "iss": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^https?://[^/?#]+/realtime$",
+ "description": "Network's issuer followed by /realtime (https://openvibe.network/realtime in production): not the issuer of a session."
+ },
+ "sub": {
+ "type": "string",
+ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
+ "description": "The person's subject id; subject-visibility events for this subject reach the connection."
+ },
+ "aud": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 1,
+ "items": {
+ "const": "openvibe.events"
+ }
+ },
+ "typ": {
+ "const": "realtime",
+ "description": "Token class. A service that finds typ on a Network token does not treat it as a session."
+ },
+ "purpose": {
+ "const": "realtime",
+ "description": "Single purpose: opening one realtime stream."
+ },
+ "iat": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "exp": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "iat + 120 as Network mints it; Events refuses a lifetime over 300 s."
+ },
+ "jti": {
+ "type": "string",
+ "pattern": "^rtk_[0-9a-f]{24}$",
+ "description": "Unique per ticket; Events accepts each once."
+ }
+ }
+ }
+ },
"identity.resolve-request": {
"identity.resolve-request": {
"version": "1.0.0",
"version": "1.0.0",
"owner": "network",
"owner": "network",
@@ -25308,6 +25374,71 @@
@@ -25308,6 +25374,71 @@
}
}
}
}
},
},
+ "network.notification.created": {
+ "version": "1.0.0",
+ "owner": "network",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/network.notification.created.v1.json",
+ "title": "NetworkNotificationCreatedPayload",
+ "description": "network.notification.created v1 (OpenVibe.Network server/notifications/notification-service.js; roadmap WS-E task 3, ADR-005 amendment 2). A notification was added to a person's inbox (Network is the only notification store, ADR-020). Written to Network's outbox in the transaction that stores the notification, so it exists if and only if the notification does; a muted category, a blocked sender or a go-live dedupe creates neither. Envelope: subject { type: user, id: <recipient usr_> }, visibility subject, actor system:network (never the sender: Events streams a subject event to its actor too), priority low. It is the person's realtime topic (\"user:<id>\" in the roadmap): a browser subscribes to `network.notification.*` on Events' /realtime/stream with a realtime ticket (identity.realtime-ticket-claims@1) and receives only its own; anyone else's subscription to the same pattern yields nothing. It carries what the notification badge needs and nothing it shows: never the title, the message, the link or the sender. The badge re-reads the unread count and the newest items from Network's API, which stays authoritative (reads, dismissals, other devices). Guests (anonymous accounts) and accounts without a usr_ subject get no event.",
+ "type": "object",
+ "required": [
+ "notification_id",
+ "type",
+ "category",
+ "priority",
+ "created_at",
+ "unread_count"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "notification_id": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9_-]{1,64}$",
+ "description": "The notification's id in Network's store (a UUID today): the id GET /api/notifications returns and POST /api/notifications/:id/read takes."
+ },
+ "type": {
+ "type": "string",
+ "pattern": "^[A-Z][A-Z0-9_]{1,63}$",
+ "description": "The notification type (openvibe-shared notifications TYPES, e.g. STREAM_LIVE, CONTENT_REPLY, DEAL_WATCH_MATCH); GENERIC when the stored type has another shape."
+ },
+ "category": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9_]{1,31}$",
+ "description": "The preference category it was filed under (social, chat, stream, service, moderation, system, ...)."
+ },
+ "priority": {
+ "enum": [
+ "low",
+ "normal",
+ "high",
+ "critical"
+ ],
+ "description": "The notification's priority (the badge may ring or toast on high and critical)."
+ },
+ "service": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^[a-z][a-z0-9-]{1,39}$",
+ "description": "The service it is about (live, deals, trade, ...), or null."
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "unread_count": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991,
+ "description": "The recipient's unread count right after this notification was stored, so a badge can update without a request. A later read elsewhere is not an event: the badge re-reads the count on focus and on its fallback poll."
+ }
+ }
+ }
+ },
"network.blocks-result": {
"network.blocks-result": {
"version": "1.0.0",
"version": "1.0.0",
"owner": "network",
"owner": "network",
@@ -25879,6 +26010,66 @@
@@ -25879,6 +26010,66 @@
]
]
}
}
},
},
+ "network.realtime-ticket-result": {
+ "version": "1.0.0",
+ "owner": "network",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/network/realtime-ticket-result.v1.json",
+ "title": "NetworkRealtimeTicketResult",
+ "description": "What POST /api/v1/realtime/ticket answers (OpenVibe.Network; ADR-005 amendment 2): a realtime ticket (identity.realtime-ticket-claims@1) for the signed-in person, and where to use it. The caller is the person's own session (Bearer Network JWT, or the ov_token cookie on openvibe.network); guests are refused (403 realtime.guest), and 503 realtime.disabled means the operator turned browser realtime off (REALTIME_TICKETS=off): the client stays on polling. The client opens `${stream_url}?topics=${topics}&ticket=${ticket}` (plus last_event_id when it resumes) at once, and asks for a fresh ticket for every reconnect: a ticket opens one stream.",
+ "type": "object",
+ "required": [
+ "ticket",
+ "expires_at",
+ "expires_in",
+ "stream_url",
+ "topics",
+ "subject"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "ticket": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$",
+ "maxLength": 4096,
+ "description": "The signed ticket (a compact JWS). Never logged, never stored."
+ },
+ "expires_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "expires_in": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 300,
+ "description": "Seconds until the ticket expires (120)."
+ },
+ "stream_url": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^https?://[^?#]+/realtime/stream$",
+ "description": "Events' realtime stream (https://events.openvibe.network/realtime/stream)."
+ },
+ "topics": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 20,
+ "items": {
+ "type": "string",
+ "pattern": "^(\\*|[a-z0-9_]+)(\\.(\\*|[a-z0-9_]+))*$"
+ },
+ "description": "The topic patterns the notification badge subscribes to: [\"network.notification.*\"]."
+ },
+ "subject": {
+ "type": "string",
+ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
+ "description": "Whose ticket it is; a client ignores any event whose subject is someone else."
+ }
+ }
+ }
+ },
"network.staff-list-result": {
"network.staff-list-result": {
"version": "1.0.0",
"version": "1.0.0",
"owner": "network",
"owner": "network",
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.AI API",
"title": "OpenVibe.AI API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.AI as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.AI as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Billing API",
"title": "OpenVibe.Billing API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Billing as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Billing as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Blog API",
"title": "OpenVibe.Blog API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Blog as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Blog as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Chat API",
"title": "OpenVibe.Chat API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Chat as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Chat as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Codes API",
"title": "OpenVibe.Codes API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Codes as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Codes as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Community API",
"title": "OpenVibe.Community API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Community as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Community as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Coupons API",
"title": "OpenVibe.Coupons API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Coupons as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Coupons as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Deals API",
"title": "OpenVibe.Deals API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Deals as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Deals as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Events API",
"title": "OpenVibe.Events API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Events as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Events as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Games API",
"title": "OpenVibe.Games API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Games as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Games as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Host API",
"title": "OpenVibe.Host API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Host as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Host as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Live API",
"title": "OpenVibe.Live API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Live as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Live as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Media API",
"title": "OpenVibe.Media API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Media as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Media as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Network API",
"title": "OpenVibe.Network API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Network as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Network as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.News API",
"title": "OpenVibe.News API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.News as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.News as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenRe.Stream API",
"title": "OpenRe.Stream API",
- "version": "0.60.0",
- "description": "Routes of OpenRe.Stream as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenRe.Stream as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Reviews API",
"title": "OpenVibe.Reviews API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Reviews as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Reviews as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Search API",
"title": "OpenVibe.Search API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Search as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Search as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Sources API",
"title": "OpenVibe.Sources API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Sources as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Sources as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Tips API",
"title": "OpenVibe.Tips API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Tips as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Tips as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Tools API",
"title": "OpenVibe.Tools API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Tools as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Tools as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Trade API",
"title": "OpenVibe.Trade API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Trade as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Trade as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.VIP API",
"title": "OpenVibe.VIP API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.VIP as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.VIP as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -3,8 +3,8 @@
@@ -3,8 +3,8 @@
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"info": {
"title": "OpenVibe.Wiki API",
"title": "OpenVibe.Wiki API",
- "version": "0.60.0",
- "description": "Routes of OpenVibe.Wiki as the OpenVibe contracts describe them (openvibe-contracts 0.60.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
+ "version": "0.61.0",
+ "description": "Routes of OpenVibe.Wiki as the OpenVibe contracts describe them (openvibe-contracts 0.61.0): each operation lists the capabilities it performs; a token for the service's audience must carry one of them unless the route is open. Generated by openvibe-contracts lib/openapi.js; do not edit.",
"license": {
"license": {
"name": "MIT",
"name": "MIT",
"identifier": "MIT"
"identifier": "MIT"
@@ -90,6 +90,43 @@ export type ServiceTokenClaims = {
@@ -90,6 +90,43 @@ export type ServiceTokenClaims = {
[k: string]: unknown | undefined;
[k: string]: unknown | undefined;
};
};
+/** [email protected] (owner: network) */
+/**
+ * Claims of a realtime ticket (ADR-005 amendment 2; roadmap WS-E task 3, WS-F task 1): a two-minute RS256 JWT that OpenVibe.Network signs for the signed-in person (POST /api/v1/realtime/ticket) so a browser on any OpenVibe site can open Events' /realtime/stream?ticket=... as that person. A browser cannot put a header on an EventSource, and the ov_token cookie of events.openvibe.network is third-party on every other site, so the ticket travels in the URL; that is why it is short-lived, single-purpose and single-use. It is never a session token, by three independent rules: its issuer is Network's issuer followed by /realtime (every service checks the issuer of a session token), it carries typ (services refuse a session token that has one, as for FedCM assertions), and its only audience is openvibe.events. Events accepts it only as ?ticket= on /realtime/stream, verifies the signature with Network's key, iss, aud, typ, purpose and expiry (lifetime at most 300 s), refuses a jti it has already seen while the ticket is valid, and never logs it. The connection then sees what the person's session would: public events and subject events addressed to `sub`.
+ */
+export interface RealtimeTicketClaims {
+ /**
+ * Network's issuer followed by /realtime (https://openvibe.network/realtime in production): not the issuer of a session.
+ */
+ iss: string;
+ /**
+ * The person's subject id; subject-visibility events for this subject reach the connection.
+ */
+ sub: string;
+ /**
+ * @minItems 1
+ * @maxItems 1
+ */
+ aud: ["openvibe.events"];
+ /**
+ * Token class. A service that finds typ on a Network token does not treat it as a session.
+ */
+ typ: "realtime";
+ /**
+ * Single purpose: opening one realtime stream.
+ */
+ purpose: "realtime";
+ iat: number;
+ /**
+ * iat + 120 as Network mints it; Events refuses a lifetime over 300 s.
+ */
+ exp: number;
+ /**
+ * Unique per ticket; Events accepts each once.
+ */
+ jti: string;
+}
+
/** [email protected] (owner: network) */
/** [email protected] (owner: network) */
/**
/**
* identity.resolve-request@1: the request of identity.subject.resolve on OpenVibe.Network. GET /internal/identity/resolve?subject_id= or ?system=&id=[&type=user] takes no body. POST /internal/identity/resolve-batch: { subject_ids } or { system, type?, ids } (at most 500; 413 beyond).
* identity.resolve-request@1: the request of identity.subject.resolve on OpenVibe.Network. GET /internal/identity/resolve?subject_id= or ?system=&id=[&type=user] takes no body. POST /internal/identity/resolve-batch: { subject_ids } or { system, type?, ids } (at most 500; 413 beyond).
@@ -25769,6 +25806,38 @@ export interface NetworkUserTokenValidAfterPayload {
@@ -25769,6 +25806,38 @@ export interface NetworkUserTokenValidAfterPayload {
"password_changed" | "password_reset" | "signed_out_everywhere" | "banned" | "account_deleted" | "staff_revoked";
"password_changed" | "password_reset" | "signed_out_everywhere" | "banned" | "account_deleted" | "staff_revoked";
}
}
+/** [email protected] (owner: network) */
+/**
+ * network.notification.created v1 (OpenVibe.Network server/notifications/notification-service.js; roadmap WS-E task 3, ADR-005 amendment 2). A notification was added to a person's inbox (Network is the only notification store, ADR-020). Written to Network's outbox in the transaction that stores the notification, so it exists if and only if the notification does; a muted category, a blocked sender or a go-live dedupe creates neither. Envelope: subject { type: user, id: <recipient usr_> }, visibility subject, actor system:network (never the sender: Events streams a subject event to its actor too), priority low. It is the person's realtime topic ("user:<id>" in the roadmap): a browser subscribes to `network.notification.*` on Events' /realtime/stream with a realtime ticket (identity.realtime-ticket-claims@1) and receives only its own; anyone else's subscription to the same pattern yields nothing. It carries what the notification badge needs and nothing it shows: never the title, the message, the link or the sender. The badge re-reads the unread count and the newest items from Network's API, which stays authoritative (reads, dismissals, other devices). Guests (anonymous accounts) and accounts without a usr_ subject get no event.
+ */
+export interface NetworkNotificationCreatedPayload {
+ /**
+ * The notification's id in Network's store (a UUID today): the id GET /api/notifications returns and POST /api/notifications/:id/read takes.
+ */
+ notification_id: string;
+ /**
+ * The notification type (openvibe-shared notifications TYPES, e.g. STREAM_LIVE, CONTENT_REPLY, DEAL_WATCH_MATCH); GENERIC when the stored type has another shape.
+ */
+ type: string;
+ /**
+ * The preference category it was filed under (social, chat, stream, service, moderation, system, ...).
+ */
+ category: string;
+ /**
+ * The notification's priority (the badge may ring or toast on high and critical).
+ */
+ priority: "low" | "normal" | "high" | "critical";
+ /**
+ * The service it is about (live, deals, trade, ...), or null.
+ */
+ service?: string | null;
+ created_at: string;
+ /**
+ * The recipient's unread count right after this notification was stored, so a badge can update without a request. A later read elsewhere is not an event: the badge re-reads the count on focus and on its fallback poll.
+ */
+ unread_count: number;
+}
+
/** [email protected] (owner: network) */
/** [email protected] (owner: network) */
/**
/**
* network.blocks-result@1: the answer of network.blocks.read on OpenVibe.Network. GET /internal/blocks?subject=usr_… (a service token holding network.blocks.read; the shared internal key is refused) → { subject, blocks, blocked_by }: the people the subject has blocked and the people who have blocked the subject, as usr_ subjects in subject order, active blocks only (a person may hold at most 5000). Answered with Cache-Control: no-store. A subject that is not a usr_ id is refused with 400 blocks.bad_subject (problem+json).
* network.blocks-result@1: the answer of network.blocks.read on OpenVibe.Network. GET /internal/blocks?subject=usr_… (a service token holding network.blocks.read; the shared internal key is refused) → { subject, blocks, blocked_by }: the people the subject has blocked and the people who have blocked the subject, as usr_ subjects in subject order, active blocks only (a person may hold at most 5000). Answered with Cache-Control: no-store. A subject that is not a usr_ id is refused with 400 blocks.bad_subject (problem+json).
@@ -26042,6 +26111,168 @@ export type NetworkNotificationPushResult =
@@ -26042,6 +26111,168 @@ export type NetworkNotificationPushResult =
total: number;
total: number;
};
};
+/** [email protected] (owner: network) */
+/**
+ * What POST /api/v1/realtime/ticket answers (OpenVibe.Network; ADR-005 amendment 2): a realtime ticket (identity.realtime-ticket-claims@1) for the signed-in person, and where to use it. The caller is the person's own session (Bearer Network JWT, or the ov_token cookie on openvibe.network); guests are refused (403 realtime.guest), and 503 realtime.disabled means the operator turned browser realtime off (REALTIME_TICKETS=off): the client stays on polling. The client opens `${stream_url}?topics=${topics}&ticket=${ticket}` (plus last_event_id when it resumes) at once, and asks for a fresh ticket for every reconnect: a ticket opens one stream.
+ */
+export interface NetworkRealtimeTicketResult {
+ /**
+ * The signed ticket (a compact JWS). Never logged, never stored.
+ */
+ ticket: string;
+ expires_at: string;
+ /**
+ * Seconds until the ticket expires (120).
+ */
+ expires_in: number;
+ /**
+ * Events' realtime stream (https://events.openvibe.network/realtime/stream).
+ */
+ stream_url: string;
+ /**
+ * The topic patterns the notification badge subscribes to: ["network.notification.*"].
+ *
+ * @minItems 1
+ * @maxItems 20
+ */
+ topics:
+ | [string]
+ | [string, string]
+ | [string, string, string]
+ | [string, string, string, string]
+ | [string, string, string, string, string]
+ | [string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string, string, string, string, string, string, string]
+ | [
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string
+ ]
+ | [
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string
+ ]
+ | [
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string
+ ]
+ | [
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string
+ ]
+ | [
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string
+ ]
+ | [
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string,
+ string
+ ];
+ /**
+ * Whose ticket it is; a client ignores any event whose subject is someone else.
+ */
+ subject: string;
+}
+
/** [email protected] (owner: network) */
/** [email protected] (owner: network) */
/**
/**
* network.staff-list-result@1: the answer of network.staff.read on OpenVibe.Network. GET /api/v1/staff/moderators[?service=chat|live|community|pastes|calls] (a service token holding network.staff.read, or a signed-in person holding staff.moderation.logs) → { service, staff }: the network's global moderators, admins and the owner who are not banned, with the staff capabilities their role holds in the contracts staff map; ?service keeps those holding the capability that moderates that service (another service name keeps those holding staff.content.moderate; an invalid value is ignored and answered as null). Answered with Cache-Control: private, no-store; a person without staff.moderation.logs gets 403 { error: forbidden }.
* network.staff-list-result@1: the answer of network.staff.read on OpenVibe.Network. GET /api/v1/staff/moderators[?service=chat|live|community|pastes|calls] (a service token holding network.staff.read, or a signed-in person holding staff.moderation.logs) → { service, staff }: the network's global moderators, admins and the owner who are not banned, with the staff capabilities their role holds in the contracts staff map; ?service keeps those holding the capability that moderates that service (another service name keeps those holding staff.content.moderate; an invalid value is ignored and answered as null). Answered with Cache-Control: private, no-store; a person without staff.moderation.logs gets 403 { error: forbidden }.
@@ -76,5 +76,5 @@
@@ -76,5 +76,5 @@
"none": "no claims: one process owns events.db (better-sqlite3, single writer); one delivery per subscription is in flight at a time, tracked in memory"
"none": "no claims: one process owns events.db (better-sqlite3, single writer); one delivery per subscription is in flight at a time, tracked in memory"
}
}
},
},
- "notes": "Durable events, subscriptions, signed delivery with retry, dead letters and replay; SSE realtime gateway at /realtime/stream until ADR-005 decides where Realtime lives."
+ "notes": "Durable events, subscriptions, signed delivery with retry, dead letters and replay; SSE realtime gateway at /realtime/stream (ADR-005); browsers on any OpenVibe site authenticate a stream with a two-minute, single-use realtime ticket from Network (?ticket=, identity.realtime-ticket-claims@1; ADR-005 amendment 2)."
}
}
@@ -54,6 +54,7 @@
@@ -54,6 +54,7 @@
"network.credential.rotated",
"network.credential.rotated",
"network.grant.changed",
"network.grant.changed",
"network.module.updated",
"network.module.updated",
+ "network.notification.created",
"network.principal_grant.changed",
"network.principal_grant.changed",
"network.user.token_valid_after",
"network.user.token_valid_after",
"network.user.updated"
"network.user.updated"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-contracts",
"name": "openvibe-contracts",
- "version": "0.60.0",
+ "version": "0.61.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
"types": "generated/typescript/index.d.ts",
@@ -763,6 +763,34 @@ await assert.rejects(failing.getToken(), /401: invalid_client/);
@@ -763,6 +763,34 @@ await assert.rejects(failing.getToken(), /401: invalid_client/);
ok(contracts.schema('registry.release-manifest').properties.release.pattern === contracts.schema('host.release.published').properties.release.pattern, 'the release pattern is the release manifest\'s');
ok(contracts.schema('registry.release-manifest').properties.release.pattern === contracts.schema('host.release.published').properties.release.pattern, 'the release pattern is the release manifest\'s');
}
}
+// ── A person's realtime topic (WS-E task 3, WS-F task 1; ADR-005 amendment 2) ──
+// network.notification.created carries what a badge needs and nothing it shows; a realtime ticket can
+// never pass for a session (issuer, typ, audience); the badge's topics are Events patterns, never user:<id>.
+{
+ const cat = (id) => contracts.catalog.find(c => c.id === id);
+ for (const id of ['network.notification.created', 'identity.realtime-ticket-claims', 'network.realtime-ticket-result']) {
+ ok(cat(id) && cat(id).owner === 'network' && cat(id).visibility === 'first-party' && cat(id).adr === 'ADR-005', `${id} is a first-party Network contract under ADR-005`);
+ }
+ ok(services.get('network').eventsProduced.includes('network.notification.created'), 'the network manifest produces network.notification.created');
+ const N = (p) => contracts.validate('network.notification.created@1', p).valid;
+ const n = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/network.notification.created/valid/stream-live.json'), 'utf8'));
+ ok(N(n), 'a go-live notification event');
+ for (const k of ['title', 'message', 'url', 'icon', 'sender_id', 'sender_name', 'sender_avatar', 'rich_content', 'user_id', 'subject']) ok(!N({ ...n, [k]: 'x' }), `the event never carries ${k}`);
+ for (const k of ['notification_id', 'type', 'category', 'priority', 'created_at', 'unread_count']) ok(!N({ ...n, [k]: undefined }), `the event needs ${k}`);
+ const T = (c) => contracts.validate('identity.realtime-ticket-claims@1', c).valid;
+ const t = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/identity.realtime-ticket-claims/valid/badge-ticket.json'), 'utf8'));
+ ok(T(t), 'a realtime ticket');
+ ok(!T({ ...t, iss: 'https://openvibe.network' }) && !T({ ...t, typ: undefined }) && !T({ ...t, aud: ['openvibe.network'] }), 'a ticket never has a session\'s issuer, lacks typ, or names another audience');
+ ok(!T({ ...t, subject_id: t.sub }) && !T({ ...t, id: 57 }) && !T({ ...t, cap: ['events.event.read'] }), 'a ticket carries no session or principal claims');
+ const R = (r) => contracts.validate('network.realtime-ticket-result@1', r).valid;
+ const r = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/network.realtime-ticket-result/valid/badge.json'), 'utf8'));
+ ok(R(r) && !R({ ...r, topics: ['user:' + t.sub] }) && !R({ ...r, topics: ['network.notification:x'] }), 'the badge subscribes to Events patterns, never user:<id>');
+ // The badge's pattern matches the event type (Events topics.js: `*` is one or more whole segments).
+ const match = (p, type) => new RegExp(`^${p.split('.').map(s => (s === '*' ? '[a-z0-9_]+(?:\\.[a-z0-9_]+)*' : s)).join('\\.')}$`).test(type);
+ ok(r.topics.every(p => match(p, 'network.notification.created')), 'the ticket\'s topics match network.notification.created');
+ ok(R({ ...r, expires_in: 300 }) && !R({ ...r, expires_in: 301 }), 'a ticket lives at most 300 s');
+}
+
// ── Capability schemas (WS-C task 4): a ratchet over compatibility/capability-schema-gaps.json ──
// ── Capability schemas (WS-C task 4): a ratchet over compatibility/capability-schema-gaps.json ──
execFileSync(process.execPath, [path.join(__dirname, 'capability-schemas.test.js')], { stdio: 'inherit' });
execFileSync(process.execPath, [path.join(__dirname, 'capability-schemas.test.js')], { stdio: 'inherit' });