openvibers/source.openvibe.games Commits

feat: implement Steam auth GUI and full auth bridge

CO copilot-swe-agent[bot] committed 30 Jun 2026 · ceae47ccae · 10 files, +1634 −3

- backend: add getSession to OpenVibeSessionStore + RedisSessionStore
- backend: add GET /v1/auth/session endpoint (****** verification)
- backend: install @fastify/[email protected], serve client/ at /client/
- backend: add session verification test (14/14 pass)
- client: create client/index.html — full in-game Steam auth GUI
  - dark Steam-inspired theme, auth screen + main UI
  - hash routing: portal, servers, leaderboard, inventory, shop, settings
  - window.OV.onSteamAuthResult() callback for C++ injection
  - openvibe://auth/steam bridge call, openvibe://ready notification
  - localStorage session persistence with backend verification
- C++ vgui_openvibe_menu.cpp: add openvibe://auth/steam bridge handler
- C++ vgui_openvibe_menu.cpp: add openvibe://ready handler (push stored token)
- C++ vgui_openvibe_menu.cpp: add OpenVibe_NotifyHTMLSteamAuth() + OV_SanitiseJSString()
- C++ vgui_openvibe_menu.cpp: update ov_menu_url default to port 3000
- C++ openvibe_client.cpp: add ov_session_token ConVar (HIDDEN|PROTECTED|ARCHIVE)
- C++ openvibe_client.cpp: add OpenVibe_GetSessionToken() accessor
- C++ openvibe_client.cpp: parse sessionToken/steamId/displayName from auth response
- C++ openvibe_client.cpp: call OpenVibe_NotifyHTMLSteamAuth after auth
- PROJECT_INDEX.md: document new session verification + in-game auth GUI

Changes

