openvibers/source.openvibe.games Commits
feat: implement Steam auth GUI and full auth bridge
Changes
@@@ -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` |@@@ -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",@@@ -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",@@@ -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();++ });});@@@ -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;@@@ -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);@@@ -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();}nnew file mode 100644@@@ -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, '&')++ .replace(/</g, '<')++ .replace(/>/g, '>')++ .replace(/"/g, '"')++ .replace(/'/g, ''');++ }++++ // ── 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>@@@ -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();@@@ -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
@@ -33,6 +33,8 @@ openvibe-source/
@@ -33,6 +33,8 @@ openvibe-source/
| Backend API | Implemented | `backend/src/app.ts`, `backend/src/repository-pg.ts`, `backend/src/sessions.ts` |
| Backend API | Implemented | `backend/src/app.ts`, `backend/src/repository-pg.ts`, `backend/src/sessions.ts` |
| Backend tests | Passing | `backend/src/app.test.ts` |
| Backend tests | Passing | `backend/src/app.test.ts` |
| Steam auth route | Implemented | `POST /v1/auth/steam`; requires production Steam env vars |
| 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` |
| Redis sessions | Implemented | `backend/src/sessions.ts` |
| CDN asset manifest | Implemented | `GET /v1/assets/manifest` |
| CDN asset manifest | Implemented | `GET /v1/assets/manifest` |
| C++ client travel | Implemented | `sdk/openvibe/client/hl2mp/openvibe_client.cpp` |
| C++ client travel | Implemented | `sdk/openvibe/client/hl2mp/openvibe_client.cpp` |
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"version": "0.0.1",
"version": "0.0.1",
"dependencies": {
"dependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/cors": "^11.1.0",
+ "@fastify/static": "^9.1.3",
"dotenv": "^17.2.3",
"dotenv": "^17.2.3",
"fastify": "^5.6.2",
"fastify": "^5.6.2",
"ioredis": "^5.11.1",
"ioredis": "^5.11.1",
@@ -503,6 +504,22 @@
@@ -503,6 +504,22 @@
"node": ">=18"
"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": {
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -634,6 +651,53 @@
@@ -634,6 +651,53 @@
"ipaddr.js": "^2.1.0"
"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": {
"node_modules/@ioredis/commands": {
"version": "1.10.0",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
@@ -647,6 +711,15 @@
@@ -647,6 +711,15 @@
"dev": true,
"dev": true,
"license": "MIT"
"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": {
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1202,6 +1275,27 @@
@@ -1202,6 +1275,27 @@
"fastq": "^1.17.1"
"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": {
"node_modules/chai": {
"version": "6.2.2",
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1221,6 +1315,19 @@
@@ -1221,6 +1315,19 @@
"node": ">=0.10.0"
"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": {
"node_modules/convert-source-map": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1267,6 +1374,15 @@
@@ -1267,6 +1374,15 @@
"node": ">=0.10"
"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": {
"node_modules/dequal": {
"version": "2.0.3",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -1347,6 +1463,12 @@
@@ -1347,6 +1463,12 @@
"@esbuild/win32-x64": "0.28.1"
"@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": {
"node_modules/estree-walker": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -1533,6 +1655,49 @@
@@ -1533,6 +1655,49 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
"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": {
"node_modules/ioredis": {
"version": "5.11.1",
"version": "5.11.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
@@ -1887,6 +2052,15 @@
@@ -1887,6 +2052,15 @@
"url": "https://opencollective.com/parcel"
"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": {
"node_modules/magic-string": {
"version": "0.30.21",
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1897,6 +2071,42 @@
@@ -1897,6 +2071,42 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
"@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": {
"node_modules/ms": {
"version": "2.1.3",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1944,6 +2154,22 @@
@@ -1944,6 +2154,22 @@
"node": ">=14.0.0"
"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": {
"node_modules/pathe": {
"version": "2.0.3",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2369,6 +2595,12 @@
@@ -2369,6 +2595,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
"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": {
"node_modules/siginfo": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -2417,6 +2649,15 @@
@@ -2417,6 +2649,15 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
"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": {
"node_modules/std-env": {
"version": "4.1.0",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
@@ -2495,6 +2736,15 @@
@@ -2495,6 +2736,15 @@
"node": ">=20"
"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": {
"node_modules/tslib": {
"version": "2.8.1",
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
},
},
"dependencies": {
"dependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/cors": "^11.1.0",
+ "@fastify/static": "^9.1.3",
"dotenv": "^17.2.3",
"dotenv": "^17.2.3",
"fastify": "^5.6.2",
"fastify": "^5.6.2",
"ioredis": "^5.11.1",
"ioredis": "^5.11.1",
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { createApp } from "./app.js";
import { createApp } from "./app.js";
import { MemoryOpenVibeRepository } from "./repository-memory.js";
import { MemoryOpenVibeRepository } from "./repository-memory.js";
-import { SessionInput } from "./sessions.js";
+import { SessionData, SessionInput } from "./sessions.js";
const steamId = "76561198000000000";
const steamId = "76561198000000000";
const serverSecret = "dev-secret";
const serverSecret = "dev-secret";
@@ -595,4 +595,54 @@ describe("OpenVibe API vertical slice", () => {
@@ -595,4 +595,54 @@ describe("OpenVibe API vertical slice", () => {
await app.close();
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();
+ });
});
});
@@ -100,6 +100,31 @@ export async function createApp(options: AppOptions): Promise<FastifyInstance> {
@@ -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) => {
app.post("/v1/auth/steam", async (request, reply) => {
const body = authSteamSchema.parse(request.body ?? {});
const body = authSteamSchema.parse(request.body ?? {});
const apiKey = process.env.STEAM_WEB_API_KEY;
const apiKey = process.env.STEAM_WEB_API_KEY;
@@ -3,6 +3,11 @@ import { Pool } from "pg";
@@ -3,6 +3,11 @@ import { Pool } from "pg";
import { createApp } from "./app.js";
import { createApp } from "./app.js";
import { PgOpenVibeRepository } from "./repository-pg.js";
import { PgOpenVibeRepository } from "./repository-pg.js";
import { RedisSessionStore } from "./sessions.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({
const pool = new Pool({
connectionString:
connectionString:
@@ -17,6 +22,14 @@ const app = await createApp({
@@ -17,6 +22,14 @@ const app = await createApp({
: undefined,
: 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 host = process.env.HOST ?? "127.0.0.1";
const port = Number(process.env.PORT ?? 3000);
const port = Number(process.env.PORT ?? 3000);
@@ -8,8 +8,15 @@ export interface SessionInput {
@@ -8,8 +8,15 @@ export interface SessionInput {
ttlSeconds: number;
ttlSeconds: number;
}
}
+export interface SessionData {
+ steamId: string;
+ provider: "dev" | "steam";
+ createdAt: string;
+}
+
export interface OpenVibeSessionStore {
export interface OpenVibeSessionStore {
createSession(input: SessionInput): Promise<void>;
createSession(input: SessionInput): Promise<void>;
+ getSession?(token: string): Promise<SessionData | null>;
close?(): Promise<void>;
close?(): Promise<void>;
}
}
@@ -59,6 +66,16 @@ export class RedisSessionStore implements OpenVibeSessionStore {
@@ -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> {
async close(): Promise<void> {
this.redis.disconnect();
this.redis.disconnect();
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,1139 @@
@@ -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, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ // ── 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>
@@ -23,6 +23,9 @@
@@ -23,6 +23,9 @@
extern CSteamAPIContext *steamapicontext;
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(
static ConVar ov_api_url(
"ov_api_url",
"ov_api_url",
"http://127.0.0.1:3000",
"http://127.0.0.1:3000",
@@ -53,6 +56,19 @@ static ConVar ov_auth_identity(
@@ -53,6 +56,19 @@ static ConVar ov_auth_identity(
FCVAR_ARCHIVE,
FCVAR_ARCHIVE,
"Steam Web API ticket identity for OpenVibe backend authentication." );
"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[] =
static const char *s_OpenVibeModes[] =
{
{
"hub",
"hub",
@@ -398,11 +414,30 @@ private:
@@ -398,11 +414,30 @@ private:
if ( OV_ExtractJsonBool( szBody, "authenticated" ) )
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
else
{
{
Warning( "[OV Auth] OpenVibe API rejected or could not validate Steam ticket: %s\n", szBody );
Warning( "[OV Auth] OpenVibe API rejected or could not validate Steam ticket: %s\n", szBody );
+ OpenVibe_NotifyHTMLSteamAuth( false, "", "", "" );
}
}
ReleaseRequest();
ReleaseRequest();
@@ -22,7 +22,7 @@ using namespace vgui;
@@ -22,7 +22,7 @@ using namespace vgui;
static ConVar ov_menu_url(
static ConVar ov_menu_url(
"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,
FCVAR_ARCHIVE,
"OpenVibe HTML menu URL." );
"OpenVibe HTML menu URL." );
@@ -288,6 +288,20 @@ private:
@@ -288,6 +288,20 @@ private:
return;
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" ) )
if ( OV_URLStartsWith( pszURL, "openvibe://external" ) )
{
{
char szUrl[512];
char szUrl[512];
@@ -452,3 +466,88 @@ static ConCommand ov_menu_settings_route_cmd(
@@ -452,3 +466,88 @@ static ConCommand ov_menu_settings_route_cmd(
FCVAR_CLIENTDLL );
FCVAR_CLIENTDLL );
// OPENVIBE_UNIFIED_UI_ROUTE_COMMANDS_END
// 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