openvibers/openvibe.games Commits

Rank from the contracts staff map (WS-D, ADR-022; Contracts 0.38.0): owner from the is_owner claim, admin from staff.games.manage (map editor, noclip, mod administration), moderator from staff.moderation.chat, read from the token the Network just accepted; the account's fresh role wins after a role change. OWNER_USERNAME and role-name comparisons are gone; noclip uses canEditMap. networkAuth.test.ts fails on raw role checks

OP OpenVibers committed 24 Sept 2026 · 9cfbd11afd · 9 files, +127 −25


Changes

ddiff --git a/apps/server/dist-types/net/networkAuth.d.ts b/apps/server/dist-types/net/networkAuth.d.ts
iindex 7e21922..644ca5d 100644
---- a/apps/server/dist-types/net/networkAuth.d.ts
++++ b/apps/server/dist-types/net/networkAuth.d.ts
@@@ -1,10 +1,3 @@
--/**
-- * openvibe.network SSO adapter. Access tokens are JWTs issued by the openvibe.network
-- * OAuth server; we validate by calling its /api/auth/me endpoint (which
-- * verifies signature + ban state) rather than trusting the JWT locally.
-- * The response nests the account under `user`; rank maps the network RBAC:
-- * the configured owner account, then role admin / moderator (global_mod).
-- */
export type OpenVibeRank = 'owner' | 'admin' | 'moderator' | null;
export interface NetworkUser {
/** The Network's own account id (legacy; integer today). */
@@@ -18,6 +11,8 @@ export interface NetworkUser {
subjectId: string | null;
}
export declare function isCanonicalSubject(value: unknown): value is string;
++/** The rank the staff map gives these (accepted) claims. */
++export declare function rankOf(claims: Record<string, unknown>): OpenVibeRank;
/** The account record as openvibe.network's /api/auth/me returns it. */
export interface NetworkAccount {
id?: string | number;
@@@ -35,5 +30,6 @@ export interface NetworkAccount {
*/
export declare function fetchNetworkAccount(url: string | null, auth: string | undefined): Promise<NetworkAccount | null>;
export declare function resolveNetworkUser(url: string | null, auth: string | undefined): Promise<NetworkUser | null>;
++/** The map editor and mod administration: staff.games.manage (rank owner or admin). */
export declare function canEditMap(rank: OpenVibeRank): boolean;
//# sourceMappingURL=networkAuth.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/networkAuth.d.ts.map b/apps/server/dist-types/net/networkAuth.d.ts.map
iindex 3f561ad..1c3a299 100644
---- a/apps/server/dist-types/net/networkAuth.d.ts.map
++++ b/apps/server/dist-types/net/networkAuth.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"networkAuth.d.ts","sourceRoot":"","sources":["../../src/net/networkAuth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,IAAI,CAAA;AAEjE,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,YAAY,CAAA;IAClB;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CACzB;AAKD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAElE;AAqBD,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAahC;AAED,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAe7B;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAEtD"}
\\ No newline at end of file
++{"version":3,"file":"networkAuth.d.ts","sourceRoot":"","sources":["../../src/net/networkAuth.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,IAAI,CAAA;AAEjE,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,YAAY,CAAA;IAClB;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CACzB;AAKD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAElE;AAiBD,4DAA4D;AAC5D,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,YAAY,CAKpE;AAmBD,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAahC;AAED,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAgB7B;AAED,uFAAuF;AACvF,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAEtD"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/networkAuth.test.d.ts b/apps/server/dist-types/net/networkAuth.test.d.ts
nnew file mode 100644
iindex 0000000..f1cc915
---- /dev/null
++++ b/apps/server/dist-types/net/networkAuth.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=networkAuth.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/networkAuth.test.d.ts.map b/apps/server/dist-types/net/networkAuth.test.d.ts.map
nnew file mode 100644
iindex 0000000..e4a9627
---- /dev/null
++++ b/apps/server/dist-types/net/networkAuth.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"networkAuth.test.d.ts","sourceRoot":"","sources":["../../src/net/networkAuth.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex 525e5cf..f5c2437 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -19,7 +19,7 @@
"@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.34.3",
++ "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",
"tsx": "^4.19.3",
"ws": "^8.18.1"
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex bb38cce..1ebc9c7 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -67,7 +67,7 @@ import {
type PlayerId,
} from '@openvibe/shared'
import type { ServerConfig } from '../config.js'
--import { resolveNetworkUser } from '../net/networkAuth.js'
++import { canEditMap, resolveNetworkUser } from '../net/networkAuth.js'
import { accountForNetworkUser, isGuestToken } from '../platform/accounts.js'
import type { EventPlayer, GameEventRecorder, ProgressSnapshot } from '../platform/gameEvents.js'
import type { ModHost, ModRuntime } from '../mods/runtime.js'
@@@ -558,7 +558,7 @@ export class GameServer {
// Rank-gated noclip build mode. The flag lives in the move state so
// server sim and client prediction stay in lockstep via the normal
// self-state replication path.
-- if (session.rank === 'owner' || session.rank === 'admin') {
++ if (canEditMap(session.rank)) {
session.move.noclip = msg.on
session.move.vel.x = 0
session.move.vel.y = 0
ddiff --git a/apps/server/src/net/networkAuth.test.ts b/apps/server/src/net/networkAuth.test.ts
nnew file mode 100644
iindex 0000000..da0e830
---- /dev/null
++++ b/apps/server/src/net/networkAuth.test.ts
@@@ -0,0 +1,70 @@
++import { readdirSync, readFileSync, statSync } from 'node:fs'
++import { createServer, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { join, relative } from 'node:path'
++import { afterAll, beforeAll, describe, expect, it } from 'vitest'
++import { canEditMap, rankOf, resolveNetworkUser } from './networkAuth.js'
++
++/**
++ * Rank from the contracts staff map (ADR-022): the owner claim, staff.games.manage and
++ * staff.moderation.chat decide it, never a role name or a username; the account's fresh
++ * role wins over a token minted before a role change.
++ */
++
++const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString('base64url')
++const token = (claims: Record<string, unknown>): string => `${b64({ alg: 'RS256' })}.${b64(claims)}.sig`
++
++let server: Server
++let url: string
++let account: Record<string, unknown> = {}
++
++beforeAll(async () => {
++ server = createServer((_req, res) => {
++ res.setHeader('content-type', 'application/json')
++ res.end(JSON.stringify({ user: account }))
++ })
++ await new Promise<void>((r) => server.listen(0, '127.0.0.1', r))
++ url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/auth/me`
++})
++afterAll(() => server.close())
++
++describe('rank from the staff map', () => {
++ it('maps claims to ranks', () => {
++ expect(rankOf({ role: 'user' })).toBe(null)
++ expect(rankOf({ role: 'global_mod' })).toBe('moderator')
++ expect(rankOf({ role: 'admin' })).toBe('admin')
++ expect(rankOf({ role: 'admin', is_owner: true })).toBe('owner')
++ expect(rankOf({ role: 'user', is_owner: true })).toBe(null)
++ expect(rankOf({ role: 'global_mod', staff_caps: ['staff.games.manage'], staff_map: '1.1.0' })).toBe('admin')
++ expect(rankOf({ role: 'admin', staff_caps: ['staff.moderation.chat'], staff_map: '1.1.0' })).toBe('moderator')
++ expect(canEditMap('owner') && canEditMap('admin') && !canEditMap('moderator') && !canEditMap(null)).toBe(true)
++ })
++
++ it('reads the accepted token and prefers the account role after a change', async () => {
++ account = { id: 7, username: 'goosely', role: 'admin', subject_id: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' }
++ expect((await resolveNetworkUser(url, token({ role: 'admin', is_owner: true })))?.rank).toBe('owner')
++ // a username is not the owner claim
++ expect((await resolveNetworkUser(url, token({ role: 'admin' })))?.rank).toBe('admin')
++ account = { id: 8, username: 'demoted', role: 'user' }
++ expect((await resolveNetworkUser(url, token({ role: 'admin', staff_caps: ['staff.games.manage'], staff_map: '1.1.0' })))?.rank).toBe(null)
++ account = { id: 9, username: 'promoted', role: 'admin' }
++ expect((await resolveNetworkUser(url, token({ role: 'user' })))?.rank).toBe('admin')
++ })
++
++ it('no server file compares a role name by hand', () => {
++ const root = join(__dirname, '..')
++ const offenders: string[] = []
++ const walk = (dir: string): void => {
++ for (const name of readdirSync(dir)) {
++ const f = join(dir, name)
++ if (statSync(f).isDirectory()) { walk(f); continue }
++ if (!/\.ts$/.test(name) || name.endsWith('.test.ts')) continue
++ readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
++ if (/OWNER_USERNAME|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) offenders.push(`${relative(root, f)}:${i + 1}`)
++ })
++ }
++ }
++ walk(root)
++ expect(offenders).toEqual([])
++ })
++})
ddiff --git a/apps/server/src/net/networkAuth.ts b/apps/server/src/net/networkAuth.ts
iindex 77be5d0..3b4b99f 100644
---- a/apps/server/src/net/networkAuth.ts
++++ b/apps/server/src/net/networkAuth.ts
@@@ -2,9 +2,19 @@
* openvibe.network SSO adapter. Access tokens are JWTs issued by the openvibe.network
* OAuth server; we validate by calling its /api/auth/me endpoint (which
* verifies signature + ban state) rather than trusting the JWT locally.
-- * The response nests the account under `user`; rank maps the network RBAC:
-- * the configured owner account, then role admin / moderator (global_mod).
++ * The response nests the account under `user`. Rank comes from the contracts staff map
++ * (ADR-022) applied to the claims of the token the Network just accepted: 'owner' for the
++ * owner, 'admin' for staff.games.manage (map editor, mod administration), 'moderator' for
++ * staff.moderation.chat; never from a role name or a username.
*/
++import { createRequire } from 'node:module'
++
++interface StaffMap {
++ can(claims: Record<string, unknown>, capability: string): boolean
++ effectiveRole(claims: Record<string, unknown>): string
++}
++const staffMap = (createRequire(import.meta.url)('openvibe-contracts') as { staff: StaffMap }).staff
++
export type OpenVibeRank = 'owner' | 'admin' | 'moderator' | null
export interface NetworkUser {
@@@ -26,6 +36,29 @@ export function isCanonicalSubject(value: unknown): value is string {
return typeof value === 'string' && SUBJECT_ID.test(value)
}
++/**
++ * The claims of a JWT the Network has ALREADY accepted (via /api/auth/me).
++ * Never used to trust a token on its own.
++ */
++function acceptedClaims(token: string): Record<string, unknown> {
++ const parts = token.split('.')
++ if (parts.length !== 3) return {}
++ try {
++ const claims = JSON.parse(Buffer.from(parts[1] ?? '', 'base64url').toString('utf8')) as unknown
++ return claims && typeof claims === 'object' ? (claims as Record<string, unknown>) : {}
++ } catch {
++ return {}
++ }
++}
++
++/** The rank the staff map gives these (accepted) claims. */
++export function rankOf(claims: Record<string, unknown>): OpenVibeRank {
++ if (staffMap.effectiveRole(claims) === 'owner') return 'owner'
++ if (staffMap.can(claims, 'staff.games.manage')) return 'admin'
++ if (staffMap.can(claims, 'staff.moderation.chat')) return 'moderator'
++ return null
++}
++
/**
* Reads the `subject_id` claim of a JWT the Network has ALREADY accepted
* (via /api/auth/me). Never used to trust a token on its own.
@@@ -43,8 +76,6 @@ function subjectClaim(token: string): string | null {
}
}
--const OWNER_USERNAME = (process.env.OWNER_USERNAME ?? 'goosely').toLowerCase()
--
/** The account record as openvibe.network's /api/auth/me returns it. */
export interface NetworkAccount {
id?: string | number
@@@ -87,10 +118,11 @@ export async function resolveNetworkUser(
if (!u) return null
const id = u.id ?? u.username
const username = String(u.username ?? id)
-- let rank: OpenVibeRank = null
-- if (username.toLowerCase() === OWNER_USERNAME) rank = 'owner'
-- else if (u.role === 'admin') rank = 'admin'
-- else if (u.role === 'moderator' || u.role === 'global_mod') rank = 'moderator'
++ // Staff claims come from the token; the account's role is fresher, and when the two differ
++ // (a role change since the token was minted) the account's role alone decides.
++ const claims = auth ? acceptedClaims(auth) : {}
++ const role = typeof u.role === 'string' ? u.role : claims.role
++ const rank = rankOf(role === claims.role ? claims : { role, is_owner: claims.is_owner })
const subjectId = isCanonicalSubject(u.subject_id)
? u.subject_id
: auth
@@@ -99,6 +131,7 @@ export async function resolveNetworkUser(
return { id: String(id), name: username, rank, subjectId }
}
++/** The map editor and mod administration: staff.games.manage (rank owner or admin). */
export function canEditMap(rank: OpenVibeRank): boolean {
return rank === 'owner' || rank === 'admin'
}
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 0582bf5..8c64512 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -112,8 +112,8 @@ importers:
specifier: ^11.9.1
version: 11.10.0
openvibe-contracts:
-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.34.3
-- version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.34.3
++ 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
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
@@@ -1326,9 +1326,9 @@ packages:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
-- openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.34.3:
-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.34.3}
-- version: 0.34.3
++ 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
engines: {node: '>=20'}
hasBin: true
@@@ -2742,7 +2742,7 @@ snapshots:
dependencies:
wrappy: 1.0.2
-- openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.34.3:
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0:
dependencies:
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])