ddiff --git a/PROJECT_INDEX.md b/PROJECT_INDEX.md
iindex 4954b26..93b20fc 100644
---- a/PROJECT_INDEX.md
++++ b/PROJECT_INDEX.md
@@@ -33,6 +33,8 @@ openvibe-source/
| Backend API | Implemented | `backend/src/app.ts`, `backend/src/repository-pg.ts`, `backend/src/sessions.ts` |
| Backend tests | Passing | `backend/src/app.test.ts` |
| Steam auth route | Implemented | `POST /v1/auth/steam`; requires production Steam env vars |
++| Session verification | Implemented | `GET /v1/auth/session` — Bearer-token validation with Redis |
++| In-game Steam auth GUI | Implemented | `client/index.html` — embedded web UI served at `/client/`; `openvibe://auth/steam` + `openvibe://ready` bridge handlers |
| Redis sessions | Implemented | `backend/src/sessions.ts` |
| CDN asset manifest | Implemented | `GET /v1/assets/manifest` |
| C++ client travel | Implemented | `sdk/openvibe/client/hl2mp/openvibe_client.cpp` |
ddiff --git a/backend/package-lock.json b/backend/package-lock.json
iindex 7908ba9..73ef30b 100644
---- a/backend/package-lock.json
++++ b/backend/package-lock.json
@@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@fastify/cors": "^11.1.0",
++ "@fastify/static": "^9.1.3",
"dotenv": "^17.2.3",
"fastify": "^5.6.2",
"ioredis": "^5.11.1",
@@@ -503,6 +504,22 @@
"node": ">=18"
}
},
++ "node_modules/@fastify/accept-negotiator": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
++ "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "MIT"
++ },
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@@ -634,6 +651,53 @@
"ipaddr.js": "^2.1.0"
}
},
++ "node_modules/@fastify/send": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
++ "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "@lukeed/ms": "^2.0.2",
++ "escape-html": "~1.0.3",
++ "fast-decode-uri-component": "^1.0.1",
++ "http-errors": "^2.0.0",
++ "mime": "^3"
++ }
++ },
++ "node_modules/@fastify/static": {
++ "version": "9.1.3",
++ "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
++ "integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "@fastify/accept-negotiator": "^2.0.0",
++ "@fastify/send": "^4.0.0",
++ "content-disposition": "^1.0.1",
++ "fastify-plugin": "^5.0.0",
++ "fastq": "^1.17.1",
++ "glob": "^13.0.0"
++ }
++ },
"node_modules/@ioredis/commands": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
@@@ -647,6 +711,15 @@
"dev": true,
"license": "MIT"
},
++ "node_modules/@lukeed/ms": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
++ "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@@ -1202,6 +1275,27 @@
"fastq": "^1.17.1"
}
},
++ "node_modules/balanced-match": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
++ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
++ "license": "MIT",
++ "engines": {
++ "node": "18 || 20 || >=22"
++ }
++ },
++ "node_modules/brace-expansion": {
++ "version": "5.0.7",
++ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
++ "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
++ "license": "MIT",
++ "dependencies": {
++ "balanced-match": "^4.0.2"
++ },
++ "engines": {
++ "node": "18 || 20 || >=22"
++ }
++ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@@ -1221,6 +1315,19 @@
"node": ">=0.10.0"
}
},
++ "node_modules/content-disposition": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
++ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@@ -1267,6 +1374,15 @@
"node": ">=0.10"
}
},
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@@ -1347,6 +1463,12 @@
"@esbuild/win32-x64": "0.28.1"
}
},
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@@ -1533,6 +1655,49 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
++ "node_modules/glob": {
++ "version": "13.0.6",
++ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
++ "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
++ "license": "BlueOak-1.0.0",
++ "dependencies": {
++ "minimatch": "^10.2.2",
++ "minipass": "^7.1.3",
++ "path-scurry": "^2.0.2"
++ },
++ "engines": {
++ "node": "18 || 20 || >=22"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/isaacs"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
"node_modules/ioredis": {
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
@@@ -1887,6 +2052,15 @@
"url": "https://opencollective.com/parcel"
}
},
++ "node_modules/lru-cache": {
++ "version": "11.5.1",
++ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
++ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
++ "license": "BlueOak-1.0.0",
++ "engines": {
++ "node": "20 || >=22"
++ }
++ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@@ -1897,6 +2071,42 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
++ "node_modules/mime": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
++ "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=10.0.0"
++ }
++ },
++ "node_modules/minimatch": {
++ "version": "10.2.5",
++ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
++ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
++ "license": "BlueOak-1.0.0",
++ "dependencies": {
++ "brace-expansion": "^5.0.5"
++ },
++ "engines": {
++ "node": "18 || 20 || >=22"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/isaacs"
++ }
++ },
++ "node_modules/minipass": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
++ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
++ "license": "BlueOak-1.0.0",
++ "engines": {
++ "node": ">=16 || 14 >=14.17"
++ }
++ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@@ -1944,6 +2154,22 @@
"node": ">=14.0.0"
}
},
++ "node_modules/path-scurry": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
++ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
++ "license": "BlueOak-1.0.0",
++ "dependencies": {
++ "lru-cache": "^11.0.0",
++ "minipass": "^7.1.2"
++ },
++ "engines": {
++ "node": "18 || 20 || >=22"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/isaacs"
++ }
++ },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@@ -2369,6 +2595,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@@ -2417,6 +2649,15 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
@@@ -2495,6 +2736,15 @@
"node": ">=20"
}
},
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
ddiff --git a/backend/package.json b/backend/package.json
iindex c4f8a29..03b4935 100644
---- a/backend/package.json
++++ b/backend/package.json
@@@ -14,6 +14,7 @@
},
"dependencies": {
"@fastify/cors": "^11.1.0",
++ "@fastify/static": "^9.1.3",
"dotenv": "^17.2.3",
"fastify": "^5.6.2",
"ioredis": "^5.11.1",
ddiff --git a/backend/src/app.test.ts b/backend/src/app.test.ts
iindex a920cbb..60fd14b 100644
---- a/backend/src/app.test.ts
++++ b/backend/src/app.test.ts
@@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { createApp } from "./app.js";
import { MemoryOpenVibeRepository } from "./repository-memory.js";
--import { SessionInput } from "./sessions.js";
++import { SessionData, SessionInput } from "./sessions.js";
const steamId = "76561198000000000";
const serverSecret = "dev-secret";
@@@ -595,4 +595,54 @@ describe("OpenVibe API vertical slice", () => {
await app.close();
});
++
++ it("GET /v1/auth/session verifies a valid session token and rejects an invalid one", async () => {
++ const sessions = new Map<string, SessionInput>();
++ const app = await createApp({
++ repository: new MemoryOpenVibeRepository(),
++ devAuthEnabled: true,
++ sessionStore: {
++ async createSession(input: SessionInput) {
++ sessions.set(input.token, input);
++ },
++ async getSession(token: string): Promise<SessionData | null> {
++ const s = sessions.get(token);
++ if (!s) return null;
++ return { steamId: s.steamId, provider: s.provider, createdAt: new Date().toISOString() };
++ },
++ },
++ });
++ await app.ready();
++
++ const auth = await app.inject({
++ method: "POST",
++ url: "/v1/auth/dev",
++ payload: { steamId, displayName: "Session Check" },
++ });
++ expect(auth.statusCode).toBe(200);
++ const { sessionToken } = auth.json();
++
++
++ const valid = await app.inject({
++ method: "GET",
++ url: "/v1/auth/session",
++ headers: { authorization: `Bearer ${sessionToken}` },
++ });
++ expect(valid.statusCode).toBe(200);
++ expect(valid.json().valid).toBe(true);
++ expect(valid.json().steamId).toBe(steamId);
++ expect(valid.json().provider).toBe("dev");
++
++ const missing = await app.inject({ method: "GET", url: "/v1/auth/session" });
++ expect(missing.statusCode).toBe(401);
++
++ const invalid = await app.inject({
++ method: "GET",
++ url: "/v1/auth/session",
++ headers: { authorization: "not-a-valid-token-format" },
++ });
++ expect(invalid.statusCode).toBe(401);
++
++ await app.close();
++ });
});
ddiff --git a/backend/src/app.ts b/backend/src/app.ts
iindex 6bd83b3..3b359d9 100644
---- a/backend/src/app.ts
++++ b/backend/src/app.ts
@@@ -100,6 +100,31 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {
};
});
++ app.get("/v1/auth/session", async (request, reply) => {
++ const authHeader = (request.headers["authorization"] ?? "") as string;
++ if (!authHeader.startsWith("Bearer ")) {
++ return reply.code(401).send({ error: "missing_token" });
++ }
++ const token = authHeader.slice(7).trim();
++ if (!token) {
++ return reply.code(401).send({ error: "missing_token" });
++ }
++
++ if (options.sessionStore?.getSession) {
++ const session = await options.sessionStore.getSession(token);
++ if (!session) return reply.code(401).send({ error: "invalid_token" });
++ return { valid: true, steamId: session.steamId, provider: session.provider };
++ }
++
++ // Fallback when no session store is configured: parse steamId from token format.
++ const parts = token.split(".");
++ if (parts.length >= 3 && (parts[0] === "dev" || parts[0] === "steam")) {
++ return { valid: true, provider: parts[0] as "dev" | "steam", steamId: parts[1] };
++ }
++
++ return reply.code(401).send({ error: "invalid_token" });
++ });
++
app.post("/v1/auth/steam", async (request, reply) => {
const body = authSteamSchema.parse(request.body ?? {});
const apiKey = process.env.STEAM_WEB_API_KEY;
ddiff --git a/backend/src/index.ts b/backend/src/index.ts
iindex 192be27..070bd4c 100644
---- a/backend/src/index.ts
++++ b/backend/src/index.ts
@@@ -3,6 +3,11 @@ import { Pool } from "pg";
import { createApp } from "./app.js";
import { PgOpenVibeRepository } from "./repository-pg.js";
import { RedisSessionStore } from "./sessions.js";
++import fastifyStatic from "@fastify/static";
++import { resolve } from "path";
++import { fileURLToPath } from "url";
++
++const __dirname = fileURLToPath(new URL(".", import.meta.url));
const pool = new Pool({
connectionString:
@@@ -17,6 +22,14 @@ const app = await createApp({
: undefined,
});
++// Serve the in-game web client from the repo-root client/ directory.
++const clientRoot = resolve(__dirname, "../../client");
++await app.register(fastifyStatic, {
++ root: clientRoot,
++ prefix: "/client/",
++ decorateReply: false,
++});
++
const host = process.env.HOST ?? "127.0.0.1";
const port = Number(process.env.PORT ?? 3000);
ddiff --git a/backend/src/sessions.ts b/backend/src/sessions.ts
iindex eb30e0c..b25c139 100644
---- a/backend/src/sessions.ts
++++ b/backend/src/sessions.ts
@@@ -8,8 +8,15 @@ export interface SessionInput {
ttlSeconds: number;
}
++export interface SessionData {
++ steamId: string;
++ provider: "dev" | "steam";
++ createdAt: string;
++}
++
export interface OpenVibeSessionStore {
createSession(input: SessionInput): Promise<void>;
++ getSession?(token: string): Promise<SessionData | null>;
close?(): Promise<void>;
}
@@@ -59,6 +66,16 @@ export class RedisSessionStore implements OpenVibeSessionStore {
);
}
++ async getSession(token: string): Promise<SessionData | null> {
++ if (this.redis.status === "wait") {
++ await this.redis.connect();
++ }
++
++ const raw = await this.redis.get(`openvibe:session:${token}`);
++ if (!raw) return null;
++ return JSON.parse(raw) as SessionData;
++ }
++
async close(): Promise<void> {
this.redis.disconnect();
}
ddiff --git a/client/index.html b/client/index.html
nnew file mode 100644
iindex 0000000..5224054
---- /dev/null
++++ b/client/index.html
@@@ -0,0 +1,1139 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++ <meta charset="UTF-8">
++ <meta name="viewport" content="width=device-width, initial-scale=1.0">
++ <title>OpenVibe: Source</title>
++ <style>
++ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
++
++ :root {
++ --bg: #070e14;
++ --bg-card: #0d1a25;
++ --bg-hover: #13263a;
++ --border: #1c3348;
++ --accent: #1a9fff;
++ --accent-dk: #1277c8;
++ --text: #c6d4df;
++ --text-dim: #7b9ab2;
++ --text-head: #e8f0f6;
++ --green: #4caf50;
++ --red: #e53935;
++ --gold: #f5a623;
++ --radius: 6px;
++ --transition: 0.15s ease;
++ }
++
++ html, body { height: 100%; overflow: hidden; }
++
++ body {
++ background: var(--bg);
++ color: var(--text);
++ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
++ font-size: 14px;
++ line-height: 1.5;
++ display: flex;
++ flex-direction: column;
++ }
++
++ /* ── Screen switching ─────────────────────────────── */
++ .screen { display: none; flex-direction: column; height: 100%; }
++ .screen.active { display: flex; }
++
++ /* ── Auth screen ──────────────────────────────────── */
++ #ov-auth {
++ align-items: center;
++ justify-content: center;
++ background:
++ radial-gradient(ellipse at 30% 40%, rgba(26,159,255,0.06) 0%, transparent 60%),
++ radial-gradient(ellipse at 70% 70%, rgba(20,100,180,0.06) 0%, transparent 60%),
++ var(--bg);
++ }
++
++ .auth-card {
++ width: 380px;
++ background: var(--bg-card);
++ border: 1px solid var(--border);
++ border-radius: 10px;
++ padding: 40px 36px 36px;
++ text-align: center;
++ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
++ }
++
++ .ov-logo {
++ font-size: 26px;
++ font-weight: 700;
++ letter-spacing: 0.02em;
++ color: var(--text-head);
++ margin-bottom: 6px;
++ }
++
++ .ov-subtitle {
++ font-size: 13px;
++ color: var(--text-dim);
++ margin-bottom: 28px;
++ }
++
++ .auth-divider {
++ height: 1px;
++ background: var(--border);
++ margin: 24px 0;
++ }
++
++ .auth-label {
++ font-size: 13px;
++ color: var(--text-dim);
++ margin-bottom: 14px;
++ }
++
++ .btn {
++ display: inline-flex;
++ align-items: center;
++ justify-content: center;
++ gap: 10px;
++ padding: 11px 22px;
++ border: none;
++ border-radius: var(--radius);
++ font-size: 14px;
++ font-weight: 600;
++ cursor: pointer;
++ transition: background var(--transition), opacity var(--transition), transform var(--transition);
++ text-decoration: none;
++ white-space: nowrap;
++ }
++ .btn:active:not(:disabled) { transform: translateY(1px); }
++ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
++
++ .btn-steam {
++ width: 100%;
++ background: linear-gradient(180deg, #4a9dd4 0%, #2a7ab9 100%);
++ color: #fff;
++ }
++ .btn-steam:hover:not(:disabled) { background: linear-gradient(180deg, #5aadde 0%, #3285c7 100%); }
++
++ .btn-dev {
++ width: 100%;
++ background: var(--bg-hover);
++ color: var(--text-dim);
++ border: 1px solid var(--border);
++ font-size: 12px;
++ margin-top: 10px;
++ }
++ .btn-dev:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
++
++ .steam-logo {
++ width: 20px;
++ height: 20px;
++ /* SVG steam logo embedded */
++ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='white' d='M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.252 0-2.265-1.014-2.265-2.265z'/%3E%3C/svg%3E") no-repeat center;
++ flex-shrink: 0;
++ }
++
++ .auth-status {
++ margin-top: 14px;
++ min-height: 20px;
++ font-size: 13px;
++ color: var(--text-dim);
++ }
++ .auth-status.error { color: var(--red); }
++ .auth-status.success { color: var(--green); }
++
++ /* ── Main screen layout ───────────────────────────── */
++ #ov-main { flex: 1; }
++
++ /* Top bar */
++ .topbar {
++ display: flex;
++ align-items: center;
++ padding: 0 16px;
++ height: 44px;
++ background: var(--bg-card);
++ border-bottom: 1px solid var(--border);
++ flex-shrink: 0;
++ gap: 12px;
++ }
++
++ .topbar-brand {
++ font-size: 15px;
++ font-weight: 700;
++ color: var(--text-head);
++ letter-spacing: 0.02em;
++ margin-right: 4px;
++ }
++
++ .topbar-nav {
++ display: flex;
++ gap: 2px;
++ flex: 1;
++ }
++
++ .nav-tab {
++ padding: 6px 13px;
++ border-radius: var(--radius);
++ font-size: 13px;
++ font-weight: 500;
++ color: var(--text-dim);
++ cursor: pointer;
++ border: none;
++ background: none;
++ transition: background var(--transition), color var(--transition);
++ white-space: nowrap;
++ }
++ .nav-tab:hover { background: var(--bg-hover); color: var(--text); }
++ .nav-tab.active { background: rgba(26,159,255,0.15); color: var(--accent); }
++
++ .topbar-profile {
++ display: flex;
++ align-items: center;
++ gap: 10px;
++ margin-left: auto;
++ }
++
++ .profile-avatar {
++ width: 28px;
++ height: 28px;
++ border-radius: 50%;
++ background: var(--bg-hover);
++ border: 1px solid var(--border);
++ display: flex;
++ align-items: center;
++ justify-content: center;
++ font-size: 12px;
++ font-weight: 700;
++ color: var(--accent);
++ overflow: hidden;
++ flex-shrink: 0;
++ }
++
++ .profile-name {
++ font-size: 13px;
++ font-weight: 600;
++ color: var(--text-head);
++ max-width: 120px;
++ overflow: hidden;
++ text-overflow: ellipsis;
++ white-space: nowrap;
++ }
++
++ .profile-currency {
++ font-size: 12px;
++ color: var(--gold);
++ font-weight: 600;
++ }
++
++ .profile-xp {
++ font-size: 12px;
++ color: var(--text-dim);
++ }
++
++ .btn-signout {
++ padding: 4px 10px;
++ font-size: 12px;
++ background: none;
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ color: var(--text-dim);
++ cursor: pointer;
++ transition: border-color var(--transition), color var(--transition);
++ }
++ .btn-signout:hover { border-color: var(--red); color: var(--red); }
++
++ /* Content area */
++ .content {
++ flex: 1;
++ overflow-y: auto;
++ padding: 20px;
++ }
++
++ /* Route panels */
++ .route { display: none; }
++ .route.active { display: block; }
++
++ /* ── Portal (game mode selection) ─────────────────── */
++ .modes-grid {
++ display: grid;
++ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
++ gap: 14px;
++ max-width: 1100px;
++ }
++
++ .mode-card {
++ background: var(--bg-card);
++ border: 1px solid var(--border);
++ border-radius: 8px;
++ padding: 20px;
++ cursor: pointer;
++ transition: border-color var(--transition), background var(--transition), transform var(--transition);
++ display: flex;
++ flex-direction: column;
++ gap: 8px;
++ }
++ .mode-card:hover { border-color: var(--accent); background: var(--bg-hover); transform: translateY(-2px); }
++ .mode-card:active { transform: translateY(0); }
++
++ .mode-icon { font-size: 28px; }
++
++ .mode-title {
++ font-size: 16px;
++ font-weight: 700;
++ color: var(--text-head);
++ }
++
++ .mode-desc {
++ font-size: 12px;
++ color: var(--text-dim);
++ line-height: 1.4;
++ }
++
++ .mode-badge {
++ display: inline-block;
++ padding: 2px 8px;
++ background: rgba(26,159,255,0.15);
++ border: 1px solid rgba(26,159,255,0.3);
++ border-radius: 12px;
++ font-size: 11px;
++ color: var(--accent);
++ font-weight: 600;
++ margin-top: 4px;
++ align-self: flex-start;
++ }
++
++ /* ── Section headers ──────────────────────────────── */
++ .section-header {
++ font-size: 18px;
++ font-weight: 700;
++ color: var(--text-head);
++ margin-bottom: 16px;
++ }
++
++ /* ── Loading spinner ──────────────────────────────── */
++ .spinner {
++ display: inline-block;
++ width: 16px;
++ height: 16px;
++ border: 2px solid var(--border);
++ border-top-color: var(--accent);
++ border-radius: 50%;
++ animation: spin 0.7s linear infinite;
++ vertical-align: middle;
++ }
++ @keyframes spin { to { transform: rotate(360deg); } }
++
++ /* ── Leaderboard ──────────────────────────────────── */
++ .lb-table {
++ width: 100%;
++ max-width: 700px;
++ border-collapse: collapse;
++ }
++ .lb-table th, .lb-table td {
++ padding: 8px 12px;
++ text-align: left;
++ border-bottom: 1px solid var(--border);
++ }
++ .lb-table th {
++ font-size: 11px;
++ font-weight: 600;
++ text-transform: uppercase;
++ letter-spacing: 0.05em;
++ color: var(--text-dim);
++ background: var(--bg-card);
++ }
++ .lb-table tr:hover td { background: var(--bg-hover); }
++ .lb-rank { color: var(--text-dim); font-weight: 600; width: 40px; }
++ .lb-rank.top { color: var(--gold); }
++ .lb-xp { color: var(--accent); font-weight: 600; }
++
++ /* ── Shop / Inventory items ───────────────────────── */
++ .items-grid {
++ display: grid;
++ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
++ gap: 12px;
++ max-width: 1100px;
++ }
++
++ .item-card {
++ background: var(--bg-card);
++ border: 1px solid var(--border);
++ border-radius: 8px;
++ padding: 14px;
++ display: flex;
++ flex-direction: column;
++ gap: 6px;
++ }
++
++ .item-type {
++ font-size: 10px;
++ text-transform: uppercase;
++ letter-spacing: 0.06em;
++ color: var(--text-dim);
++ }
++
++ .item-name {
++ font-size: 14px;
++ font-weight: 600;
++ color: var(--text-head);
++ }
++
++ .item-desc {
++ font-size: 12px;
++ color: var(--text-dim);
++ flex: 1;
++ }
++
++ .item-price {
++ font-size: 13px;
++ font-weight: 700;
++ color: var(--gold);
++ margin-top: 4px;
++ }
++
++ .btn-buy {
++ margin-top: 6px;
++ padding: 6px 14px;
++ font-size: 12px;
++ background: rgba(26,159,255,0.1);
++ border: 1px solid rgba(26,159,255,0.3);
++ border-radius: var(--radius);
++ color: var(--accent);
++ cursor: pointer;
++ font-weight: 600;
++ transition: background var(--transition), border-color var(--transition);
++ }
++ .btn-buy:hover { background: rgba(26,159,255,0.2); border-color: var(--accent); }
++ .btn-buy:disabled { opacity: 0.4; cursor: not-allowed; }
++
++ .item-owned {
++ display: inline-block;
++ font-size: 11px;
++ color: var(--green);
++ font-weight: 600;
++ margin-top: 4px;
++ }
++
++ /* ── Empty/error states ───────────────────────────── */
++ .empty-state {
++ padding: 40px;
++ text-align: center;
++ color: var(--text-dim);
++ font-size: 13px;
++ }
++
++ /* ── Toast notification ───────────────────────────── */
++ #ov-toast {
++ position: fixed;
++ bottom: 20px;
++ right: 20px;
++ padding: 10px 18px;
++ background: var(--bg-card);
++ border: 1px solid var(--border);
++ border-radius: 8px;
++ font-size: 13px;
++ color: var(--text);
++ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
++ z-index: 9999;
++ transform: translateY(80px);
++ opacity: 0;
++ transition: transform 0.3s ease, opacity 0.3s ease;
++ pointer-events: none;
++ max-width: 320px;
++ }
++ #ov-toast.show { transform: translateY(0); opacity: 1; }
++ #ov-toast.info { border-color: var(--accent); }
++ #ov-toast.success { border-color: var(--green); color: var(--green); }
++ #ov-toast.error { border-color: var(--red); color: var(--red); }
++
++ /* ── Servers list ─────────────────────────────────── */
++ .server-list { display: flex; flex-direction: column; gap: 8px; max-width: 800px; }
++
++ .server-row {
++ background: var(--bg-card);
++ border: 1px solid var(--border);
++ border-radius: 8px;
++ padding: 12px 16px;
++ display: flex;
++ align-items: center;
++ gap: 16px;
++ }
++
++ .server-mode {
++ width: 90px;
++ font-size: 11px;
++ text-transform: uppercase;
++ letter-spacing: 0.05em;
++ color: var(--text-dim);
++ font-weight: 600;
++ }
++
++ .server-name { flex: 1; font-size: 13px; font-weight: 500; color: var(--text-head); }
++
++ .server-players { font-size: 13px; color: var(--text-dim); white-space: nowrap; }
++
++ .server-state {
++ font-size: 11px;
++ font-weight: 600;
++ padding: 2px 8px;
++ border-radius: 12px;
++ text-transform: uppercase;
++ }
++ .server-state.open { background: rgba(76,175,80,0.15); color: var(--green); border: 1px solid rgba(76,175,80,0.3); }
++ .server-state.full { background: rgba(229,57,53,0.15); color: var(--red); border: 1px solid rgba(229,57,53,0.3); }
++ .server-state.starting { background: rgba(245,166,35,0.15); color: var(--gold); border: 1px solid rgba(245,166,35,0.3); }
++
++ .btn-join-server {
++ padding: 5px 14px;
++ font-size: 12px;
++ font-weight: 600;
++ background: rgba(26,159,255,0.1);
++ border: 1px solid rgba(26,159,255,0.3);
++ border-radius: var(--radius);
++ color: var(--accent);
++ cursor: pointer;
++ transition: background var(--transition);
++ }
++ .btn-join-server:hover { background: rgba(26,159,255,0.2); }
++
++ /* ── Settings ─────────────────────────────────────── */
++ .settings-section { max-width: 500px; }
++ .settings-row {
++ display: flex;
++ align-items: center;
++ justify-content: space-between;
++ padding: 12px 0;
++ border-bottom: 1px solid var(--border);
++ }
++ .settings-label { font-size: 13px; color: var(--text); }
++ .settings-desc { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
++ .settings-input {
++ background: var(--bg-card);
++ border: 1px solid var(--border);
++ border-radius: var(--radius);
++ color: var(--text);
++ padding: 5px 10px;
++ font-size: 13px;
++ width: 220px;
++ }
++ .settings-input:focus { outline: none; border-color: var(--accent); }
++
++ /* scrollbar */
++ ::-webkit-scrollbar { width: 6px; height: 6px; }
++ ::-webkit-scrollbar-track { background: var(--bg); }
++ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
++ ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
++ </style>
++</head>
++<body>
++
++<!-- ───────────────────────────────────────────────────────── AUTH SCREEN -->
++<div id="ov-auth" class="screen active">
++ <div class="auth-card">
++ <div class="ov-logo">OpenVibe: Source</div>
++ <div class="ov-subtitle">Multiplayer experience — powered by Steam</div>
++
++ <div class="auth-divider"></div>
++ <div class="auth-label">Sign in to access all features and game modes.</div>
++
++ <button id="btn-steam-auth" class="btn btn-steam" onclick="OV.steamAuth()">
++ <span class="steam-logo"></span>
++ Sign in with Steam
++ </button>
++
++ <div id="auth-status" class="auth-status"></div>
++
++ <div id="devauth-section" style="display:none">
++ <div class="auth-divider" style="margin-top:14px;"></div>
++ <button id="btn-dev-auth" class="btn btn-dev" onclick="OV.devAuth()">
++ Dev / Local auth (no Steam)
++ </button>
++ </div>
++ </div>
++</div>
++
++<!-- ───────────────────────────────────────────────────────── MAIN SCREEN -->
++<div id="ov-main" class="screen">
++
++ <!-- Top bar -->
++ <div class="topbar">
++ <span class="topbar-brand">OpenVibe</span>
++
++ <nav class="topbar-nav" id="main-nav">
++ <button class="nav-tab active" data-route="portal">Play</button>
++ <button class="nav-tab" data-route="servers">Servers</button>
++ <button class="nav-tab" data-route="leaderboard">Leaderboard</button>
++ <button class="nav-tab" data-route="inventory">Inventory</button>
++ <button class="nav-tab" data-route="shop">Shop</button>
++ <button class="nav-tab" data-route="settings">Settings</button>
++ </nav>
++
++ <div class="topbar-profile">
++ <div id="profile-avatar" class="profile-avatar">?</div>
++ <span id="profile-name" class="profile-name">Player</span>
++ <span id="profile-currency" class="profile-currency">0 VC</span>
++ <span id="profile-xp" class="profile-xp">0 XP</span>
++ <button class="btn-signout" onclick="OV.signOut()">Sign out</button>
++ </div>
++ </div>
++
++ <!-- Scrollable content area -->
++ <div class="content">
++
++ <!-- Portal: game mode selection -->
++ <div id="route-portal" class="route active">
++ <div class="section-header">Choose Your Game Mode</div>
++ <div class="modes-grid">
++ <div class="mode-card" onclick="OV.joinMode('hub')">
++ <div class="mode-icon">🏠</div>
++ <div class="mode-title">Hub</div>
++ <div class="mode-desc">Central social hub. Meet other players, trade, and explore.</div>
++ <span class="mode-badge">Open Now</span>
++ </div>
++ <div class="mode-card" onclick="OV.joinMode('prophunt')">
++ <div class="mode-icon">🎭</div>
++ <div class="mode-title">Prop Hunt</div>
++ <div class="mode-desc">Disguise as objects or hunt the hidden props before time runs out.</div>
++ <span class="mode-badge">Popular</span>
++ </div>
++ <div class="mode-card" onclick="OV.joinMode('deathrun')">
++ <div class="mode-icon">💀</div>
++ <div class="mode-title">Deathrun</div>
++ <div class="mode-desc">Race through deadly obstacle courses. One wrong step and you're gone.</div>
++ <span class="mode-badge">Fast-paced</span>
++ </div>
++ <div class="mode-card" onclick="OV.joinMode('fortwars')">
++ <div class="mode-icon">🏰</div>
++ <div class="mode-title">Fort Wars</div>
++ <div class="mode-desc">Build your fortress and defend against enemy raids.</div>
++ <span class="mode-badge">Team Play</span>
++ </div>
++ <div class="mode-card" onclick="OV.joinMode('traitortown')">
++ <div class="mode-icon">🕵️</div>
++ <div class="mode-title">Traitor Town</div>
++ <div class="mode-desc">Trust no one. Identify and eliminate the hidden traitors.</div>
++ <span class="mode-badge">Social Deduction</span>
++ </div>
++ </div>
++ </div>
++
++ <!-- Servers -->
++ <div id="route-servers" class="route">
++ <div class="section-header">Server Browser</div>
++ <div id="server-list" class="server-list">
++ <div class="empty-state"><span class="spinner"></span> Loading servers…</div>
++ </div>
++ </div>
++
++ <!-- Leaderboard -->
++ <div id="route-leaderboard" class="route">
++ <div class="section-header">Leaderboard</div>
++ <div id="leaderboard-content">
++ <div class="empty-state"><span class="spinner"></span> Loading…</div>
++ </div>
++ </div>
++
++ <!-- Inventory -->
++ <div id="route-inventory" class="route">
++ <div class="section-header">Your Inventory</div>
++ <div id="inventory-content">
++ <div class="empty-state"><span class="spinner"></span> Loading…</div>
++ </div>
++ </div>
++
++ <!-- Shop -->
++ <div id="route-shop" class="route">
++ <div class="section-header">Item Shop</div>
++ <div id="shop-content">
++ <div class="empty-state"><span class="spinner"></span> Loading…</div>
++ </div>
++ </div>
++
++ <!-- Settings -->
++ <div id="route-settings" class="route">
++ <div class="section-header">Settings</div>
++ <div class="settings-section">
++ <div class="settings-row">
++ <div>
++ <div class="settings-label">API Endpoint</div>
++ <div class="settings-desc">Backend API URL (advanced)</div>
++ </div>
++ <input id="settings-api-url" class="settings-input" type="text"
++ onchange="OV.saveSettings()" />
++ </div>
++ <div class="settings-row">
++ <div>
++ <div class="settings-label">Display Name</div>
++ <div class="settings-desc">How you appear to other players</div>
++ </div>
++ <input id="settings-display-name" class="settings-input" type="text"
++ placeholder="(from Steam)" readonly />
++ </div>
++ <div class="settings-row">
++ <div>
++ <div class="settings-label">Steam ID</div>
++ <div class="settings-desc">Your unique identifier</div>
++ </div>
++ <input id="settings-steam-id" class="settings-input" type="text" readonly />
++ </div>
++ <div class="settings-row" style="border:none; margin-top:8px;">
++ <span></span>
++ <button class="btn btn-dev" style="width:auto; padding: 6px 18px;" onclick="OV.signOut()">
++ Sign out
++ </button>
++ </div>
++ </div>
++ </div>
++
++ </div><!-- /content -->
++</div><!-- /ov-main -->
++
++<!-- Toast notification -->
++<div id="ov-toast"></div>
++
++<script>
++(function () {
++ 'use strict';
++
++ // ── Detect embedded Source engine context ─────────────────────────────
++ var params = new URLSearchParams(location.search);
++ var embedded = params.get('embedded') === '1';
++ var shell = params.get('shell') || '';
++ var devMode = params.get('devauth') === '1' || location.hostname === 'localhost' || location.hostname === '127.0.0.1';
++ var apiBase = localStorage.getItem('ov_api_url') || (location.origin);
++
++ // ── Internal state ────────────────────────────────────────────────────
++ var _session = null; // { sessionToken, steamId, displayName, provider }
++ var _profile = null; // full player profile from /v1/me
++
++ // ── Toast helper ──────────────────────────────────────────────────────
++ var _toastTimer = null;
++ function toast(msg, type, ms) {
++ var el = document.getElementById('ov-toast');
++ el.textContent = msg;
++ el.className = 'show ' + (type || 'info');
++ clearTimeout(_toastTimer);
++ _toastTimer = setTimeout(function () { el.className = ''; }, ms || 3000);
++ }
++
++ // ── API helper ────────────────────────────────────────────────────────
++ function apiFetch(path, opts) {
++ var url = apiBase.replace(/\/+$/, '') + path;
++ var headers = Object.assign({ 'Content-Type': 'application/json' }, (opts || {}).headers || {});
++ if (_session && _session.sessionToken) {
++ headers['Authorization'] = 'Bearer ' + _session.sessionToken;
++ }
++ return fetch(url, Object.assign({ headers: headers }, opts || {}))
++ .then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); });
++ }
++
++ // ── Auth helpers ──────────────────────────────────────────────────────
++ function setAuthStatus(msg, cls) {
++ var el = document.getElementById('auth-status');
++ el.textContent = msg;
++ el.className = 'auth-status ' + (cls || '');
++ }
++
++ function saveSession(s) {
++ _session = s;
++ try { localStorage.setItem('ov_session', JSON.stringify(s)); } catch (e) {}
++ }
++
++ function clearSession() {
++ _session = null;
++ _profile = null;
++ try { localStorage.removeItem('ov_session'); } catch (e) {}
++ }
++
++ function loadStoredSession() {
++ try {
++ var raw = localStorage.getItem('ov_session');
++ return raw ? JSON.parse(raw) : null;
++ } catch (e) { return null; }
++ }
++
++ // Load player profile after authentication
++ function loadProfile(steamId) {
++ if (!steamId) return;
++ apiFetch('/v1/me?steamId=' + encodeURIComponent(steamId))
++ .then(function (res) {
++ if (res.status === 200) {
++ _profile = res.body;
++ updateProfileBar(res.body);
++ }
++ })
++ .catch(function () {});
++ }
++
++ function updateProfileBar(profile) {
++ if (!profile || !profile.player) return;
++ var p = profile.player;
++
++ var nameEl = document.getElementById('profile-name');
++ var avatarEl = document.getElementById('profile-avatar');
++ var currEl = document.getElementById('profile-currency');
++ var xpEl = document.getElementById('profile-xp');
++ var nameInput = document.getElementById('settings-display-name');
++ var sidInput = document.getElementById('settings-steam-id');
++
++ if (nameEl) nameEl.textContent = p.displayName || 'Player';
++ if (avatarEl) avatarEl.textContent = (p.displayName || '?')[0].toUpperCase();
++ if (currEl) currEl.textContent = (p.currencyBalance || 0) + ' VC';
++ if (xpEl) xpEl.textContent = (p.xp || 0) + ' XP';
++ if (nameInput) nameInput.value = p.displayName || '';
++ if (sidInput) sidInput.value = p.steamId || '';
++ }
++
++ // Verify session token with backend
++ function verifyToken(token) {
++ return apiFetch('/v1/auth/session', {
++ headers: { 'Authorization': 'Bearer ' + token }
++ }).then(function (res) { return res.status === 200 && res.body.valid; })
++ .catch(function () { return false; });
++ }
++
++ // Show main authenticated UI
++ function showMainUI(session) {
++ _session = session;
++ document.getElementById('ov-auth').classList.remove('active');
++ document.getElementById('ov-main').classList.add('active');
++
++ if (session.steamId) {
++ loadProfile(session.steamId);
++ }
++
++ var apiInput = document.getElementById('settings-api-url');
++ if (apiInput) apiInput.value = apiBase;
++
++ routeTo(location.hash ? location.hash.slice(1) : 'portal');
++ }
++
++ // Show auth screen
++ function showAuthUI(msg) {
++ clearSession();
++ document.getElementById('ov-main').classList.remove('active');
++ document.getElementById('ov-auth').classList.add('active');
++ if (msg) setAuthStatus(msg, 'error');
++
++ var btn = document.getElementById('btn-steam-auth');
++ if (btn) { btn.disabled = false; }
++
++ if (devMode) {
++ var devSection = document.getElementById('devauth-section');
++ if (devSection) devSection.style.display = 'block';
++ }
++ }
++
++ // ── Routing ───────────────────────────────────────────────────────────
++ var _currentRoute = 'portal';
++ var _routeLoaded = {};
++
++ function routeTo(routeName) {
++ var valid = ['portal','servers','leaderboard','inventory','shop','settings'];
++ if (valid.indexOf(routeName) === -1) routeName = 'portal';
++
++ _currentRoute = routeName;
++ location.replace('#' + routeName);
++
++ document.querySelectorAll('.route').forEach(function (el) {
++ el.classList.toggle('active', el.id === 'route-' + routeName);
++ });
++ document.querySelectorAll('.nav-tab').forEach(function (el) {
++ el.classList.toggle('active', el.dataset.route === routeName);
++ });
++
++ if (!_routeLoaded[routeName]) {
++ _routeLoaded[routeName] = true;
++ loadRoute(routeName);
++ }
++ }
++
++ function loadRoute(route) {
++ switch (route) {
++ case 'servers': loadServers(); break;
++ case 'leaderboard': loadLeaderboard(); break;
++ case 'inventory': loadInventory(); break;
++ case 'shop': loadShop(); break;
++ }
++ }
++
++ // Nav tab click handler
++ document.getElementById('main-nav').addEventListener('click', function (e) {
++ var tab = e.target.closest('.nav-tab');
++ if (tab && tab.dataset.route) routeTo(tab.dataset.route);
++ });
++
++ // ── Route loaders ─────────────────────────────────────────────────────
++ function loadServers() {
++ var el = document.getElementById('server-list');
++ apiFetch('/v1/servers').then(function (res) {
++ if (res.status !== 200 || !res.body.servers) {
++ el.innerHTML = '<div class="empty-state">Failed to load servers.</div>';
++ return;
++ }
++ var servers = res.body.servers;
++ if (!servers.length) {
++ el.innerHTML = '<div class="empty-state">No servers online right now.</div>';
++ return;
++ }
++ el.innerHTML = servers.map(function (s) {
++ var stateClass = ['starting','full'].indexOf(s.state) !== -1 ? s.state : 'open';
++ var canJoin = s.state === 'open';
++ return '<div class="server-row">' +
++ '<div class="server-mode">' + escHtml(s.mode) + '</div>' +
++ '<div class="server-name">' + escHtml(s.mapName) + '</div>' +
++ '<div class="server-players">' + s.playerCount + ' / ' + s.maxPlayers + '</div>' +
++ '<span class="server-state ' + stateClass + '">' + escHtml(s.state) + '</span>' +
++ (canJoin ? '<button class="btn-join-server" onclick="OV.joinMode(\'' + escHtml(s.mode) + '\')">Join</button>' : '<span></span>') +
++ '</div>';
++ }).join('');
++ }).catch(function () {
++ el.innerHTML = '<div class="empty-state">Could not reach API.</div>';
++ });
++ }
++
++ function loadLeaderboard() {
++ var el = document.getElementById('leaderboard-content');
++ apiFetch('/v1/leaderboard?limit=20').then(function (res) {
++ if (res.status !== 200 || !res.body.leaderboard) {
++ el.innerHTML = '<div class="empty-state">Failed to load leaderboard.</div>';
++ return;
++ }
++ var rows = res.body.leaderboard;
++ if (!rows.length) {
++ el.innerHTML = '<div class="empty-state">No entries yet. Play some matches!</div>';
++ return;
++ }
++ el.innerHTML = '<table class="lb-table"><thead><tr>' +
++ '<th>#</th><th>Player</th><th>XP</th><th>Balance</th>' +
++ '</tr></thead><tbody>' +
++ rows.map(function (e) {
++ var topClass = e.rank <= 3 ? ' top' : '';
++ return '<tr><td class="lb-rank' + topClass + '">' + e.rank + '</td>' +
++ '<td>' + escHtml(e.displayName) + '</td>' +
++ '<td class="lb-xp">' + e.xp + '</td>' +
++ '<td>' + e.currencyBalance + ' VC</td>' +
++ '</tr>';
++ }).join('') + '</tbody></table>';
++ }).catch(function () {
++ el.innerHTML = '<div class="empty-state">Could not reach API.</div>';
++ });
++ }
++
++ function loadInventory() {
++ if (!_session || !_session.steamId) {
++ document.getElementById('inventory-content').innerHTML =
++ '<div class="empty-state">Not signed in.</div>';
++ return;
++ }
++ var el = document.getElementById('inventory-content');
++ apiFetch('/v1/me?steamId=' + encodeURIComponent(_session.steamId)).then(function (res) {
++ if (res.status !== 200 || !res.body.inventory) {
++ el.innerHTML = '<div class="empty-state">Could not load inventory.</div>';
++ return;
++ }
++ var items = res.body.inventory;
++ if (!items.length) {
++ el.innerHTML = '<div class="empty-state">Your inventory is empty. Visit the Shop to get items!</div>';
++ return;
++ }
++ el.innerHTML = '<div class="items-grid">' + items.map(function (item) {
++ return '<div class="item-card">' +
++ '<div class="item-type">' + escHtml(item.itemType) + '</div>' +
++ '<div class="item-name">' + escHtml(item.displayName) + '</div>' +
++ '<div class="item-desc">' + escHtml(item.description || '') + '</div>' +
++ '<span class="item-owned">✓ Owned</span>' +
++ '</div>';
++ }).join('') + '</div>';
++ }).catch(function () {
++ el.innerHTML = '<div class="empty-state">Could not reach API.</div>';
++ });
++ }
++
++ function loadShop() {
++ var el = document.getElementById('shop-content');
++ apiFetch('/v1/shop').then(function (res) {
++ if (res.status !== 200 || !res.body.items) {
++ el.innerHTML = '<div class="empty-state">Could not load shop.</div>';
++ return;
++ }
++ var items = res.body.items.filter(function (i) { return i.enabled; });
++ var ownedIds = _profile ? (_profile.inventory || []).map(function (i) { return i.itemId; }) : [];
++ el.innerHTML = '<div class="items-grid">' + items.map(function (item) {
++ var owned = ownedIds.indexOf(item.itemId) !== -1;
++ return '<div class="item-card">' +
++ '<div class="item-type">' + escHtml(item.itemType) + '</div>' +
++ '<div class="item-name">' + escHtml(item.displayName) + '</div>' +
++ '<div class="item-desc">' + escHtml(item.description || '') + '</div>' +
++ '<div class="item-price">' + (item.price === 0 ? 'Free' : item.price + ' VC') + '</div>' +
++ (owned
++ ? '<span class="item-owned">✓ Owned</span>'
++ : '<button class="btn-buy" onclick="OV.buyItem(' + JSON.stringify(item.itemId) + ',' + item.price + ')" ' +
++ (!_session ? 'disabled' : '') + '>Buy</button>'
++ ) +
++ '</div>';
++ }).join('') + '</div>';
++ }).catch(function () {
++ el.innerHTML = '<div class="empty-state">Could not reach API.</div>';
++ });
++ }
++
++ // ── XSS helper ────────────────────────────────────────────────────────
++ function escHtml(s) {
++ return String(s)
++ .replace(/&/g, '&amp;')
++ .replace(/</g, '&lt;')
++ .replace(/>/g, '&gt;')
++ .replace(/"/g, '&quot;')
++ .replace(/'/g, '&#39;');
++ }
++
++ // ── Public OV API (called by C++ and by page itself) ──────────────────
++ window.OV = {
++
++ // Called by C++ after GetTicketForWebApi + POST /v1/auth/steam succeed.
++ // data: { authenticated, sessionToken, steamId, displayName, error? }
++ onSteamAuthResult: function (data) {
++ var btn = document.getElementById('btn-steam-auth');
++ if (btn) btn.disabled = false;
++
++ if (data && data.authenticated && data.sessionToken) {
++ setAuthStatus('Authenticated!', 'success');
++ var session = {
++ sessionToken: data.sessionToken,
++ steamId: data.steamId || '',
++ displayName: data.displayName || '',
++ provider: 'steam'
++ };
++ saveSession(session);
++ setTimeout(function () { showMainUI(session); }, 300);
++ } else {
++ var errMsg = (data && data.error) ? data.error : 'Steam authentication failed.';
++ setAuthStatus(errMsg, 'error');
++ }
++ },
++
++ // Trigger Steam auth via C++ bridge.
++ steamAuth: function () {
++ var btn = document.getElementById('btn-steam-auth');
++ if (btn) btn.disabled = true;
++ setAuthStatus('Connecting to Steam\u2026');
++ // Navigate to bridge URL — C++ intercepts this and calls ov_auth_steam.
++ location.href = 'openvibe://auth/steam';
++ },
++
++ // Dev / local auth (no Steam) — uses POST /v1/auth/dev.
++ devAuth: function () {
++ var btn = document.getElementById('btn-dev-auth');
++ if (btn) btn.disabled = true;
++ setAuthStatus('Signing in (dev)\u2026');
++ apiFetch('/v1/auth/dev', {
++ method: 'POST',
++ body: JSON.stringify({ steamId: '76561198000000000', displayName: 'Dev Player' })
++ }).then(function (res) {
++ if (res.status === 200 && res.body.sessionToken) {
++ var session = {
++ sessionToken: res.body.sessionToken,
++ steamId: res.body.player && res.body.player.steamId || '76561198000000000',
++ displayName: res.body.player && res.body.player.displayName || 'Dev Player',
++ provider: 'dev'
++ };
++ saveSession(session);
++ setAuthStatus('Signed in!', 'success');
++ setTimeout(function () { showMainUI(session); }, 300);
++ } else {
++ setAuthStatus('Dev auth failed: ' + (res.body.error || 'unknown'), 'error');
++ if (btn) btn.disabled = false;
++ }
++ }).catch(function (e) {
++ setAuthStatus('Could not reach API.', 'error');
++ if (btn) btn.disabled = false;
++ });
++ },
++
++ // Join a game mode — uses openvibe:// bridge in-game, API otherwise.
++ joinMode: function (mode) {
++ if (embedded && shell === 'source') {
++ location.href = 'openvibe://join?mode=' + encodeURIComponent(mode);
++ } else {
++ toast('Connect to a Source server to play ' + mode + '.', 'info', 4000);
++ }
++ },
++
++ // Buy a shop item.
++ buyItem: function (itemId, price) {
++ if (!_session || !_session.steamId) { toast('Sign in first.', 'error'); return; }
++ apiFetch('/v1/shop/buy', {
++ method: 'POST',
++ body: JSON.stringify({ steamId: _session.steamId, itemId: itemId })
++ }).then(function (res) {
++ if (res.status === 200) {
++ toast('Purchased!', 'success');
++ _profile = res.body;
++ updateProfileBar(res.body);
++ _routeLoaded['shop'] = false;
++ _routeLoaded['inventory'] = false;
++ if (_currentRoute === 'shop') loadShop();
++ } else {
++ toast(res.body.error || 'Purchase failed.', 'error');
++ }
++ }).catch(function () { toast('Could not reach API.', 'error'); });
++ },
++
++ // Save settings from the settings panel.
++ saveSettings: function () {
++ var urlInput = document.getElementById('settings-api-url');
++ if (urlInput && urlInput.value) {
++ apiBase = urlInput.value.replace(/\/+$/, '');
++ try { localStorage.setItem('ov_api_url', apiBase); } catch (e) {}
++ toast('Settings saved.', 'success');
++ }
++ },
++
++ // Sign out.
++ signOut: function () {
++ clearSession();
++ _routeLoaded = {};
++ showAuthUI('');
++ },
++
++ // Called by C++ when the ready bridge is sent (openvibe://ready).
++ // C++ uses this to inject stored session tokens on page reload.
++ onReady: function () {}
++ };
++
++ // ── Startup: check stored session ─────────────────────────────────────
++ function init() {
++ if (devMode) {
++ var devSection = document.getElementById('devauth-section');
++ if (devSection) devSection.style.display = 'block';
++ }
++
++ var stored = loadStoredSession();
++ if (stored && stored.sessionToken) {
++ // Optimistically show main UI, verify in background.
++ _session = stored;
++ verifyToken(stored.sessionToken).then(function (valid) {
++ if (valid) {
++ showMainUI(stored);
++ } else {
++ showAuthUI('Your session expired. Please sign in again.');
++ }
++ });
++ } else {
++ showAuthUI('');
++ }
++
++ // Notify C++ that the page is ready (openvibe://ready bridge handler).
++ if (embedded && shell === 'source') {
++ var readyImg = new Image();
++ readyImg.src = 'openvibe://ready';
++ }
++ }
++
++ if (document.readyState === 'loading') {
++ document.addEventListener('DOMContentLoaded', init);
++ } else {
++ init();
++ }
++
++})();
++</script>
++</body>
++</html>
ddiff --git a/sdk/openvibe/client/hl2mp/openvibe_client.cpp b/sdk/openvibe/client/hl2mp/openvibe_client.cpp
iindex 07ded68..2de7634 100644
---- a/sdk/openvibe/client/hl2mp/openvibe_client.cpp
++++ b/sdk/openvibe/client/hl2mp/openvibe_client.cpp
@@@ -23,6 +23,9 @@
extern CSteamAPIContext *steamapicontext;
++// Forward declaration: implemented in vgui_openvibe_menu.cpp.
++extern void OpenVibe_NotifyHTMLSteamAuth( bool bSuccess, const char *pszToken, const char *pszSteamId, const char *pszDisplayName );
++
static ConVar ov_api_url(
"ov_api_url",
"http://127.0.0.1:3000",
@@@ -53,6 +56,19 @@ static ConVar ov_auth_identity(
FCVAR_ARCHIVE,
"Steam Web API ticket identity for OpenVibe backend authentication." );
++static ConVar ov_session_token(
++ "ov_session_token",
++ "",
++ FCVAR_ARCHIVE | FCVAR_HIDDEN | FCVAR_PROTECTED,
++ "Stored OpenVibe session token (set after successful Steam auth)." );
++
++// Returns the current stored session token string.
++// Declared extern in vgui_openvibe_menu.cpp so the HTML bridge can read it.
++const char *OpenVibe_GetSessionToken()
++{
++ return ov_session_token.GetString();
++}
++
static const char *s_OpenVibeModes[] =
{
"hub",
@@@ -398,11 +414,30 @@ private:
if ( OV_ExtractJsonBool( szBody, "authenticated" ) )
{
-- Msg( "[OV Auth] Steam authentication accepted by OpenVibe API.\n" );
++ char szToken[512];
++ char szSteamId[32];
++ char szDisplayName[128];
++
++ szToken[0] = '\0';
++ szSteamId[0] = '\0';
++ szDisplayName[0] = '\0';
++
++ OV_ExtractJsonString( szBody, "sessionToken", szToken, sizeof( szToken ) );
++ OV_ExtractJsonString( szBody, "steamId", szSteamId, sizeof( szSteamId ) );
++ // displayName is nested under "player"; OV_ExtractJsonString finds the
++ // first occurrence in the flat JSON text which is the player.displayName.
++ if ( !OV_ExtractJsonString( szBody, "displayName", szDisplayName, sizeof( szDisplayName ) ) )
++ Q_strncpy( szDisplayName, "Unknown", sizeof( szDisplayName ) );
++
++ ov_session_token.SetValue( szToken );
++
++ Msg( "[OV Auth] Steam authentication accepted by OpenVibe API. SteamID: %s\n", szSteamId );
++ OpenVibe_NotifyHTMLSteamAuth( true, szToken, szSteamId, szDisplayName );
}
else
{
Warning( "[OV Auth] OpenVibe API rejected or could not validate Steam ticket: %s\n", szBody );
++ OpenVibe_NotifyHTMLSteamAuth( false, "", "", "" );
}
ReleaseRequest();
ddiff --git a/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp b/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp
iindex 735ca25..41f6739 100644
---- a/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp
++++ b/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp
@@@ -22,7 +22,7 @@ using namespace vgui;
static ConVar ov_menu_url(
"ov_menu_url",
-- "http://127.0.0.1:5173/client/?embedded=1&shell=source#portal",
++ "http://127.0.0.1:3000/client/?embedded=1&shell=source#portal",
FCVAR_ARCHIVE,
"OpenVibe HTML menu URL." );
@@@ -288,6 +288,20 @@ private:
return;
}
++ if ( OV_URLStartsWith( pszURL, "openvibe://auth/steam" ) )
++ {
++ // Web page requests Steam authentication; trigger the C++ auth flow.
++ engine->ClientCmd_Unrestricted( "ov_auth_steam\n" );
++ return;
++ }
++
++ if ( OV_URLStartsWith( pszURL, "openvibe://ready" ) )
++ {
++ // Web page has loaded and exposed window.OV; push any stored session.
++ OpenVibe_OnHTMLReady();
++ return;
++ }
++
if ( OV_URLStartsWith( pszURL, "openvibe://external" ) )
{
char szUrl[512];
@@@ -452,3 +466,88 @@ static ConCommand ov_menu_settings_route_cmd(
FCVAR_CLIENTDLL );
// OPENVIBE_UNIFIED_UI_ROUTE_COMMANDS_END
++// OPENVIBE_STEAM_AUTH_BRIDGE_BEGIN
++
++// Forward declaration: implemented in openvibe_client.cpp.
++extern const char *OpenVibe_GetSessionToken();
++
++// Sanitise a string for safe embedding in a JS single-quoted literal:
++// strip characters that could break out of the string context.
++static void OV_SanitiseJSString( char *pszOut, size_t nOut, const char *pszIn )
++{
++ if ( !pszIn || !pszOut || nOut == 0 )
++ {
++ if ( pszOut && nOut ) pszOut[0] = '\0';
++ return;
++ }
++
++ size_t nWritten = 0;
++ for ( const char *p = pszIn; *p && nWritten < nOut - 1; ++p )
++ {
++ unsigned char c = (unsigned char)*p;
++ // Skip control chars, quotes, backslash, and angle brackets.
++ if ( c < 32 || c == '\'' || c == '"' || c == '\\' || c == '<' || c == '>' )
++ continue;
++ pszOut[nWritten++] = *p;
++ }
++ pszOut[nWritten] = '\0';
++}
++
++// Called by COpenVibeSteamAuthClient after Steam ticket validation completes.
++// Injects the result into the HTML page via RunJS.
++void OpenVibe_NotifyHTMLSteamAuth( bool bSuccess, const char *pszToken, const char *pszSteamId, const char *pszDisplayName )
++{
++ COpenVibeHTMLPanel *pMenu = s_pOpenVibeMenu;
++ if ( !pMenu )
++ return;
++
++ char szToken[2048];
++ char szSteamId[64];
++ char szDisplayName[256];
++
++ OV_SanitiseJSString( szToken, sizeof( szToken ), pszToken ? pszToken : "" );
++ OV_SanitiseJSString( szSteamId, sizeof( szSteamId ), pszSteamId ? pszSteamId : "" );
++ OV_SanitiseJSString( szDisplayName, sizeof( szDisplayName ), pszDisplayName ? pszDisplayName : "" );
++
++ char szScript[4096];
++ if ( bSuccess )
++ {
++ Q_snprintf( szScript, sizeof( szScript ),
++ "if(typeof window.OV==='object'&&typeof window.OV.onSteamAuthResult==='function')"
++ "{window.OV.onSteamAuthResult({authenticated:true,sessionToken:'%s',steamId:'%s',displayName:'%s'});}",
++ szToken, szSteamId, szDisplayName );
++ }
++ else
++ {
++ Q_snprintf( szScript, sizeof( szScript ),
++ "if(typeof window.OV==='object'&&typeof window.OV.onSteamAuthResult==='function')"
++ "{window.OV.onSteamAuthResult({authenticated:false,error:'steam_auth_failed'});}" );
++ }
++
++ pMenu->RunJS( szScript );
++}
++
++// Called when the web page navigates to openvibe://ready.
++// Pushes any stored session token into the page so it can skip the auth screen.
++void OpenVibe_OnHTMLReady()
++{
++ COpenVibeHTMLPanel *pMenu = s_pOpenVibeMenu;
++ if ( !pMenu )
++ return;
++
++ const char *pszToken = OpenVibe_GetSessionToken();
++ if ( !pszToken || !pszToken[0] )
++ return;
++
++ char szToken[2048];
++ OV_SanitiseJSString( szToken, sizeof( szToken ), pszToken );
++
++ char szScript[3072];
++ Q_snprintf( szScript, sizeof( szScript ),
++ "if(typeof window.OV==='object'&&typeof window.OV.onSteamAuthResult==='function')"
++ "{window.OV.onSteamAuthResult({authenticated:true,sessionToken:'%s',steamId:'',displayName:''});}", szToken );
++
++ pMenu->RunJS( szScript );
++}
++
++// OPENVIBE_STEAM_AUTH_BRIDGE_END