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
Changes
@@@ -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@@@ -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 filennew file mode 100644@@@ -0,0 +1,2 @@++export {};++//# sourceMappingURL=networkAuth.test.d.ts.map\\ No newline at end of filennew file mode 100644@@@ -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@@@ -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"@@@ -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.onsession.move.vel.x = 0session.move.vel.y = 0nnew file mode 100644@@@ -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([])++ })++})@@@ -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' | nullexport 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 nullconst id = u.id ?? u.usernameconst 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'}@@@ -112,8 +112,8 @@ importers:specifier: ^11.9.1version: 11.10.0openvibe-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.0openvibe-sdk:specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0version: 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.0engines: {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.0ajv-formats: 3.0.1([email protected])
@@ -1,10 +1,3 @@
@@ -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 type OpenVibeRank = 'owner' | 'admin' | 'moderator' | null;
export interface NetworkUser {
export interface NetworkUser {
/** The Network's own account id (legacy; integer today). */
/** The Network's own account id (legacy; integer today). */
@@ -18,6 +11,8 @@ export interface NetworkUser {
@@ -18,6 +11,8 @@ export interface NetworkUser {
subjectId: string | null;
subjectId: string | null;
}
}
export declare function isCanonicalSubject(value: unknown): value is string;
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. */
/** The account record as openvibe.network's /api/auth/me returns it. */
export interface NetworkAccount {
export interface NetworkAccount {
id?: string | number;
id?: string | number;
@@ -35,5 +30,6 @@ export interface NetworkAccount {
@@ -35,5 +30,6 @@ export interface NetworkAccount {
*/
*/
export declare function fetchNetworkAccount(url: string | null, auth: string | undefined): Promise<NetworkAccount | null>;
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>;
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;
export declare function canEditMap(rank: OpenVibeRank): boolean;
//# sourceMappingURL=networkAuth.d.ts.map
//# sourceMappingURL=networkAuth.d.ts.map
\ No newline at end of file
\ No newline at end of file
@@ -1 +1 @@
@@ -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
\ 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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+export {};
+//# sourceMappingURL=networkAuth.test.d.ts.map
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -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
\ No newline at end of file
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"@openvibe/shared": "workspace:*",
"@openvibe/shared": "workspace:*",
"ajv": "^8.17.1",
"ajv": "^8.17.1",
"better-sqlite3": "^11.9.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",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"tsx": "^4.19.3",
"tsx": "^4.19.3",
"ws": "^8.18.1"
"ws": "^8.18.1"
@@ -67,7 +67,7 @@ import {
@@ -67,7 +67,7 @@ import {
type PlayerId,
type PlayerId,
} from '@openvibe/shared'
} from '@openvibe/shared'
import type { ServerConfig } from '../config.js'
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 { accountForNetworkUser, isGuestToken } from '../platform/accounts.js'
import type { EventPlayer, GameEventRecorder, ProgressSnapshot } from '../platform/gameEvents.js'
import type { EventPlayer, GameEventRecorder, ProgressSnapshot } from '../platform/gameEvents.js'
import type { ModHost, ModRuntime } from '../mods/runtime.js'
import type { ModHost, ModRuntime } from '../mods/runtime.js'
@@ -558,7 +558,7 @@ export class GameServer {
@@ -558,7 +558,7 @@ export class GameServer {
// Rank-gated noclip build mode. The flag lives in the move state so
// Rank-gated noclip build mode. The flag lives in the move state so
// server sim and client prediction stay in lockstep via the normal
// server sim and client prediction stay in lockstep via the normal
// self-state replication path.
// self-state replication path.
- if (session.rank === 'owner' || session.rank === 'admin') {
+ if (canEditMap(session.rank)) {
session.move.noclip = msg.on
session.move.noclip = msg.on
session.move.vel.x = 0
session.move.vel.x = 0
session.move.vel.y = 0
session.move.vel.y = 0
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -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([])
+ })
+})
@@ -2,9 +2,19 @@
@@ -2,9 +2,19 @@
* openvibe.network SSO adapter. Access tokens are JWTs issued by the openvibe.network
* 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
* OAuth server; we validate by calling its /api/auth/me endpoint (which
* verifies signature + ban state) rather than trusting the JWT locally.
* 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 type OpenVibeRank = 'owner' | 'admin' | 'moderator' | null
export interface NetworkUser {
export interface NetworkUser {
@@ -26,6 +36,29 @@ export function isCanonicalSubject(value: unknown): value is string {
@@ -26,6 +36,29 @@ export function isCanonicalSubject(value: unknown): value is string {
return typeof value === 'string' && SUBJECT_ID.test(value)
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
* 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.
* (via /api/auth/me). Never used to trust a token on its own.
@@ -43,8 +76,6 @@ function subjectClaim(token: string): string | null {
@@ -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. */
/** The account record as openvibe.network's /api/auth/me returns it. */
export interface NetworkAccount {
export interface NetworkAccount {
id?: string | number
id?: string | number
@@ -87,10 +118,11 @@ export async function resolveNetworkUser(
@@ -87,10 +118,11 @@ export async function resolveNetworkUser(
if (!u) return null
if (!u) return null
const id = u.id ?? u.username
const id = u.id ?? u.username
const username = String(u.username ?? id)
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)
const subjectId = isCanonicalSubject(u.subject_id)
? u.subject_id
? u.subject_id
: auth
: auth
@@ -99,6 +131,7 @@ export async function resolveNetworkUser(
@@ -99,6 +131,7 @@ export async function resolveNetworkUser(
return { id: String(id), name: username, rank, subjectId }
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 {
export function canEditMap(rank: OpenVibeRank): boolean {
return rank === 'owner' || rank === 'admin'
return rank === 'owner' || rank === 'admin'
}
}
@@ -112,8 +112,8 @@ importers:
@@ -112,8 +112,8 @@ importers:
specifier: ^11.9.1
specifier: ^11.9.1
version: 11.10.0
version: 11.10.0
openvibe-contracts:
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:
openvibe-sdk:
specifier: 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.6.0
version: 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:
@@ -1326,9 +1326,9 @@ packages:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
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'}
engines: {node: '>=20'}
hasBin: true
hasBin: true
@@ -2742,7 +2742,7 @@ snapshots:
@@ -2742,7 +2742,7 @@ snapshots:
dependencies:
dependencies:
wrappy: 1.0.2
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:
dependencies:
ajv: 8.20.0
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
ajv-formats: 3.0.1([email protected])