openvibers/openvibe.contracts Commits

v0.39.0: network.user.token_valid_after (revocation propagation, WS-B task 4) — a person's tokens issued before valid_after are refused everywhere (iat * 1000 < valid_after, Network's rule) and their sockets closed; Network produces it on password change or reset, sign out everywhere, ban, deletion and staff revocation; Chat, Community, Live, Media, Tools and Games consume it; fixtures

OP OpenVibers committed 24 Sept 2026 · 00cb9478dd · 18 files, +136 −11


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a62247fc..0f11a097 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,10 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
the notes were in the tag and commit messages (`git tag -n1`).
++## 0.39.0 — 2026-09-24
++
++**`network.user.token_valid_after`** (revocation propagation, roadmap WS-B task 4): when a person's tokens stop being good (a password change or reset, sign out everywhere, a ban, an account deletion or staff ending their sessions), Network moves their cutoff and emits this event in the same transaction. Every service that accepts Network user tokens refuses one whose `iat * 1000 < Date.parse(valid_after)` (Network's own rule), drops what it cached for those tokens and closes the sockets they opened. Consumers keep the latest cutoff per subject. Network produces it; Chat, Community, Live, Media, Tools and Games consume it. Fixtures cover valid and invalid payloads. Additive.
++
## 0.38.0 — 2026-09-24
Staff map 1.1.0 (ADR-022), so the content products and Games can check staff capabilities instead of comparing role names:
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex 855df9bc..96130aa0 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -873,6 +873,15 @@
"compatibility": "backward",
"status": "active"
},
++ {
++ "id": "network.user.token_valid_after",
++ "version": "1.0.0",
++ "owner": "network",
++ "schema": "events/payloads/network.user.token_valid_after.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
++ },
{
"id": "tools.tool",
"version": "1.1.0",
ddiff --git a/contracts/events/payloads/network.user.token_valid_after.v1.json b/contracts/events/payloads/network.user.token_valid_after.v1.json
nnew file mode 100644
iindex 00000000..ebe988d7
---- /dev/null
++++ b/contracts/events/payloads/network.user.token_valid_after.v1.json
@@@ -0,0 +1,21 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/network.user.token_valid_after.v1.json",
++ "title": "NetworkUserTokenValidAfterPayload",
++ "description": "network.user.token_valid_after v1 (OpenVibe.Network server/auth/revocation.js). A person's tokens issued before valid_after are no longer good: every service that accepts Network user tokens refuses one whose iat (seconds) * 1000 is less than valid_after (milliseconds), exactly as Network does, drops what it cached for that person's older tokens and closes the sockets they opened. Emitted in the transaction that moves the cutoff: a password change or reset, sign out everywhere, a ban, an account deletion, or staff ending someone's sessions. Consumers keep the latest valid_after per subject and ignore an older one (events can arrive out of order). Envelope: subject { type: user, id }, visibility internal, actor the person (their own change) or the staff member / system:network.",
++ "type": "object",
++ "required": ["subject", "valid_after", "reason"],
++ "additionalProperties": false,
++ "properties": {
++ "subject": {
++ "type": "object", "required": ["type", "id"], "additionalProperties": false,
++ "properties": { "type": { "const": "user" }, "id": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" } },
++ "description": "The person (identity.subject-ref@1, a user)."
++ },
++ "valid_after": { "type": "string", "format": "date-time", "description": "Tokens issued before this instant are refused (compare iat * 1000 < Date.parse(valid_after))." },
++ "reason": {
++ "enum": ["password_changed", "password_reset", "signed_out_everywhere", "banned", "account_deleted", "staff_revoked"],
++ "description": "Why the cutoff moved. Consumers treat every reason the same; it is for logs and support."
++ }
++ }
++}
ddiff --git a/fixtures/network.user.token_valid_after/invalid/guest-subject.json b/fixtures/network.user.token_valid_after/invalid/guest-subject.json
nnew file mode 100644
iindex 00000000..4e2598c6
---- /dev/null
++++ b/fixtures/network.user.token_valid_after/invalid/guest-subject.json
@@@ -0,0 +1 @@
++{ "subject": { "type": "guest", "id": "gst_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3" }, "valid_after": "2026-09-24T20:15:00Z", "reason": "signed_out_everywhere" }
ddiff --git a/fixtures/network.user.token_valid_after/invalid/unknown-reason.json b/fixtures/network.user.token_valid_after/invalid/unknown-reason.json
nnew file mode 100644
iindex 00000000..c95eb490
---- /dev/null
++++ b/fixtures/network.user.token_valid_after/invalid/unknown-reason.json
@@@ -0,0 +1 @@
++{ "subject": { "type": "user", "id": "usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3" }, "valid_after": "2026-09-24T20:15:00Z", "reason": "felt_like_it" }
ddiff --git a/fixtures/network.user.token_valid_after/valid/password-changed.json b/fixtures/network.user.token_valid_after/valid/password-changed.json
nnew file mode 100644
iindex 00000000..ed74376c
---- /dev/null
++++ b/fixtures/network.user.token_valid_after/valid/password-changed.json
@@@ -0,0 +1 @@
++{ "subject": { "type": "user", "id": "usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3" }, "valid_after": "2026-09-24T20:15:07Z", "reason": "password_changed" }
ddiff --git a/fixtures/network.user.token_valid_after/valid/signed-out-everywhere.json b/fixtures/network.user.token_valid_after/valid/signed-out-everywhere.json
nnew file mode 100644
iindex 00000000..5122f4a2
---- /dev/null
++++ b/fixtures/network.user.token_valid_after/valid/signed-out-everywhere.json
@@@ -0,0 +1 @@
++{ "subject": { "type": "user", "id": "usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3" }, "valid_after": "2026-09-24T20:15:00.000Z", "reason": "signed_out_everywhere" }
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 85bf58d9..0a14c355 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -9947,6 +9947,60 @@
}
}
},
++ "network.user.token_valid_after": {
++ "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.user.token_valid_after.v1.json",
++ "title": "NetworkUserTokenValidAfterPayload",
++ "description": "network.user.token_valid_after v1 (OpenVibe.Network server/auth/revocation.js). A person's tokens issued before valid_after are no longer good: every service that accepts Network user tokens refuses one whose iat (seconds) * 1000 is less than valid_after (milliseconds), exactly as Network does, drops what it cached for that person's older tokens and closes the sockets they opened. Emitted in the transaction that moves the cutoff: a password change or reset, sign out everywhere, a ban, an account deletion, or staff ending someone's sessions. Consumers keep the latest valid_after per subject and ignore an older one (events can arrive out of order). Envelope: subject { type: user, id }, visibility internal, actor the person (their own change) or the staff member / system:network.",
++ "type": "object",
++ "required": [
++ "subject",
++ "valid_after",
++ "reason"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "subject": {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "The person (identity.subject-ref@1, a user)."
++ },
++ "valid_after": {
++ "type": "string",
++ "format": "date-time",
++ "description": "Tokens issued before this instant are refused (compare iat * 1000 < Date.parse(valid_after))."
++ },
++ "reason": {
++ "enum": [
++ "password_changed",
++ "password_reset",
++ "signed_out_everywhere",
++ "banned",
++ "account_deleted",
++ "staff_revoked"
++ ],
++ "description": "Why the cutoff moved. Consumers treat every reason the same; it is for logs and support."
++ }
++ }
++ }
++ },
"tools.tool": {
"version": "1.1.0",
"owner": "tools",
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 309c6dda..63f5b194 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -9330,6 +9330,29 @@ export interface NetworkModuleUpdatedPayload {
};
}
++/** [email protected] (owner: network) */
++/**
++ * network.user.token_valid_after v1 (OpenVibe.Network server/auth/revocation.js). A person's tokens issued before valid_after are no longer good: every service that accepts Network user tokens refuses one whose iat (seconds) * 1000 is less than valid_after (milliseconds), exactly as Network does, drops what it cached for that person's older tokens and closes the sockets they opened. Emitted in the transaction that moves the cutoff: a password change or reset, sign out everywhere, a ban, an account deletion, or staff ending someone's sessions. Consumers keep the latest valid_after per subject and ignore an older one (events can arrive out of order). Envelope: subject { type: user, id }, visibility internal, actor the person (their own change) or the staff member / system:network.
++ */
++export interface NetworkUserTokenValidAfterPayload {
++ /**
++ * The person (identity.subject-ref@1, a user).
++ */
++ subject: {
++ type: "user";
++ id: string;
++ };
++ /**
++ * Tokens issued before this instant are refused (compare iat * 1000 < Date.parse(valid_after)).
++ */
++ valid_after: string;
++ /**
++ * Why the cutoff moved. Consumers treat every reason the same; it is for logs and support.
++ */
++ reason:
++ "password_changed" | "password_reset" | "signed_out_everywhere" | "banned" | "account_deleted" | "staff_revoked";
++}
++
/** [email protected] (owner: tools) */
/**
* One tool on openvibe.tools as its registry describes it (GET /api/v1/tools/:id, and each item of GET /api/v1/tools; capability tools.tool.read; ADR-027). There is one descriptor per catalogue tool (GET /api/catalog.json tools[].id), built from the tool's own code, so its page, the run API, openvibe-sdk/tools, the OpenAPI document and the docs all read the same facts. EXECUTION is where the tool's engine runs for its page: client (in the browser; nothing leaves it), sync (a server request answered inline) or job (an asynchronous job on a satellite, tools.job@1). API says whether POST /api/v1/tools/{id}/run exposes the tool. A client tool has api true only when it also has a server engine (a pure transform that runs in Node), and its page stays browser-only. A page-only tool (yt) has api false and run null. A job tool's run creates a job of run.job.type whose input is { ...input, ...run.job.preset, tool: run.job.operation } (the preset and the operation always win). INPUT is the JSON Schema (2020-12) that a run request's input must match, embedded or as { $ref: https://openvibe.tools/api/v1/tools/{id}/schema#/$defs/input }. GET /api/v1/tools/:id/schema answers { $schema, $id, $defs: { input, output } }, and the list uses the $ref form. When api is false, $defs.input is false (the schema that accepts nothing), since there is no run to take an input. OUTPUT.SCHEMA describes result.data for every execution: inline and job tools alike. KEYWORDS (the catalogue's search terms) and EXAMPLES (sample runs for the docs and the OpenAPI document) are optional. NOT A TOOL: there is no planned status. A planned catalogue entry has no descriptor and is not listed, and neither is a mirror (a second build of another tool). GET /api/v1/tools/:id and /schema answer 404 tools.tool.not_found for either, with a detail that says the id is planned or names the tool it mirrors, as for an id that does not exist. RULES this schema enforces: api false has no run and no examples, and api true has a run and an input; an API job tool names its job, and only job tools name one; an API tool whose output is a file runs as a job (its results are served as job files); a tools.net.probe tool fetches (egress) and is never anonymous; an anonymous egress tool has a per-target throttle (limits.perTargetPerMinute); a client tool never fetches; an unavailable tool says why (statusReason); JSON output has a schema. contracts.tools.checkDescriptor(d) also checks what depends on the id (run.path, $ref targets), files.min <= files.max, and each example: its input against an embedded input schema and limits.maxInputBytes, and its files against files (count and accept).
ddiff --git a/manifests/services/chat.json b/manifests/services/chat.json
iindex c6479141..1f4baec8 100644
---- a/manifests/services/chat.json
++++ b/manifests/services/chat.json
@@@ -19,7 +19,9 @@
"chat.dm.created",
"chat.moderation.action"
],
-- "eventsConsumed": [],
++ "eventsConsumed": [
++ "network.user.token_valid_after"
++ ],
"namespacesOwned": [
"chat.preferences"
],
ddiff --git a/manifests/services/community.json b/manifests/services/community.json
iindex 82faab2c..43f26ae9 100644
---- a/manifests/services/community.json
++++ b/manifests/services/community.json
@@@ -35,7 +35,9 @@
"community.post.created",
"community.thread.created"
],
-- "eventsConsumed": [],
++ "eventsConsumed": [
++ "network.user.token_valid_after"
++ ],
"namespacesOwned": [
"community.*"
],
ddiff --git a/manifests/services/games.json b/manifests/services/games.json
iindex 6f6a5c77..c06a0529 100644
---- a/manifests/services/games.json
++++ b/manifests/services/games.json
@@@ -28,7 +28,7 @@
"games.mod.grants_changed",
"games.mod.revoked"
],
-- "eventsConsumed": [],
++ "eventsConsumed": ["network.user.token_valid_after"],
"namespacesOwned": ["games.*"],
"notes": "Scraplandia (browser runtime). Players are keyed by canonical Network subjects (ADR-0006 in the repo). Calls Events (events.event.publish), Media (media.object.upload, namespace games: map-editor assets) and Network identity (identity.subject.resolve, batch legacy adoption) with the games client-credentials principal. Mods: games-content@1 declarative packs only; executable mods wait for OpenVibe.Host Stage C. No achievement system exists, so there are no games.achievement.* events."
}
ddiff --git a/manifests/services/live.json b/manifests/services/live.json
iindex 45d7f0ae..9f577cb6 100644
---- a/manifests/services/live.json
++++ b/manifests/services/live.json
@@@ -35,7 +35,8 @@
"openre.session.*",
"media.vod.*",
"media.clip.*",
-- "media.storage.*"
++ "media.storage.*",
++ "network.user.token_valid_after"
],
"namespacesOwned": [
"live.*"
ddiff --git a/manifests/services/media.json b/manifests/services/media.json
iindex 47760817..00395c89 100644
---- a/manifests/services/media.json
++++ b/manifests/services/media.json
@@@ -39,7 +39,9 @@
"media.vod.failed",
"media.vod.ready"
],
-- "eventsConsumed": [],
++ "eventsConsumed": [
++ "network.user.token_valid_after"
++ ],
"namespacesOwned": [
"media.*"
]
ddiff --git a/manifests/services/network.json b/manifests/services/network.json
iindex 04da6f8b..67fb85ba 100644
---- a/manifests/services/network.json
++++ b/manifests/services/network.json
@@@ -33,10 +33,11 @@
"eventsProduced": [
"network.app.created",
"network.app.revoked",
-- "network.credential.rotated",
"network.credential.revoked",
++ "network.credential.rotated",
"network.grant.changed",
-- "network.module.updated"
++ "network.module.updated",
++ "network.user.token_valid_after"
],
"eventsConsumed": [
"billing.staff.action",
ddiff --git a/manifests/services/tools.json b/manifests/services/tools.json
iindex 19bd4722..e1e228b2 100644
---- a/manifests/services/tools.json
++++ b/manifests/services/tools.json
@@@ -27,7 +27,9 @@
"tools.job.succeeded",
"tools.job.failed"
],
-- "eventsConsumed": [],
++ "eventsConsumed": [
++ "network.user.token_valid_after"
++ ],
"namespacesOwned": [
"tools.*"
],
ddiff --git a/package-lock.json b/package-lock.json
iindex ea95e229..27e5bdf1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-contracts",
-- "version": "0.38.0",
++ "version": "0.39.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-contracts",
-- "version": "0.38.0",
++ "version": "0.39.0",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 0bbfded8..2b7c37c4 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.38.0",
++ "version": "0.39.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",