openvibers/openvibe.games Commits

Sign-out everywhere reaches Games (WS-B task 4, Contracts 0.39.0, SDK 0.9.1): POST /internal/events (GAMES_EVENTS_SECRET, signature v2, loopback only) applies network.user.token_valid_after into the SDK revocation store and closes the person's sessions opened with an older sign-in (reject signed_out, 4011); guests are never touched; the subscription is created at boot when the secret is set. Sessions keep their sign-in's iat

OP OpenVibers committed 24 Sept 2026 · 5c002f2c48 · 24 files, +454 −22


Changes

ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 4d74286..4f6251a 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -357,9 +357,13 @@ async function start(): Promise<void> {
}
} else if (msg.reason === 'guest_one_character') {
hud.setStatus('guests get one character — sign in with OpenVibe for 3 slots')
-- } else if (msg.reason === 'auth_failed') {
++ } else if (msg.reason === 'auth_failed' || msg.reason === 'signed_out') {
localStorage.removeItem('ovg_sso')
-- hud.setStatus('OpenVibe sign-in expired — refresh to continue as guest or sign in again')
++ hud.setStatus(
++ msg.reason === 'signed_out'
++ ? 'You signed out of OpenVibe everywhere — refresh to continue as guest or sign in again'
++ : 'OpenVibe sign-in expired — refresh to continue as guest or sign in again',
++ )
} else {
hud.setStatus(`rejected: ${msg.reason} — refresh the page`)
}
ddiff --git a/apps/server/dist-types/config.d.ts b/apps/server/dist-types/config.d.ts
iindex 7e9c37e..6020b60 100644
---- a/apps/server/dist-types/config.d.ts
++++ b/apps/server/dist-types/config.d.ts
@@@ -50,6 +50,14 @@ export interface PlatformConfig {
mediaNamespace: string;
/** At most one `games.world.saved` checkpoint event per this many minutes. */
worldSavedEventMinutes: number;
++ /**
++ * GAMES_EVENTS_SECRET (comma-separated for rotation, 32+ characters each): signs Games' own Events
++ * subscriptions (network.user.token_valid_after → POST /internal/events). Empty = no subscription,
++ * and the route answers 503.
++ */
++ eventsSecrets: string[];
++ /** Where Events delivers (GAMES_EVENTS_ENDPOINT, else loopback on PORT). */
++ eventsEndpoint: string | null;
}
export declare function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig;
export declare function loadConfig(env: NodeJS.ProcessEnv): ServerConfig;
ddiff --git a/apps/server/dist-types/config.d.ts.map b/apps/server/dist-types/config.d.ts.map
iindex 32521d8..7acb542 100644
---- a/apps/server/dist-types/config.d.ts.map
++++ b/apps/server/dist-types/config.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,0EAA0E;IAC1E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,+EAA+E;IAC/E,cAAc,EAAE,OAAO,CAAA;IACvB,gFAAgF;IAChF,KAAK,EAAE;QACL,QAAQ,EAAE,MAAM,CAAA;QAChB,YAAY,EAAE,MAAM,CAAA;QACpB,OAAO,EAAE,MAAM,CAAA;QACf,OAAO,EAAE,MAAM,CAAA;QACf,6DAA6D;QAC7D,OAAO,EAAE,MAAM,CAAA;KAChB,GAAG,IAAI,CAAA;IACR;;;;OAIG;IACH,QAAQ,EAAE,cAAc,CAAA;IACxB,QAAQ,EAAE,MAAM,CAAA;IAChB,qCAAqC;IACrC,aAAa,EAAE,MAAM,CAAA;IACrB,8EAA8E;IAC9E,cAAc,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,iBAAiB,EAAE,MAAM,CAAA;IACzB,4DAA4D;IAC5D,kBAAkB,EAAE,MAAM,CAAA;CAC3B;AAED,MAAM,WAAW,cAAc;IAC7B,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAA;IAChB,yDAAyD;IACzD,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,0FAA0F;IAC1F,UAAU,EAAE,MAAM,CAAA;IAClB,uFAAuF;IACvF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,iEAAiE;IACjE,cAAc,EAAE,MAAM,CAAA;IACtB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAA;CAC/B;AAMD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,cAAc,CAazE;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,YAAY,CAkC/D"}
\\ No newline at end of file
++{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,0EAA0E;IAC1E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,+EAA+E;IAC/E,cAAc,EAAE,OAAO,CAAA;IACvB,gFAAgF;IAChF,KAAK,EAAE;QACL,QAAQ,EAAE,MAAM,CAAA;QAChB,YAAY,EAAE,MAAM,CAAA;QACpB,OAAO,EAAE,MAAM,CAAA;QACf,OAAO,EAAE,MAAM,CAAA;QACf,6DAA6D;QAC7D,OAAO,EAAE,MAAM,CAAA;KAChB,GAAG,IAAI,CAAA;IACR;;;;OAIG;IACH,QAAQ,EAAE,cAAc,CAAA;IACxB,QAAQ,EAAE,MAAM,CAAA;IAChB,qCAAqC;IACrC,aAAa,EAAE,MAAM,CAAA;IACrB,8EAA8E;IAC9E,cAAc,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,iBAAiB,EAAE,MAAM,CAAA;IACzB,4DAA4D;IAC5D,kBAAkB,EAAE,MAAM,CAAA;CAC3B;AAED,MAAM,WAAW,cAAc;IAC7B,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAA;IAChB,yDAAyD;IACzD,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,0FAA0F;IAC1F,UAAU,EAAE,MAAM,CAAA;IAClB,uFAAuF;IACvF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,iEAAiE;IACjE,cAAc,EAAE,MAAM,CAAA;IACtB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAA;IAC9B;;;;OAIG;IACH,aAAa,EAAE,MAAM,EAAE,CAAA;IACvB,4EAA4E;IAC5E,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAMD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,cAAc,CAmBzE;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,YAAY,CAkC/D"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/game/gameServer.d.ts b/apps/server/dist-types/game/gameServer.d.ts
iindex 0f649be..00c2233 100644
---- a/apps/server/dist-types/game/gameServer.d.ts
++++ b/apps/server/dist-types/game/gameServer.d.ts
@@@ -178,6 +178,12 @@ export declare class GameServer {
private broadcastDespawn;
private broadcastToKnowing;
/** Live map edit: every client refetches and rebuilds its terrain. */
++ /**
++ * Network moved this person's token cutoff (sign out everywhere, password changed, banned): close
++ * every session they opened with an older Network sign-in (4011 signed_out). The client's reconnect
++ * then fails its /api/auth/me check. Returns how many closed.
++ */
++ revokeSubject(subjectId: string, validAfterMs: number): number;
broadcastMapReload(): void;
/** Exposes crafting context for the client-facing recipe availability (welcome-time). */
workstationsNear(session: PlayerSession): ReadonlySet<string>;
ddiff --git a/apps/server/dist-types/game/gameServer.d.ts.map b/apps/server/dist-types/game/gameServer.d.ts.map
iindex 2cf2688..23734ee 100644
---- a/apps/server/dist-types/game/gameServer.d.ts.map
++++ b/apps/server/dist-types/game/gameServer.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
++{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/game/playerSession.d.ts b/apps/server/dist-types/game/playerSession.d.ts
iindex 4a4efb5..f6918f6 100644
---- a/apps/server/dist-types/game/playerSession.d.ts
++++ b/apps/server/dist-types/game/playerSession.d.ts
@@@ -37,6 +37,11 @@ export interface PlayerSession {
token: string;
/** Canonical openvibe.network subject of the account; null for local guests. */
subjectId: string | null;
++ /**
++ * When the Network sign-in behind this session was issued (JWT iat, seconds); null for guests. A
++ * sign-out everywhere after it closes the session (network.user.token_valid_after).
++ */
++ authIat: number | null;
/** Character slot under the account token (0..2). */
charSlot: number;
/** openvibe.network rank; gates edit mode + moderation. */
@@@ -105,6 +110,7 @@ export interface SessionInit {
entityId: EntityId;
token: string;
subjectId?: string | null;
++ authIat?: number | null;
charSlot?: number;
rank?: 'owner' | 'admin' | 'moderator' | null;
name: string;
ddiff --git a/apps/server/dist-types/game/playerSession.d.ts.map b/apps/server/dist-types/game/playerSession.d.ts.map
iindex 114f3db..79b9245 100644
---- a/apps/server/dist-types/game/playerSession.d.ts.map
++++ b/apps/server/dist-types/game/playerSession.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"playerSession.d.ts","sourceRoot":"","sources":["../../src/game/playerSession.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
++{"version":3,"file":"playerSession.d.ts","sourceRoot":"","sources":["../../src/game/playerSession.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/revocationEvents.d.ts b/apps/server/dist-types/platform/revocationEvents.d.ts
nnew file mode 100644
iindex 0000000..76d0b35
---- /dev/null
++++ b/apps/server/dist-types/platform/revocationEvents.d.ts
@@@ -0,0 +1,59 @@
++/**
++ * Sign-out everywhere reaches Games (roadmap WS-B task 4; Contracts 0.39.0
++ * network.user.token_valid_after).
++ *
++ * POST /internal/events is the endpoint of Games' Events subscription to that topic, signed with
++ * GAMES_EVENTS_SECRET (signature v2 only) and loopback only (a request carrying a forwarding header
++ * came through nginx and is refused). A delivery moves the person's cutoff in the SDK's revocation
++ * store (only ever forward; anything not from Network is ignored), then `onRevoked` closes the game
++ * sessions they opened with an older sign-in. New sign-ins already fail: Games checks every token with
++ * the Network's /api/auth/me, which applies the same cutoff.
++ */
++import type { IncomingMessage, ServerResponse } from 'node:http';
++import type { Logger } from '@openvibe/shared';
++interface RevocationStore {
++ apply(event: unknown): string;
++ isRevoked(claims: {
++ iat?: number;
++ subject_id?: string;
++ } | null | undefined): boolean;
++ cutoffFor(subject: string): number;
++}
++export declare const TOPIC = "network.user.token_valid_after";
++export interface RevocationEvents {
++ /** Answers POST /internal/events (returns true when the request was ours). */
++ handle(req: IncomingMessage, res: ServerResponse): boolean;
++ store: RevocationStore;
++ stats: {
++ received: number;
++ revoked: number;
++ closed: number;
++ refused: number;
++ ignored: number;
++ };
++}
++export declare function createRevocationEvents(opts: {
++ db: unknown;
++ secrets: string[];
++ onRevoked: (subjectId: string, validAfterMs: number) => number;
++ log: Logger;
++}): RevocationEvents;
++/**
++ * Create Games' subscription to TOPIC at Events if it is missing (idempotent: an existing one, even
++ * disabled by an operator, is left as it is). Needs the grant games events.subscription.manage.
++ */
++export declare function ensureRevocationSubscription(opts: {
++ eventsUrl: string;
++ endpoint: string;
++ secret: string;
++ tokens: {
++ getToken(ctx?: {
++ audience?: string;
++ scope?: string;
++ }): Promise<string>;
++ };
++ fetchImpl?: typeof fetch;
++ log: Logger;
++}): Promise<'exists' | 'created'>;
++export {};
++//# sourceMappingURL=revocationEvents.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/revocationEvents.d.ts.map b/apps/server/dist-types/platform/revocationEvents.d.ts.map
nnew file mode 100644
iindex 0000000..0448102
---- /dev/null
++++ b/apps/server/dist-types/platform/revocationEvents.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"revocationEvents.d.ts","sourceRoot":"","sources":["../../src/platform/revocationEvents.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAI9C,UAAU,eAAe;IACvB,KAAK,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAA;IAC7B,SAAS,CAAC,MAAM,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAAA;IACpF,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAA;CACnC;AAaD,eAAO,MAAM,KAAK,mCAAmC,CAAA;AAIrD,MAAM,WAAW,gBAAgB;IAC/B,8EAA8E;IAC9E,MAAM,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,OAAO,CAAA;IAC1D,KAAK,EAAE,eAAe,CAAA;IACtB,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAC/F;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,EAAE,EAAE,OAAO,CAAA;IACX,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,MAAM,CAAA;IAC9D,GAAG,EAAE,MAAM,CAAA;CACZ,GAAG,gBAAgB,CA0DnB;AAED;;;GAGG;AACH,wBAAsB,4BAA4B,CAAC,IAAI,EAAE;IACvD,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE;QAAE,QAAQ,CAAC,GAAG,CAAC,EAAE;YAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;KAAE,CAAA;IAClF,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,GAAG,EAAE,MAAM,CAAA;CACZ,GAAG,OAAO,CAAC,QAAQ,GAAG,SAAS,CAAC,CAkBhC"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/revocationEvents.test.d.ts b/apps/server/dist-types/platform/revocationEvents.test.d.ts
nnew file mode 100644
iindex 0000000..226b2df
---- /dev/null
++++ b/apps/server/dist-types/platform/revocationEvents.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=revocationEvents.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/revocationEvents.test.d.ts.map b/apps/server/dist-types/platform/revocationEvents.test.d.ts.map
nnew file mode 100644
iindex 0000000..f9e4b5f
---- /dev/null
++++ b/apps/server/dist-types/platform/revocationEvents.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"revocationEvents.test.d.ts","sourceRoot":"","sources":["../../src/platform/revocationEvents.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/serviceClient.d.ts b/apps/server/dist-types/platform/serviceClient.d.ts
iindex 5e6bc7d..eb5bc76 100644
---- a/apps/server/dist-types/platform/serviceClient.d.ts
++++ b/apps/server/dist-types/platform/serviceClient.d.ts
@@@ -3,6 +3,13 @@ import type { PlatformConfig } from '../config.js';
export interface PlatformClient {
client: OpenVibeClient;
clientId: string;
++ /** The `games` principal's tokens, for calls the SDK client has no method for (Events subscriptions). */
++ tokens: {
++ getToken(ctx?: {
++ audience?: string;
++ scope?: string;
++ }): Promise<string>;
++ };
}
/** Null when no client secret is configured: Games then makes no service calls. */
export declare function createPlatformClient(cfg: PlatformConfig, fetchImpl?: FetchLike): PlatformClient | null;
ddiff --git a/apps/server/dist-types/platform/serviceClient.d.ts.map b/apps/server/dist-types/platform/serviceClient.d.ts.map
iindex 4dbd21a..4c65a55 100644
---- a/apps/server/dist-types/platform/serviceClient.d.ts.map
++++ b/apps/server/dist-types/platform/serviceClient.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"serviceClient.d.ts","sourceRoot":"","sources":["../../src/platform/serviceClient.ts"],"names":[],"mappings":"AAYA,OAAO,EAAgB,KAAK,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACrF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAElD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,cAAc,CAAA;IACtB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,mFAAmF;AACnF,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,cAAc,EACnB,SAAS,CAAC,EAAE,SAAS,GACpB,cAAc,GAAG,IAAI,CAqBvB"}
\\ No newline at end of file
++{"version":3,"file":"serviceClient.d.ts","sourceRoot":"","sources":["../../src/platform/serviceClient.ts"],"names":[],"mappings":"AAYA,OAAO,EAAgB,KAAK,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACrF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAElD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,cAAc,CAAA;IACtB,QAAQ,EAAE,MAAM,CAAA;IAChB,yGAAyG;IACzG,MAAM,EAAE;QAAE,QAAQ,CAAC,GAAG,CAAC,EAAE;YAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;KAAE,CAAA;CACnF;AAED,mFAAmF;AACnF,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,cAAc,EACnB,SAAS,CAAC,EAAE,SAAS,GACpB,cAAc,GAAG,IAAI,CAqBvB"}
\\ No newline at end of file
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex f5c2437..c85df54 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -19,8 +19,8 @@
"@openvibe/shared": "workspace:*",
"ajv": "^8.17.1",
"better-sqlite3": "^11.9.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1",
"tsx": "^4.19.3",
"ws": "^8.18.1"
},
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex bea9ba7..d337bcb 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -51,6 +51,14 @@ export interface PlatformConfig {
mediaNamespace: string
/** At most one `games.world.saved` checkpoint event per this many minutes. */
worldSavedEventMinutes: number
++ /**
++ * GAMES_EVENTS_SECRET (comma-separated for rotation, 32+ characters each): signs Games' own Events
++ * subscriptions (network.user.token_valid_after → POST /internal/events). Empty = no subscription,
++ * and the route answers 503.
++ */
++ eventsSecrets: string[]
++ /** Where Events delivers (GAMES_EVENTS_ENDPOINT, else loopback on PORT). */
++ eventsEndpoint: string | null
}
function trimSlash(v: string): string {
@@@ -69,6 +77,12 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
mediaUrl: env.MEDIA_URL && env.MEDIA_MIRROR !== 'off' ? trimSlash(env.MEDIA_URL) : null,
mediaNamespace: env.MEDIA_NAMESPACE ?? 'games',
worldSavedEventMinutes: intEnv(env, 'WORLD_SAVED_EVENT_MINUTES', 15),
++ eventsSecrets: (env.GAMES_EVENTS_SECRET ?? '')
++ .split(',')
++ .map((s) => s.trim())
++ .filter((s) => s.length >= 32),
++ eventsEndpoint:
++ env.GAMES_EVENTS_ENDPOINT ?? `http://127.0.0.1:${intEnv(env, 'PORT', 8000)}/internal/events`,
}
}
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 1ebc9c7..2db9891 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -120,6 +120,18 @@ export interface GameIntegrations {
mods?: ModRuntime
}
++/** A JWT's iat (seconds), read from a token the Network has already accepted; null otherwise. */
++function tokenIat(jwt: string): number | null {
++ const part = jwt.split('.')[1]
++ if (!part) return null
++ try {
++ const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as { iat?: unknown }
++ return typeof claims.iat === 'number' ? claims.iat : null
++ } catch {
++ return null
++ }
++}
++
export class GameServer {
private readonly sessions = new Map<PlayerId, PlayerSession>()
private readonly sessionsByConn = new Map<GameConnection, PlayerSession>()
@@@ -1213,6 +1225,7 @@ export class GameServer {
// recovery path when the token is gone.
let token = msg.token
let subjectId: string | null = null
++ let authIat: number | null = null
let rank: 'owner' | 'admin' | 'moderator' | null = null
if (msg.auth) {
const user = await resolveNetworkUser(this.config.networkAuthUrl, msg.auth)
@@@ -1234,6 +1247,7 @@ export class GameServer {
}
token = account.key
subjectId = account.subjectId
++ authIat = tokenIat(msg.auth)
rank = user.rank
} else {
// A guest token may never look like an account key (`usr_…`,
@@@ -1298,6 +1312,7 @@ export class GameServer {
entityId: newEntityId(),
token,
subjectId,
++ authIat,
rank,
name: msg.name,
spawn,
@@@ -2672,6 +2687,28 @@ export class GameServer {
}
/** Live map edit: every client refetches and rebuilds its terrain. */
++ /**
++ * Network moved this person's token cutoff (sign out everywhere, password changed, banned): close
++ * every session they opened with an older Network sign-in (4011 signed_out). The client's reconnect
++ * then fails its /api/auth/me check. Returns how many closed.
++ */
++ revokeSubject(subjectId: string, validAfterMs: number): number {
++ if (!subjectId || !Number.isFinite(validAfterMs)) return 0
++ let closed = 0
++ for (const session of [...this.sessions.values()]) {
++ if (session.subjectId !== subjectId) continue
++ if (session.authIat !== null && session.authIat * 1000 >= validAfterMs) continue
++ try {
++ session.send(encodeServerMessage({ t: 'reject', reason: 'signed_out' }))
++ } catch {
++ // closing anyway
++ }
++ session.closeConnection(4011, 'signed_out')
++ closed++
++ }
++ return closed
++ }
++
broadcastMapReload(): void {
this.broadcastAll({ t: 'map_reload' })
this.broadcastAll({ t: 'announce', text: '🗺 The world was reshaped by the map editors…' })
ddiff --git a/apps/server/src/game/platformIntegration.test.ts b/apps/server/src/game/platformIntegration.test.ts
iindex f456ba4..b2f6e42 100644
---- a/apps/server/src/game/platformIntegration.test.ts
++++ b/apps/server/src/game/platformIntegration.test.ts
@@@ -300,4 +300,19 @@ describe('Games on platform identity, events and mods (real server, restart)', (
await b.outbox.stop()
b.dispose()
}, 60_000)
++
++ it('a sign-out everywhere closes the signed-in session, never a guest (WS-B task 4)', async () => {
++ const c = boot(platform)
++ const ana = connect(c.game)
++ expect((await ana.hello('abcdefgh12', 'tok-ana')).welcome).toBeDefined()
++ const guest = connect(c.game)
++ expect((await guest.hello('guest00042')).welcome).toBeDefined()
++ expect(c.game.revokeSubject('usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2ZZ', Date.now())).toBe(0)
++ expect(c.game.revokeSubject(SUBJECT, Date.now())).toBe(1)
++ expect(ana.inbox.some((m) => m.t === 'reject' && m.reason === 'signed_out')).toBe(true)
++ expect(guest.inbox.some((m) => m.t === 'reject')).toBe(false)
++ c.game.shutdown()
++ await c.outbox.stop()
++ c.dispose()
++ }, 60_000)
})
ddiff --git a/apps/server/src/game/playerSession.ts b/apps/server/src/game/playerSession.ts
iindex 6bbcce3..4395224 100644
---- a/apps/server/src/game/playerSession.ts
++++ b/apps/server/src/game/playerSession.ts
@@@ -47,6 +47,11 @@ export interface PlayerSession {
token: string
/** Canonical openvibe.network subject of the account; null for local guests. */
subjectId: string | null
++ /**
++ * When the Network sign-in behind this session was issued (JWT iat, seconds); null for guests. A
++ * sign-out everywhere after it closes the session (network.user.token_valid_after).
++ */
++ authIat: number | null
/** Character slot under the account token (0..2). */
charSlot: number
/** openvibe.network rank; gates edit mode + moderation. */
@@@ -113,6 +118,7 @@ export interface SessionInit {
entityId: EntityId
token: string
subjectId?: string | null
++ authIat?: number | null
charSlot?: number
rank?: 'owner' | 'admin' | 'moderator' | null
name: string
@@@ -138,6 +144,7 @@ export function createSession(init: SessionInit): PlayerSession {
entityId: init.entityId,
token: init.token,
subjectId: init.subjectId ?? null,
++ authIat: init.authIat ?? null,
charSlot: init.charSlot ?? 0,
rank: init.rank ?? null,
name: init.name,
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 4080779..70638e0 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -34,6 +34,7 @@ import {
} from './platform/gameEvents.js'
import { MediaMirror } from './platform/mediaMirror.js'
import { createPlatformClient } from './platform/serviceClient.js'
++import { createRevocationEvents, ensureRevocationSubscription } from './platform/revocationEvents.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
/**
@@@ -154,6 +155,30 @@ async function main(): Promise<void> {
mods: modRuntime,
})
++ // Sign-out everywhere (network.user.token_valid_after): POST /internal/events closes the person's
++ // older game sessions; the subscription is created at boot when a secret is set (WS-B task 4).
++ const revocations = createRevocationEvents({
++ db: store.db,
++ secrets: config.platform.eventsSecrets,
++ onRevoked: (subject, validAfterMs) => game.revokeSubject(subject, validAfterMs),
++ log: platformLog,
++ })
++ if (platform && config.platform.eventsUrl && config.platform.eventsSecrets[0] && config.platform.eventsEndpoint) {
++ const subscribe = (attempt: number): void => {
++ ensureRevocationSubscription({
++ eventsUrl: config.platform.eventsUrl as string,
++ endpoint: config.platform.eventsEndpoint as string,
++ secret: config.platform.eventsSecrets[0] as string,
++ tokens: platform.tokens,
++ log: platformLog,
++ }).catch((err: unknown) => {
++ platformLog.warn('events subscription not ready (will retry)', { error: String((err as Error)?.message ?? err), attempt })
++ if (attempt < 5) setTimeout(() => subscribe(attempt + 1), 60_000 * attempt).unref()
++ })
++ }
++ subscribe(1)
++ }
++
// GET /release.json (D43): the deployed commit and package versions, as every OpenVibe service serves it.
const serveRelease = releaseHandler(buildRelease(process.cwd()))
@@@ -211,6 +236,7 @@ async function main(): Promise<void> {
config.oauth,
{
handle: (req, res) => {
++ if (revocations.handle(req, res)) return true
if (readiness.handle(req, res)) return true
if (serveRelease(req, res)) return true
if ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {
ddiff --git a/apps/server/src/platform/revocationEvents.test.ts b/apps/server/src/platform/revocationEvents.test.ts
nnew file mode 100644
iindex 0000000..d9f9551
---- /dev/null
++++ b/apps/server/src/platform/revocationEvents.test.ts
@@@ -0,0 +1,91 @@
++import { createServer, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { createRequire } from 'node:module'
++import { createConsoleLogger } from '@openvibe/shared'
++import { afterAll, beforeAll, describe, expect, it } from 'vitest'
++import { createRevocationEvents, ensureRevocationSubscription, TOPIC } from './revocationEvents.js'
++
++/**
++ * POST /internal/events for network.user.token_valid_after: signature v2 only, loopback only, the
++ * cutoff moves once (a redelivery changes nothing, anything not from Network is ignored) and the
++ * person's sessions are closed through onRevoked; the boot subscription is created once.
++ */
++const req = createRequire(import.meta.url)
++const Database = req('better-sqlite3') as new (path: string) => unknown
++const { signDeliveryHeaders } = req('openvibe-sdk/events') as {
++ signDeliveryHeaders(body: string, secret: string): Record<string, string>
++}
++const SECRET = 'x'.repeat(40)
++const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3'
++const log = createConsoleLogger({ app: 'test' }, 'error')
++const closedFor: [string, number][] = []
++const events = createRevocationEvents({
++ db: new Database(':memory:'),
++ secrets: [SECRET],
++ onRevoked: (s, ms) => {
++ closedFor.push([s, ms])
++ return 2
++ },
++ log,
++})
++let server: Server
++let url: string
++
++beforeAll(async () => {
++ server = createServer((req, res) => {
++ if (!events.handle(req, res)) {
++ res.writeHead(404)
++ res.end()
++ }
++ })
++ await new Promise<void>((r) => server.listen(0, '127.0.0.1', r))
++ url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/internal/events`
++})
++afterAll(() => server.close())
++
++const at = Date.parse('2026-09-24T20:00:00Z')
++const envelope = (over: Record<string, unknown> = {}) => ({
++ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3',
++ event_type: TOPIC,
++ version: 1,
++ source: 'network',
++ payload: { subject: { type: 'user', id: SUBJECT }, valid_after: new Date(at).toISOString(), reason: 'signed_out_everywhere' },
++ ...over,
++})
++const post = async (event: unknown, headers: Record<string, string> = {}, secret = SECRET) => {
++ const body = JSON.stringify({ event, seq: 1 })
++ const r = await fetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } })
++ return { status: r.status, body: (await r.json()) as { outcome?: string; closed?: number } }
++}
++
++describe('revocation events', () => {
++ it('applies a signed delivery once and closes the sessions', async () => {
++ expect(await post(envelope())).toMatchObject({ status: 200, body: { outcome: 'revoked', closed: 2 } })
++ expect(closedFor).toEqual([[SUBJECT, at]])
++ expect(events.store.isRevoked({ subject_id: SUBJECT, iat: at / 1000 - 1 })).toBe(true)
++ expect((await post(envelope())).body.outcome).toBe('unchanged')
++ expect((await post(envelope({ source: 'live' }))).body.outcome).toBe('ignored:source')
++ expect(closedFor.length).toBe(1)
++ })
++
++ it('refuses forged, proxied and non-POST requests', async () => {
++ expect((await post(envelope(), {}, 'y'.repeat(40))).status).toBe(401)
++ expect((await post(envelope(), { 'x-forwarded-for': '1.2.3.4' })).status).toBe(403)
++ expect((await fetch(url)).status).toBe(405)
++ })
++
++ it('creates the subscription once', async () => {
++ const subs: { topic_pattern: string; endpoint: string }[] = []
++ const fake = (async (u: string, init?: RequestInit) => {
++ if (init?.method === 'POST') {
++ subs.push(JSON.parse(String(init.body)) as { topic_pattern: string; endpoint: string })
++ return Response.json({ id: 'sub_1' }, { status: 201 })
++ }
++ return Response.json({ subscriptions: subs.map((s, i) => ({ id: `sub_${i + 1}`, ...s })) })
++ }) as unknown as typeof fetch
++ const opts = { eventsUrl: 'http://events.test', endpoint: 'http://127.0.0.1:8000/internal/events', secret: SECRET, tokens: { getToken: async () => 'tok' }, fetchImpl: fake, log }
++ expect(await ensureRevocationSubscription(opts)).toBe('created')
++ expect(await ensureRevocationSubscription(opts)).toBe('exists')
++ expect(subs).toEqual([{ topic_pattern: TOPIC, endpoint: opts.endpoint, secret: SECRET }])
++ })
++})
ddiff --git a/apps/server/src/platform/revocationEvents.ts b/apps/server/src/platform/revocationEvents.ts
nnew file mode 100644
iindex 0000000..20a36df
---- /dev/null
++++ b/apps/server/src/platform/revocationEvents.ts
@@@ -0,0 +1,140 @@
++/**
++ * Sign-out everywhere reaches Games (roadmap WS-B task 4; Contracts 0.39.0
++ * network.user.token_valid_after).
++ *
++ * POST /internal/events is the endpoint of Games' Events subscription to that topic, signed with
++ * GAMES_EVENTS_SECRET (signature v2 only) and loopback only (a request carrying a forwarding header
++ * came through nginx and is refused). A delivery moves the person's cutoff in the SDK's revocation
++ * store (only ever forward; anything not from Network is ignored), then `onRevoked` closes the game
++ * sessions they opened with an older sign-in. New sign-ins already fail: Games checks every token with
++ * the Network's /api/auth/me, which applies the same cutoff.
++ */
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { createRequire } from 'node:module'
++import type { Logger } from '@openvibe/shared'
++
++const req = createRequire(import.meta.url)
++
++interface RevocationStore {
++ apply(event: unknown): string
++ isRevoked(claims: { iat?: number; subject_id?: string } | null | undefined): boolean
++ cutoffFor(subject: string): number
++}
++type ParseDelivery = (
++ raw: Buffer,
++ headers: IncomingMessage['headers'],
++ secret: string,
++ opts: { requireV2: boolean },
++) => { event?: { event_id?: string; event_type?: string; payload?: { subject?: { id?: string }; valid_after?: string } } } | null
++
++const { createRevocationStore } = req('openvibe-sdk/auth') as {
++ createRevocationStore(db: unknown, opts?: { table?: string }): RevocationStore
++}
++const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDelivery }
++
++export const TOPIC = 'network.user.token_valid_after'
++const PATH = '/internal/events'
++const MAX_BODY = 256 * 1024
++
++export interface RevocationEvents {
++ /** Answers POST /internal/events (returns true when the request was ours). */
++ handle(req: IncomingMessage, res: ServerResponse): boolean
++ store: RevocationStore
++ stats: { received: number; revoked: number; closed: number; refused: number; ignored: number }
++}
++
++export function createRevocationEvents(opts: {
++ db: unknown
++ secrets: string[]
++ onRevoked: (subjectId: string, validAfterMs: number) => number
++ log: Logger
++}): RevocationEvents {
++ const store = createRevocationStore(opts.db, { table: 'token_revocations' })
++ const stats = { received: 0, revoked: 0, closed: 0, refused: 0, ignored: 0 }
++ const send = (res: ServerResponse, status: number, body: unknown): void => {
++ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
++ res.end(JSON.stringify(body))
++ }
++
++ function handle(req: IncomingMessage, res: ServerResponse): boolean {
++ if ((req.url ?? '').split('?')[0] !== PATH) return false
++ if (req.method !== 'POST') {
++ send(res, 405, { error: 'POST only' })
++ return true
++ }
++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {
++ send(res, 403, { error: 'internal route' })
++ return true
++ }
++ if (opts.secrets.length === 0) {
++ send(res, 503, { error: 'GAMES_EVENTS_SECRET is not set' })
++ return true
++ }
++ const chunks: Buffer[] = []
++ let size = 0
++ req.on('data', (c: Buffer) => {
++ size += c.length
++ if (size > MAX_BODY) req.destroy()
++ else chunks.push(c)
++ })
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks)
++ let delivery: ReturnType<ParseDelivery> = null
++ for (const s of opts.secrets) {
++ delivery = parseDelivery(raw, req.headers, s, { requireV2: true })
++ if (delivery) break
++ }
++ if (!delivery || !delivery.event) {
++ stats.refused++
++ send(res, 401, { error: 'bad signature' })
++ return
++ }
++ stats.received++
++ const event = delivery.event
++ const outcome = store.apply(event)
++ let closed = 0
++ if (outcome === 'revoked') {
++ stats.revoked++
++ const subject = event.payload?.subject?.id ?? ''
++ closed = opts.onRevoked(subject, Date.parse(event.payload?.valid_after ?? ''))
++ stats.closed += closed
++ if (closed) opts.log.info('signed out everywhere: sessions closed', { closed })
++ } else if (outcome.startsWith('ignored')) stats.ignored++
++ send(res, 200, { event_id: event.event_id ?? null, outcome, closed })
++ })
++ return true
++ }
++
++ return { handle, store, stats }
++}
++
++/**
++ * Create Games' subscription to TOPIC at Events if it is missing (idempotent: an existing one, even
++ * disabled by an operator, is left as it is). Needs the grant games events.subscription.manage.
++ */
++export async function ensureRevocationSubscription(opts: {
++ eventsUrl: string
++ endpoint: string
++ secret: string
++ tokens: { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> }
++ fetchImpl?: typeof fetch
++ log: Logger
++}): Promise<'exists' | 'created'> {
++ const f = opts.fetchImpl ?? fetch
++ const token = await opts.tokens.getToken({ audience: 'openvibe.events', scope: 'events.subscription.manage' })
++ const headers = { authorization: `Bearer ${token}`, accept: 'application/json' }
++ const list = await f(`${opts.eventsUrl}/api/v1/subscriptions`, { headers })
++ if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`)
++ const body = (await list.json()) as { subscriptions?: { id: string; topic_pattern: string; endpoint: string }[] }
++ const found = (body.subscriptions ?? []).find((s) => s.topic_pattern === TOPIC && s.endpoint === opts.endpoint)
++ if (found) return 'exists'
++ const r = await f(`${opts.eventsUrl}/api/v1/subscriptions`, {
++ method: 'POST',
++ headers: { ...headers, 'content-type': 'application/json' },
++ body: JSON.stringify({ topic_pattern: TOPIC, endpoint: opts.endpoint, secret: opts.secret }),
++ })
++ if (r.status === 409) return 'exists'
++ if (!r.ok) throw new Error(`Events answered ${r.status} creating the ${TOPIC} subscription`)
++ opts.log.info('events subscription created', { topic: TOPIC, endpoint: opts.endpoint })
++ return 'created'
++}
ddiff --git a/apps/server/src/platform/serviceClient.ts b/apps/server/src/platform/serviceClient.ts
iindex f2ece3f..c77a80f 100644
---- a/apps/server/src/platform/serviceClient.ts
++++ b/apps/server/src/platform/serviceClient.ts
@@@ -16,6 +16,8 @@ import type { PlatformConfig } from '../config.js'
export interface PlatformClient {
client: OpenVibeClient
clientId: string
++ /** The `games` principal's tokens, for calls the SDK client has no method for (Events subscriptions). */
++ tokens: { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> }
}
/** Null when no client secret is configured: Games then makes no service calls. */
@@@ -42,5 +44,5 @@ export function createPlatformClient(
retries: 0,
...(fetchImpl ? { fetch: fetchImpl } : {}),
})
-- return { client, clientId: cfg.clientId }
++ return { client, clientId: cfg.clientId, tokens }
}
ddiff --git a/packages/protocol/src/messages/server.ts b/packages/protocol/src/messages/server.ts
iindex 6697697..5cf030b 100644
---- a/packages/protocol/src/messages/server.ts
++++ b/packages/protocol/src/messages/server.ts
@@@ -29,7 +29,13 @@ export interface ServerWelcome {
export interface ServerReject {
t: 'reject'
reason:
-- 'protocol_mismatch' | 'server_full' | 'invalid_hello' | 'guest_one_character' | 'auth_failed'
++ | 'protocol_mismatch'
++ | 'server_full'
++ | 'invalid_hello'
++ | 'guest_one_character'
++ | 'auth_failed'
++ /** The person signed out everywhere (or changed their password, or was banned) on the Network. */
++ | 'signed_out'
}
/** Entities that became relevant to this client (full state). */
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 8c64512..fd143a1 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -112,11 +112,11 @@ importers:
specifier: ^11.9.1
version: 11.10.0
openvibe-contracts:
-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0
-- version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0
++ version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0
openvibe-sdk:
-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0
-- version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1
++ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1
tsx:
specifier: ^4.19.3
version: 4.23.11
@@@ -1326,15 +1326,15 @@ packages:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
-- openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0:
-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0}
-- version: 0.38.0
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0:
++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0}
++ version: 0.39.0
engines: {node: '>=20'}
hasBin: true
-- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0:
-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0}
-- version: 0.6.0
++ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1:
++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1}
++ version: 0.9.1
engines: {node: '>=20'}
@@@ -2742,12 +2742,12 @@ snapshots:
dependencies:
wrappy: 1.0.2
-- openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0:
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.39.0:
dependencies:
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
-- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0: {}
++ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.9.1: {}
dependencies: