From 42b0c64b6baa93c04309ca33c855ec61c9b0795f Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:49:58 +0000 Subject: [PATCH 1/6] Add manual browser capture flow for Boosteroid authentication --- .env.example | 15 +- .gitignore | 1 + README.md | 34 +- package-lock.json | 59 ++- package.json | 4 +- server/config.ts | 22 +- server/index.ts | 116 ++++-- server/lib/authCapture.ts | 473 +++++++++++++++++++++++ server/lib/upstream.ts | 13 +- src/api/client.ts | 3 +- src/api/config.ts | 7 +- src/api/endpoints.ts | 33 +- src/api/index.ts | 5 +- src/auth/AuthContext.tsx | 24 +- src/auth/context.ts | 4 +- src/auth/index.ts | 1 - src/auth/login-adapter.ts | 15 - src/components/AuthCaptureDebugPanel.tsx | 120 ++++++ src/pages/LibraryPage.tsx | 5 + src/pages/LoginPage.tsx | 467 +++++++++++----------- src/types/index.ts | 93 ++++- 21 files changed, 1167 insertions(+), 347 deletions(-) create mode 100644 server/lib/authCapture.ts delete mode 100644 src/auth/login-adapter.ts create mode 100644 src/components/AuthCaptureDebugPanel.tsx diff --git a/.env.example b/.env.example index f34607c..561be3f 100644 --- a/.env.example +++ b/.env.example @@ -2,13 +2,6 @@ # Set only when the frontend is deployed separately from the backend bridge. # VITE_API_BASE_URL= -# Cloudflare Turnstile site key. -# For local development, use the Cloudflare test key that always passes: -# VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA -# For production, leave unset to use the default production key, -# or set explicitly: -# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAB83Vz-GpH08brQi - # Backend bridge configuration. # SERVER_PORT=3001 # UPSTREAM_BASE_URL=https://cloud.boosteroid.com @@ -17,6 +10,14 @@ # SESSION_TTL_SECONDS=2592000 # COOKIE_SECURE=false # APP_ORIGIN=http://localhost:3000 +# AUTH_CAPTURE_ARTIFACT_DIR=/absolute/path/to/OpenStroid/.runtime/auth-captures +# BROWSER_LOGIN_TIMEOUT_MS=300000 +# BROWSER_LOGIN_POLL_INTERVAL_MS=1500 +# BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS=30000 +# BROWSER_HEADLESS=false +# BROWSER_CHANNEL=chromium +# BROWSER_EXECUTABLE_PATH=/absolute/path/to/chrome-or-chromium +# BROWSER_LAUNCH_ARGS=--start-maximized # Optional: override the Vite dev proxy target if your backend runs elsewhere. # BACKEND_PROXY_TARGET=http://localhost:3001 diff --git a/.gitignore b/.gitignore index 4c50899..4e20891 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist-ssr .env build +.runtime diff --git a/README.md b/README.md index 726284e..df2875f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenStroid -Open-source cloud gaming client. Built with React, TypeScript, Mantine, Vite, and an Express auth bridge. +Open-source cloud gaming client. Built with React, TypeScript, Mantine, Vite, Express, and a backend-owned browser capture bridge for Boosteroid authentication. ## Quick start @@ -13,20 +13,24 @@ npm run dev - Frontend dev server: [http://localhost:3000](http://localhost:3000) - Backend auth bridge: [http://localhost:3001](http://localhost:3001) -`npm run dev` starts both processes. The browser talks only to first-party `/auth`, `/me`, and `/library` routes. The backend bridge owns the upstream Boosteroid session and proxies authenticated requests to `https://cloud.boosteroid.com`. +`npm run dev` starts both processes. The browser talks only to first-party `/auth`, `/me`, and `/library` routes. The backend bridge owns the upstream Boosteroid session, launches a real browser window for manual upstream login, and proxies authenticated requests to `https://cloud.boosteroid.com`. ## Auth bridge architecture -OpenStroid is no longer a browser-direct Boosteroid client. +OpenStroid is no longer a browser-direct or credential-forwarding Boosteroid client. -- The frontend sends login, session bootstrap, logout, and library requests to first-party endpoints on the OpenStroid origin. +- The frontend starts a backend login capture instead of posting credentials to OpenStroid. +- The backend launches a visible Playwright-controlled browser window to the real Boosteroid login experience and waits for the user to complete Turnstile manually. +- Once Boosteroid sets authenticated upstream state, the backend captures raw cookies and observed auth/session payloads, validates the upstream session, and converts it into the existing encrypted first-party OpenStroid cookie session. +- The frontend sends session bootstrap, logout, and library requests to first-party endpoints on the OpenStroid origin. - The backend bridge talks to `https://cloud.boosteroid.com` for: - - `POST /api/v1/auth/login` + - browser-observed `POST /api/v1/auth/login` - `POST /api/v1/auth/refresh-token` - `POST /api/v2/auth/logout` - `GET /api/v1/user` - `GET /api/v1/boostore/applications/installed` -- Upstream access and refresh tokens are stored only inside encrypted, HttpOnly first-party cookies. +- Upstream access and refresh tokens are stored only inside encrypted, HttpOnly first-party cookies for normal app behavior. +- Raw upstream cookies, login/session payloads, and request metadata are also persisted under `.runtime/auth-captures/` and exposed through a dedicated authenticated debug endpoint. - The browser no longer stores raw upstream tokens in `localStorage`. - Session bootstrap uses `GET /auth/session` instead of reading browser storage. - Upstream 401s are refreshed server-side with a shared refresh lock to avoid duplicate refresh races. @@ -37,7 +41,11 @@ The backend exposes normalized first-party endpoints: | Method | Route | Description | |---|---|---| -| `POST` | `/auth/login` | Login with email/password/Turnstile, create first-party session cookie, return `{ authenticated, user }` | +| `POST` | `/auth/login/start` | Start a browser-backed Boosteroid login capture and launch the upstream login window | +| `GET` | `/auth/login/status` | Read the latest capture status and establish the first-party OpenStroid session on success | +| `GET` | `/auth/login/status/:id` | Read status for a specific capture session | +| `POST` | `/auth/login/cancel` | Cancel the active capture and clean up browser resources | +| `GET` | `/auth/debug/capture` | Return the latest raw upstream capture artifact, including cookies and payloads | | `POST` | `/auth/logout` | Clear first-party session and attempt upstream logout | | `GET` | `/auth/session` | Validate/refresh current session and return `{ authenticated, user }` | | `GET` | `/me` | Return `{ user }` for authenticated clients | @@ -49,7 +57,6 @@ The backend exposes normalized first-party endpoints: | Variable | Default | Description | |---|---|---| | `VITE_API_BASE_URL` | *(empty)* | Frontend API origin. Leave empty for same-origin deployments. | -| `VITE_TURNSTILE_SITE_KEY` | `0x4AAAAAAB83Vz-GpH08brQi` | Cloudflare Turnstile site key. Use `1x00000000000000000000AA` for local dev. | | `SERVER_PORT` | `3001` | Backend bridge port. | | `UPSTREAM_BASE_URL` | `https://cloud.boosteroid.com` | Upstream Boosteroid base URL. | | `SESSION_SECRET` | `openstroid-development-session-secret` | Secret used to encrypt/authenticate the session cookie. Replace in production. | @@ -57,6 +64,14 @@ The backend exposes normalized first-party endpoints: | `SESSION_TTL_SECONDS` | `2592000` | Cookie/session lifetime in seconds. | | `COOKIE_SECURE` | `false` in dev, `true` in production | Whether to mark the auth cookie as `Secure`. | | `APP_ORIGIN` | *(unset)* | Optional allowed browser origin when frontend and backend run on different origins. | +| `AUTH_CAPTURE_ARTIFACT_DIR` | `/.runtime/auth-captures` | Directory where raw capture JSON artifacts are written. | +| `BROWSER_LOGIN_TIMEOUT_MS` | `300000` | Maximum time allowed for manual upstream login before timing out. | +| `BROWSER_LOGIN_POLL_INTERVAL_MS` | `1500` | Interval used while checking whether upstream auth cookies/tokens are ready. | +| `BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS` | `30000` | Initial page navigation timeout for the launched browser. | +| `BROWSER_HEADLESS` | `false` | Whether to launch the backend browser headlessly. Visible mode is the intended default. | +| `BROWSER_CHANNEL` | *(unset)* | Optional browser channel passed to Playwright, such as `chromium` or `chrome`. | +| `BROWSER_EXECUTABLE_PATH` | *(unset)* | Optional explicit browser executable path. | +| `BROWSER_LAUNCH_ARGS` | *(unset)* | Comma-separated extra browser launch arguments. | | `BACKEND_PROXY_TARGET` | `http://localhost:3001` | Vite-only proxy target for local frontend development. | ## Production notes @@ -89,8 +104,9 @@ src/ ## Current features (auth bridge refactor) -- **Secure login flow** — email/password authentication with Cloudflare Turnstile routed through the backend bridge +- **Manual upstream login capture** — the user completes the real Boosteroid + Turnstile login in a backend-launched browser window - **Server-managed session** — session bootstrap checks `/auth/session` and keeps upstream tokens out of browser JavaScript +- **Debug evidence capture** — raw upstream cookies, payloads, and request metadata are inspectable from the UI and saved to `.runtime/auth-captures` - **My Games library** — installed games loaded through first-party backend routes with existing loading, empty, and error states - **Logout** — clears the OpenStroid session and attempts upstream logout - **Server-side refresh** — upstream token refresh happens on the backend with refresh de-duplication for concurrent requests diff --git a/package-lock.json b/package-lock.json index 69d76d5..0ed6ba5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,10 +18,10 @@ "axios": "^1.15.0", "cookie-parser": "^1.4.7", "express": "^5.2.1", + "playwright": "^1.53.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.14.0", - "react-turnstile": "^1.1.5" + "react-router-dom": "^7.14.0" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -2791,7 +2791,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4399,6 +4398,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", @@ -4809,16 +4852,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/react-turnstile": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/react-turnstile/-/react-turnstile-1.1.5.tgz", - "integrity": "sha512-VTL5OeHAatzCEVQxAZox70/TPmhKxEbNgtr++dg+8zm9QrWKuoU9E0+7gqmycOSCDZuJFzvMMLKQb5PVUPLV6w==", - "license": "MIT", - "peerDependencies": { - "react": ">= 16.13.1", - "react-dom": ">= 16.13.1" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index c41c6b2..e3e41f2 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "axios": "^1.15.0", "cookie-parser": "^1.4.7", "express": "^5.2.1", + "playwright": "^1.53.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.14.0", - "react-turnstile": "^1.1.5" + "react-router-dom": "^7.14.0" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/server/config.ts b/server/config.ts index dd5c265..1fa36a0 100644 --- a/server/config.ts +++ b/server/config.ts @@ -12,7 +12,11 @@ function parseBoolean(value: string | undefined, fallback: boolean): boolean { } const nodeEnv = process.env.NODE_ENV ?? 'development'; -const projectRoot = path.resolve(import.meta.dirname, '..'); +const configDir = import.meta.dirname; +const buildServerSuffix = `${path.sep}build${path.sep}server${path.sep}server`; +const projectRoot = configDir.endsWith(buildServerSuffix) + ? path.resolve(configDir, '..', '..', '..') + : path.resolve(configDir, '..'); export const serverConfig = { nodeEnv, @@ -24,5 +28,21 @@ export const serverConfig = { sessionTtlSeconds: parseNumber(process.env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 30), cookieSecure: parseBoolean(process.env.COOKIE_SECURE, nodeEnv === 'production'), appOrigin: process.env.APP_ORIGIN, + authCaptureArtifactDir: + process.env.AUTH_CAPTURE_ARTIFACT_DIR ?? + path.resolve(projectRoot, '.runtime', 'auth-captures'), + browserLoginTimeoutMs: parseNumber(process.env.BROWSER_LOGIN_TIMEOUT_MS, 5 * 60 * 1000), + browserLoginPollIntervalMs: parseNumber(process.env.BROWSER_LOGIN_POLL_INTERVAL_MS, 1500), + browserLaunchNavigateTimeoutMs: parseNumber( + process.env.BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS, + 30 * 1000, + ), + browserHeadless: parseBoolean(process.env.BROWSER_HEADLESS, false), + browserExecutablePath: process.env.BROWSER_EXECUTABLE_PATH, + browserChannel: process.env.BROWSER_CHANNEL, + browserLaunchArgs: (process.env.BROWSER_LAUNCH_ARGS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), distDir: path.resolve(projectRoot, 'dist'), } as const; diff --git a/server/index.ts b/server/index.ts index 3ac1640..cadf6ea 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,11 +3,11 @@ import path from 'node:path'; import express, { type NextFunction, type Request, type Response } from 'express'; import cookieParser from 'cookie-parser'; import { serverConfig } from './config.js'; +import { authCaptureManager } from './lib/authCapture.js'; import { clearSession, createSession, readSession, writeSession } from './lib/session.js'; import { getInstalledGamesUpstream, getUpstreamUser, - loginUpstream, logoutUpstream, normalizeError, withRefresh, @@ -53,35 +53,87 @@ function requireSession(req: Request, res: Response) { return session; } +function sendCaptureStatus(req: Request, res: Response, captureId?: string) { + const capture = authCaptureManager.getStatus(captureId); + if (!capture) { + res.status(404).json({ message: 'No login capture session found.' }); + return; + } + + let sessionEstablished = false; + if (capture.status === 'succeeded' && capture.bridgeSession) { + const existingSession = readSession(req); + const nextSession = createSession({ + accessToken: capture.bridgeSession.accessToken, + refreshToken: capture.bridgeSession.refreshToken, + userData: capture.bridgeSession.userData, + user: capture.bridgeSession.user, + existing: existingSession ?? capture.bridgeSession, + }); + writeSession(res, nextSession); + sessionEstablished = true; + } + + res.json({ + id: capture.id, + status: capture.status, + startedAt: capture.startedAt, + updatedAt: capture.updatedAt, + completedAt: capture.completedAt, + timeoutAt: capture.timeoutAt, + finalUrl: capture.finalUrl, + errors: capture.errors, + eventCount: capture.eventCount, + user: capture.userPayload, + sessionEstablished, + }); +} + app.get('/health', (_req, res) => { res.json({ ok: true }); }); -app.post('/auth/login', async (req, res, next) => { +app.post('/auth/login/start', async (_req, res, next) => { + try { + const capture = await authCaptureManager.start(); + clearSession(res); + res.status(202).json(capture); + } catch (error) { + next(error); + } +}); + +app.get('/auth/login/status', (req, res) => { + sendCaptureStatus(req, res); +}); + +app.get('/auth/login/status/:id', (req, res) => { + sendCaptureStatus(req, res, req.params.id); +}); + +app.post('/auth/login/cancel', async (req, res, next) => { try { - const body = req.body as Record; - const payload: Record = { - email: String(body.email ?? '').trim().toLowerCase(), - password: String(body.password ?? ''), - remember_me: Boolean(body.remember_me), - }; - - const turnstileToken = body['cf-turnstile-response']; - if (typeof turnstileToken === 'string' && turnstileToken.length > 0) { - payload['cf-turnstile-response'] = turnstileToken; + const captureId = typeof req.body?.id === 'string' ? req.body.id : undefined; + const capture = await authCaptureManager.cancel(captureId); + if (!capture) { + res.status(404).json({ message: 'No active login capture session found.' }); + return; } - const tokens = await loginUpstream(payload); - const user = await getUpstreamUser(tokens.access_token); - const session = createSession({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - userData: tokens.user_data, - user, + clearSession(res); + res.json({ + id: capture.id, + status: capture.status, + startedAt: capture.startedAt, + updatedAt: capture.updatedAt, + completedAt: capture.completedAt, + timeoutAt: capture.timeoutAt, + finalUrl: capture.finalUrl, + errors: capture.errors, + eventCount: capture.eventCount, + user: capture.userPayload, + sessionEstablished: false, }); - - writeSession(res, session); - sendSession(res, user); } catch (error) { next(error); } @@ -129,6 +181,26 @@ app.get('/auth/session', async (req, res, next) => { } }); +app.get('/auth/debug/capture', (req, res) => { + const session = requireSession(req, res); + if (!session) return; + + const { artifact, path: artifactPath } = authCaptureManager.getLatestArtifact(); + if (!artifact) { + res.status(404).json({ message: 'No auth capture artifact available yet.' }); + return; + } + + res.json({ + artifact, + artifactPath, + requestedBy: { + email: session.user?.email ?? null, + updatedAt: session.updatedAt, + }, + }); +}); + app.get('/me', async (req, res, next) => { const session = requireSession(req, res); if (!session) return; diff --git a/server/lib/authCapture.ts b/server/lib/authCapture.ts new file mode 100644 index 0000000..9c90579 --- /dev/null +++ b/server/lib/authCapture.ts @@ -0,0 +1,473 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { chromium, type Browser, type BrowserContext, type Cookie, type Page } from 'playwright'; +import { serverConfig } from '../config.js'; +import { createSession, type BridgeSession } from './session.js'; +import { getUpstreamUser, unwrapRecord } from './upstream.js'; + +const LOGIN_URL = 'https://boosteroid.com'; +const AUTH_COOKIE_NAMES = ['access_token', 'refresh_token', 'boosteroid_auth', 'qr_auth_code'] as const; +const RELEVANT_PATH_PATTERNS = [ + '/api/v1/auth/login', + '/api/v1/auth/refresh-token', + '/api/v2/auth/logout', + '/api/v1/user', + '/api/v1/boostore/applications/installed', + '/auth', + '/login', + '/session', +]; +const FINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled', 'timed_out'] as const); +const MAX_CAPTURE_FILES = 25; + +type CaptureStatus = 'starting' | 'awaiting_user' | 'succeeded' | 'failed' | 'cancelled' | 'timed_out'; +type CaptureTerminalStatus = 'succeeded' | 'failed' | 'cancelled' | 'timed_out'; + +export interface CaptureEvent { + timestamp: string; + type: 'page' | 'request' | 'response' | 'note' | 'error'; + method?: string; + url?: string; + status?: number; + payload?: unknown; + headers?: Record; + cookieNames?: string[]; + message?: string; +} + +export interface StoredCookie { + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: string; +} + +export interface CaptureArtifact { + id: string; + status: CaptureStatus; + startedAt: string; + updatedAt: string; + completedAt: string | null; + timeoutAt: string; + loginUrl: string; + finalUrl: string | null; + upstreamBaseUrl: string; + errors: string[]; + eventCount: number; + authCookies: Partial>; + allCookies: StoredCookie[]; + observedResponses: CaptureEvent[]; + userPayload: Record | null; + bridgeSession: BridgeSession | null; +} + +interface CaptureRuntime { + id: string; + status: CaptureStatus; + startedAtMs: number; + updatedAtMs: number; + timeoutAtMs: number; + completedAtMs: number | null; + browser: Browser | null; + context: BrowserContext | null; + page: Page | null; + events: CaptureEvent[]; + errors: string[]; + bridgeSession: BridgeSession | null; + artifact: CaptureArtifact | null; + persistedPath: string | null; + cancelled: boolean; + waitPromise: Promise | null; +} + +function isRelevantUrl(url: string): boolean { + return RELEVANT_PATH_PATTERNS.some((pattern) => url.includes(pattern)); +} + +function toIso(ms: number | null): string | null { + return ms ? new Date(ms).toISOString() : null; +} + +function summarizeHeaders(headers: Record): Record { + const picked: Record = {}; + for (const key of ['content-type', 'location']) { + if (headers[key]) picked[key] = headers[key]; + } + return picked; +} + +function serializeCookie(cookie: Cookie): StoredCookie { + return { + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + expires: cookie.expires, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + }; +} + +async function ensureArtifactDir(): Promise { + await fs.mkdir(serverConfig.authCaptureArtifactDir, { recursive: true }); +} + +async function pruneArtifacts(): Promise { + const entries = await fs.readdir(serverConfig.authCaptureArtifactDir, { withFileTypes: true }).catch(() => []); + const files = await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json')).map(async (entry) => { + const filePath = path.join(serverConfig.authCaptureArtifactDir, entry.name); + const stat = await fs.stat(filePath); + return { filePath, mtimeMs: stat.mtimeMs }; + })); + files.sort((a, b) => b.mtimeMs - a.mtimeMs); + await Promise.all(files.slice(MAX_CAPTURE_FILES).map((file) => fs.unlink(file.filePath).catch(() => undefined))); +} + +async function persistArtifact(artifact: CaptureArtifact): Promise { + await ensureArtifactDir(); + const filePath = path.join(serverConfig.authCaptureArtifactDir, `${artifact.startedAt.replace(/[:.]/g, '-')}-${artifact.id}.json`); + await fs.writeFile(filePath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); + await pruneArtifacts(); + return filePath; +} + +class AuthCaptureManager { + private active: CaptureRuntime | null = null; + private latestArtifact: CaptureArtifact | null = null; + private latestArtifactPath: string | null = null; + + constructor() { + void this.restoreLatestArtifact().catch(() => undefined); + } + + async start(): Promise<{ id: string; status: CaptureStatus; timeoutAt: string }> { + this.cleanupFinishedActive(); + if (this.active && !FINAL_STATUSES.has(this.active.status as CaptureTerminalStatus)) { + const error = new Error('A login capture is already in progress.'); + (error as Error & { status?: number; details?: unknown }).status = 409; + (error as Error & { status?: number; details?: unknown }).details = { id: this.active.id, status: this.active.status }; + throw error; + } + + const now = Date.now(); + const capture: CaptureRuntime = { + id: randomUUID(), + status: 'starting', + startedAtMs: now, + updatedAtMs: now, + timeoutAtMs: now + serverConfig.browserLoginTimeoutMs, + completedAtMs: null, + browser: null, + context: null, + page: null, + events: [], + errors: [], + bridgeSession: null, + artifact: null, + persistedPath: null, + cancelled: false, + waitPromise: null, + }; + + this.active = capture; + capture.waitPromise = this.runCapture(capture); + return { id: capture.id, status: capture.status, timeoutAt: new Date(capture.timeoutAtMs).toISOString() }; + } + + async cancel(id?: string): Promise { + const capture = this.active; + if (!capture) return null; + if (id && capture.id !== id) return null; + capture.cancelled = true; + if (!FINAL_STATUSES.has(capture.status as CaptureTerminalStatus)) { + await this.finalize(capture, 'cancelled', 'Capture cancelled by user.'); + } + return capture.artifact; + } + + getStatus(id?: string): CaptureArtifact | null { + const current = this.active; + if (current && (!id || current.id === id)) { + return this.toArtifact(current); + } + if (this.latestArtifact && (!id || this.latestArtifact.id === id)) { + return this.latestArtifact; + } + return null; + } + + getLatestArtifact(): { artifact: CaptureArtifact | null; path: string | null } { + return { artifact: this.latestArtifact, path: this.latestArtifactPath }; + } + + private async restoreLatestArtifact(): Promise { + await ensureArtifactDir(); + const entries = await fs.readdir(serverConfig.authCaptureArtifactDir, { withFileTypes: true }).catch(() => []); + const files = await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json')).map(async (entry) => { + const filePath = path.join(serverConfig.authCaptureArtifactDir, entry.name); + const stat = await fs.stat(filePath); + return { filePath, mtimeMs: stat.mtimeMs }; + })); + const latest = files.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]; + if (!latest) return; + const raw = await fs.readFile(latest.filePath, 'utf8').catch(() => null); + if (!raw) return; + this.latestArtifact = JSON.parse(raw) as CaptureArtifact; + this.latestArtifactPath = latest.filePath; + } + + private cleanupFinishedActive(): void { + if (this.active && FINAL_STATUSES.has(this.active.status as CaptureTerminalStatus)) { + this.active = null; + } + } + + private pushEvent(capture: CaptureRuntime, event: CaptureEvent): void { + capture.events.push(event); + capture.updatedAtMs = Date.now(); + } + + private attachNetworkListeners(capture: CaptureRuntime, page: Page): void { + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame()) { + this.pushEvent(capture, { + timestamp: new Date().toISOString(), + type: 'page', + url: frame.url(), + message: 'Main frame navigated.', + }); + } + }); + + page.on('request', (request) => { + const url = request.url(); + if (!isRelevantUrl(url)) return; + this.pushEvent(capture, { + timestamp: new Date().toISOString(), + type: 'request', + method: request.method(), + url, + message: 'Observed relevant request.', + }); + }); + + page.on('response', async (response) => { + const url = response.url(); + if (!isRelevantUrl(url)) return; + const headers = response.headers(); + const event: CaptureEvent = { + timestamp: new Date().toISOString(), + type: 'response', + method: response.request().method(), + url, + status: response.status(), + headers: summarizeHeaders(headers), + message: 'Observed relevant response.', + }; + + const headerArray = await response.headersArray(); + const setCookieValues = headerArray + .filter((header) => header.name.toLowerCase() === 'set-cookie') + .map((header) => header.value); + if (setCookieValues.length > 0) { + event.cookieNames = setCookieValues.map((value) => value.split('=')[0] ?? '').filter(Boolean); + } + + const contentType = headers['content-type'] ?? ''; + if (contentType.includes('application/json')) { + try { + event.payload = await response.json(); + } catch { + event.message = 'Observed relevant response, but JSON payload could not be parsed.'; + } + } + + this.pushEvent(capture, event); + }); + + page.on('pageerror', (error) => { + this.pushEvent(capture, { + timestamp: new Date().toISOString(), + type: 'error', + message: error.message, + }); + }); + } + + private extractTokens(cookies: Cookie[]): { accessToken: string | null; refreshToken: string | null } { + const accessToken = cookies.find((cookie) => cookie.name === 'access_token')?.value ?? null; + const refreshToken = cookies.find((cookie) => cookie.name === 'refresh_token')?.value ?? null; + return { accessToken, refreshToken }; + } + + private async buildBridgeSession(capture: CaptureRuntime): Promise { + if (!capture.context || !capture.page) return null; + const cookies = await capture.context.cookies(); + const { accessToken, refreshToken } = this.extractTokens(cookies); + if (!accessToken || !refreshToken) return null; + + const user = await getUpstreamUser(accessToken); + this.pushEvent(capture, { + timestamp: new Date().toISOString(), + type: 'note', + method: 'GET', + url: `${serverConfig.upstreamBaseUrl}/api/v1/user`, + status: 200, + payload: user, + message: 'Validated captured tokens via upstream user lookup.', + }); + + const loginResponse = capture.events.find((event) => event.type === 'response' && event.url?.includes('/api/v1/auth/login') && event.payload && typeof event.payload === 'object'); + const loginEnvelope = unwrapRecord(loginResponse?.payload); + + return createSession({ + accessToken, + refreshToken, + userData: loginEnvelope.user_data, + user, + }); + } + + private toArtifact(capture: CaptureRuntime): CaptureArtifact { + const cookies = capture.artifact?.allCookies ?? []; + const authCookies = capture.artifact?.authCookies ?? {}; + return { + id: capture.id, + status: capture.status, + startedAt: new Date(capture.startedAtMs).toISOString(), + updatedAt: new Date(capture.updatedAtMs).toISOString(), + completedAt: toIso(capture.completedAtMs), + timeoutAt: new Date(capture.timeoutAtMs).toISOString(), + loginUrl: LOGIN_URL, + finalUrl: capture.page?.url() ?? capture.artifact?.finalUrl ?? null, + upstreamBaseUrl: serverConfig.upstreamBaseUrl, + errors: [...capture.errors], + eventCount: capture.events.length, + authCookies, + allCookies: cookies, + observedResponses: capture.events.filter((event) => event.type === 'response' || event.type === 'note'), + userPayload: (capture.bridgeSession?.user as Record | undefined) ?? null, + bridgeSession: capture.bridgeSession, + }; + } + + private async finalize(capture: CaptureRuntime, status: CaptureStatus, message?: string): Promise { + if (FINAL_STATUSES.has(capture.status as CaptureTerminalStatus)) return; + capture.status = status; + capture.updatedAtMs = Date.now(); + capture.completedAtMs = Date.now(); + if (message) { + if (status !== 'succeeded') { + capture.errors.push(message); + } + this.pushEvent(capture, { + timestamp: new Date().toISOString(), + type: status === 'succeeded' ? 'note' : 'error', + message, + }); + } + + const cookies = capture.context ? await capture.context.cookies() : []; + const allCookies = cookies.map(serializeCookie); + const authCookies = Object.fromEntries( + AUTH_COOKIE_NAMES.map((name) => { + const found = cookies.find((cookie) => cookie.name === name); + return [name, found ? serializeCookie(found) : undefined]; + }).filter((entry) => entry[1] !== undefined), + ) as Partial>; + + const artifact = this.toArtifact(capture); + artifact.status = status; + artifact.completedAt = new Date(capture.completedAtMs).toISOString(); + artifact.updatedAt = new Date(capture.updatedAtMs).toISOString(); + artifact.finalUrl = capture.page?.url() ?? artifact.finalUrl; + artifact.allCookies = allCookies; + artifact.authCookies = authCookies; + artifact.bridgeSession = capture.bridgeSession; + artifact.userPayload = (capture.bridgeSession?.user as Record | undefined) ?? null; + artifact.errors = [...capture.errors]; + artifact.eventCount = capture.events.length; + artifact.observedResponses = capture.events.filter((event) => event.type === 'response' || event.type === 'note'); + + capture.artifact = artifact; + capture.persistedPath = await persistArtifact(artifact); + this.latestArtifact = artifact; + this.latestArtifactPath = capture.persistedPath; + + await capture.page?.close().catch(() => undefined); + await capture.context?.close().catch(() => undefined); + await capture.browser?.close().catch(() => undefined); + capture.page = null; + capture.context = null; + capture.browser = null; + } + + private async runCapture(capture: CaptureRuntime): Promise { + try { + await ensureArtifactDir(); + const browser = await chromium.launch({ + headless: serverConfig.browserHeadless, + channel: serverConfig.browserChannel || undefined, + executablePath: serverConfig.browserExecutablePath || undefined, + args: serverConfig.browserLaunchArgs, + }); + const context = await browser.newContext({ viewport: { width: 1400, height: 920 } }); + const page = await context.newPage(); + capture.browser = browser; + capture.context = context; + capture.page = page; + capture.status = 'awaiting_user'; + capture.updatedAtMs = Date.now(); + this.attachNetworkListeners(capture, page); + this.pushEvent(capture, { + timestamp: new Date().toISOString(), + type: 'note', + message: 'Browser launched. Waiting for manual Boosteroid login.', + }); + + await page.goto(LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: serverConfig.browserLaunchNavigateTimeoutMs }); + + while (Date.now() < capture.timeoutAtMs) { + if (capture.cancelled) { + await this.finalize(capture, 'cancelled', 'Capture cancelled by user.'); + return; + } + + const bridgeSession = await this.buildBridgeSession(capture).catch((error: unknown) => { + const message = error instanceof Error ? error.message : 'Waiting for captured upstream session.'; + this.pushEvent(capture, { + timestamp: new Date().toISOString(), + type: 'note', + message, + }); + return null; + }); + + if (bridgeSession) { + capture.bridgeSession = bridgeSession; + await this.finalize(capture, 'succeeded', 'Successfully captured authenticated Boosteroid session.'); + return; + } + + await page.waitForTimeout(serverConfig.browserLoginPollIntervalMs); + } + + await this.finalize(capture, 'timed_out', 'Login capture timed out before an authenticated session was detected.'); + } catch (error) { + if (capture.cancelled) { + return; + } + const message = error instanceof Error ? error.message : 'Unexpected browser capture failure.'; + capture.errors.push(message); + await this.finalize(capture, 'failed', message); + } + } +} + +export const authCaptureManager = new AuthCaptureManager(); diff --git a/server/lib/upstream.ts b/server/lib/upstream.ts index b57db5c..478cab2 100644 --- a/server/lib/upstream.ts +++ b/server/lib/upstream.ts @@ -20,7 +20,7 @@ export interface UpstreamTokens { const refreshRequests = new Map>(); -function unwrapRecord(data: unknown): Record { +export function unwrapRecord(data: unknown): Record { if (data && typeof data === 'object' && 'data' in data && data.data && typeof data.data === 'object') { return data.data as Record; } @@ -28,6 +28,17 @@ function unwrapRecord(data: unknown): Record { } export function normalizeError(error: unknown): { status: number; message: string; details?: unknown } { + if (error instanceof Error) { + const typedError = error as Error & { status?: number; details?: unknown }; + if (typeof typedError.status === 'number') { + return { + status: typedError.status, + message: typedError.message, + details: typedError.details, + }; + } + } + if (axios.isAxiosError(error)) { const status = error.response?.status ?? 502; const payload = error.response?.data as Record | undefined; diff --git a/src/api/client.ts b/src/api/client.ts index 958c635..3dfdc32 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -17,7 +17,8 @@ apiClient.interceptors.response.use( const requestUrl = error.config?.url ?? ''; const shouldBroadcastUnauthorized = error.response?.status === 401 && - requestUrl !== API_CONFIG.endpoints.login && + requestUrl !== API_CONFIG.endpoints.loginStart && + requestUrl !== API_CONFIG.endpoints.loginStatus && requestUrl !== API_CONFIG.endpoints.session; if (shouldBroadcastUnauthorized && typeof window !== 'undefined') { diff --git a/src/api/config.ts b/src/api/config.ts index bfcf346..7e7d18b 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -1,12 +1,13 @@ export const API_CONFIG = { baseUrl: import.meta.env.VITE_API_BASE_URL || '', endpoints: { - login: '/auth/login', + loginStart: '/auth/login/start', + loginStatus: '/auth/login/status', + loginCancel: '/auth/login/cancel', + loginDebugCapture: '/auth/debug/capture', logout: '/auth/logout', session: '/auth/session', me: '/me', installedGames: '/library/installed', }, - turnstileSiteKey: - import.meta.env.VITE_TURNSTILE_SITE_KEY || '0x4AAAAAAB83Vz-GpH08brQi', } as const; diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 21f022e..7656943 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -1,7 +1,13 @@ import { apiClient } from './client'; import { API_CONFIG } from './config'; -import { buildLoginPayload } from '../auth/login-adapter'; -import type { AuthSession, InstalledGame, LoginCredentials, User } from '../types'; +import type { + AuthCaptureDebugResponse, + AuthSession, + InstalledGame, + LoginCaptureSessionStatus, + LoginCaptureStartResponse, + User, +} from '../types'; function extractSession(data: Record): AuthSession { return { @@ -10,10 +16,25 @@ function extractSession(data: Record): AuthSession { }; } -export async function login(credentials: LoginCredentials): Promise { - const payload = buildLoginPayload(credentials); - const { data } = await apiClient.post(API_CONFIG.endpoints.login, payload); - return extractSession(data); +export async function startLoginCapture(): Promise { + const { data } = await apiClient.post(API_CONFIG.endpoints.loginStart); + return data as LoginCaptureStartResponse; +} + +export async function getLoginCaptureStatus(id?: string): Promise { + const url = id ? `${API_CONFIG.endpoints.loginStatus}/${id}` : API_CONFIG.endpoints.loginStatus; + const { data } = await apiClient.get(url); + return data as LoginCaptureSessionStatus; +} + +export async function cancelLoginCapture(id?: string): Promise { + const { data } = await apiClient.post(API_CONFIG.endpoints.loginCancel, id ? { id } : {}); + return data as LoginCaptureSessionStatus; +} + +export async function getAuthCaptureDebug(): Promise { + const { data } = await apiClient.get(API_CONFIG.endpoints.loginDebugCapture); + return data as AuthCaptureDebugResponse; } export async function logout(): Promise { diff --git a/src/api/index.ts b/src/api/index.ts index e1186e7..74d5f87 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,10 @@ export { apiClient } from './client'; export { API_CONFIG } from './config'; export { - login, + startLoginCapture, + getLoginCaptureStatus, + cancelLoginCapture, + getAuthCaptureDebug, logout, getSession, getCurrentUser, diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index dafc1d1..db00c4f 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -4,7 +4,7 @@ import { useEffect, type ReactNode, } from 'react'; -import type { LoginCredentials, User } from '../types'; +import type { User } from '../types'; import * as api from '../api'; import { AuthContext } from './context'; import { clearLegacyAuthStorage } from './storage'; @@ -35,7 +35,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); }, []); - const bootstrapSession = useCallback(async () => { + const refreshSession = useCallback(async () => { + setState((current) => ({ ...current, isLoading: true })); clearLegacyAuthStorage(); try { @@ -48,8 +49,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [applySession]); useEffect(() => { - bootstrapSession(); - }, [bootstrapSession]); + void refreshSession(); + }, [refreshSession]); useEffect(() => { const handleUnauthorized = () => { @@ -63,18 +64,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; }, [applySession]); - const login = useCallback(async (credentials: LoginCredentials) => { - setState((current) => ({ ...current, isLoading: true })); - try { - clearLegacyAuthStorage(); - const session = await api.login(credentials); - applySession(session.user); - } catch (error) { - setState((current) => ({ ...current, isLoading: false })); - throw error; - } - }, [applySession]); - const logout = useCallback(async () => { try { await api.logout(); @@ -88,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { @@ -96,4 +85,3 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); } - diff --git a/src/auth/context.ts b/src/auth/context.ts index 8911f0f..eb4ed2b 100644 --- a/src/auth/context.ts +++ b/src/auth/context.ts @@ -1,5 +1,5 @@ import { createContext } from 'react'; -import type { LoginCredentials, User } from '../types'; +import type { User } from '../types'; interface AuthState { user: User | null; @@ -9,7 +9,7 @@ interface AuthState { } export interface AuthContextValue extends AuthState { - login: (credentials: LoginCredentials) => Promise; + refreshSession: () => Promise; logout: () => Promise; } diff --git a/src/auth/index.ts b/src/auth/index.ts index 22669af..8ffdf5d 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,4 +1,3 @@ export { AuthProvider } from './AuthContext'; export { useAuth } from './useAuth'; export { clearLegacyAuthStorage } from './storage'; -export { buildLoginPayload } from './login-adapter'; diff --git a/src/auth/login-adapter.ts b/src/auth/login-adapter.ts deleted file mode 100644 index fcf43b0..0000000 --- a/src/auth/login-adapter.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { LoginCredentials } from '../types'; - -export function buildLoginPayload( - credentials: LoginCredentials, -): Record { - const payload: Record = { - email: credentials.email.toLowerCase(), - password: credentials.password, - remember_me: credentials.remember_me ?? false, - }; - if (credentials['cf-turnstile-response']) { - payload['cf-turnstile-response'] = credentials['cf-turnstile-response']; - } - return payload; -} diff --git a/src/components/AuthCaptureDebugPanel.tsx b/src/components/AuthCaptureDebugPanel.tsx new file mode 100644 index 0000000..ee541e9 --- /dev/null +++ b/src/components/AuthCaptureDebugPanel.tsx @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Button, + Code, + CopyButton, + Group, + Loader, + Paper, + ScrollArea, + Stack, + Text, + Title, +} from '@mantine/core'; +import { IconAlertCircle, IconCheck, IconCopy, IconRefresh } from '@tabler/icons-react'; +import { AxiosError } from 'axios'; +import { getAuthCaptureDebug } from '../api'; +import type { ApiError, AuthCaptureDebugResponse } from '../types'; + +interface AuthCaptureDebugPanelProps { + title?: string; + compact?: boolean; +} + +export function AuthCaptureDebugPanel({ + title = 'Captured upstream auth evidence', + compact = false, +}: AuthCaptureDebugPanelProps) { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await getAuthCaptureDebug(); + setData(response); + } catch (err) { + const axiosErr = err as AxiosError; + setError(axiosErr.response?.data?.message || 'Debug capture is not available yet.'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + return ( + + + + + {title} + + Raw cookies, network payloads, and bridge session data captured by the backend-owned browser context. + + + + + + {isLoading && ( + + + Loading latest capture artifact… + + )} + + {!isLoading && error && ( + } color="yellow" variant="light"> + {error} + + )} + + {!isLoading && data && ( + + + Capture ID: {data.artifact.id} + Artifact: {data.artifactPath ?? 'in-memory only'} + + + {({ copied, copy }) => ( + + )} + + + {JSON.stringify(data, null, 2)} + + + )} + + + ); +} diff --git a/src/pages/LibraryPage.tsx b/src/pages/LibraryPage.tsx index 92a058f..2f551d1 100644 --- a/src/pages/LibraryPage.tsx +++ b/src/pages/LibraryPage.tsx @@ -23,6 +23,7 @@ import { IconCloudComputing, } from '@tabler/icons-react'; import { getInstalledGames } from '../api'; +import { AuthCaptureDebugPanel } from '../components/AuthCaptureDebugPanel'; import type { InstalledGame } from '../types'; type LoadState = 'loading' | 'success' | 'error'; @@ -122,6 +123,10 @@ export function LibraryPage() { ))} )} + + + + ); } diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index aea1f07..484c168 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,114 +1,167 @@ -import { useState, useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useLocation, Navigate } from 'react-router-dom'; import { - TextInput, - PasswordInput, + Alert, + Box, Button, + Center, + Code, + Group, + List, + Loader, Paper, - Title, - Text, Stack, - Box, - Center, - Alert, - Transition, - Checkbox, + Text, + ThemeIcon, + Title, } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { IconMail, IconLock, IconAlertCircle } from '@tabler/icons-react'; +import { + IconAlertCircle, + IconArrowRight, + IconCheck, + IconExternalLink, + IconPlayerStop, + IconRefresh, + IconSparkles, +} from '@tabler/icons-react'; import { AxiosError } from 'axios'; -import { Turnstile } from 'react-turnstile'; +import { + cancelLoginCapture, + getLoginCaptureStatus, + startLoginCapture, +} from '../api'; import { useAuth } from '../auth'; -import { API_CONFIG } from '../api/config'; -import type { ApiError } from '../types'; -import classes from './LoginPage.module.css'; +import { AuthCaptureDebugPanel } from '../components/AuthCaptureDebugPanel'; +import type { ApiError, LoginCaptureSessionStatus, LoginCaptureStatus } from '../types'; + +const POLL_INTERVAL_MS = 1500; +const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']); + +function describeStatus(status: LoginCaptureStatus): string { + switch (status) { + case 'starting': + return 'Launching a real browser window on the backend host.'; + case 'awaiting_user': + return 'Finish login in the Boosteroid window. Turnstile stays on Boosteroid.'; + case 'succeeded': + return 'Authenticated session captured. OpenStroid is establishing its own first-party session.'; + case 'failed': + return 'Capture failed before an authenticated upstream session was detected.'; + case 'cancelled': + return 'Capture was cancelled and the browser context was cleaned up.'; + case 'timed_out': + return 'Capture timed out before login completed.'; + default: + return 'Waiting for capture status.'; + } +} export function LoginPage() { - const { login, isAuthenticated, isBootstrapping, isLoading } = useAuth(); + const { refreshSession, isAuthenticated, isBootstrapping } = useAuth(); const navigate = useNavigate(); const location = useLocation(); + const [capture, setCapture] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); const [serverError, setServerError] = useState(null); - const [turnstileToken, setTurnstileToken] = useState(null); - const [turnstileError, setTurnstileError] = useState(null); - const [turnstileInstanceKey, setTurnstileInstanceKey] = useState(0); + const pollHandle = useRef(null); const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/library'; - const form = useForm({ - initialValues: { - email: '', - password: '', - remember_me: false, - }, - validate: { - email: (value) => { - if (!value.trim()) return 'Email is required'; - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Enter a valid email address'; - return null; - }, - password: (value) => { - if (!value) return 'Password is required'; - if (value.length < 8) return 'Password must be at least 8 characters'; - return null; - }, - }, - validateInputOnBlur: true, - }); - - const handleTurnstileVerify = useCallback((token: string) => { - setTurnstileToken(token); - setTurnstileError(null); + const stopPolling = useCallback(() => { + if (pollHandle.current !== null) { + window.clearTimeout(pollHandle.current); + pollHandle.current = null; + } }, []); - const handleTurnstileError = useCallback(() => { - setTurnstileToken(null); - setTurnstileError('Captcha verification failed. Please try again.'); - }, []); + const pollStatus = useCallback(async (captureId: string) => { + try { + const next = await getLoginCaptureStatus(captureId); + setCapture(next); + setServerError(null); - const handleTurnstileExpire = useCallback(() => { - setTurnstileToken(null); - }, []); + if (next.status === 'succeeded' && next.sessionEstablished) { + await refreshSession(); + navigate(from, { replace: true }); + return; + } - const handleSubmit = useCallback(async (values: { email: string; password: string; remember_me: boolean }) => { - if (!turnstileToken) { - setServerError('Please complete the captcha verification.'); - return; + if (!TERMINAL_STATUSES.has(next.status)) { + pollHandle.current = window.setTimeout(() => { + void pollStatus(captureId); + }, POLL_INTERVAL_MS); + } + } catch (err) { + const axiosErr = err as AxiosError; + setServerError(axiosErr.response?.data?.message || 'Failed to read login capture status.'); } + }, [from, navigate, refreshSession]); + + useEffect(() => { + return () => stopPolling(); + }, [stopPolling]); + const handleStart = useCallback(async () => { + stopPolling(); + setIsSubmitting(true); setServerError(null); try { - await login({ - email: values.email, - password: values.password, - remember_me: values.remember_me, - 'cf-turnstile-response': turnstileToken, - }); - navigate(from, { replace: true }); + const started = await startLoginCapture(); + const initialStatus = await getLoginCaptureStatus(started.id); + setCapture(initialStatus); + void pollStatus(started.id); } catch (err) { - setTurnstileToken(null); - setTurnstileInstanceKey((current) => current + 1); + const axiosErr = err as AxiosError; + const fallback = axiosErr.response?.status === 409 + ? 'A login capture is already running. Use refresh to follow it or cancel it first.' + : 'Could not start the Boosteroid browser login flow.'; + setServerError(axiosErr.response?.data?.message || fallback); + } finally { + setIsSubmitting(false); + } + }, [pollStatus, stopPolling]); + const handleCancel = useCallback(async () => { + if (!capture) return; + stopPolling(); + setIsSubmitting(true); + try { + const cancelled = await cancelLoginCapture(capture.id); + setCapture(cancelled); + } catch (err) { const axiosErr = err as AxiosError; - const status = axiosErr.response?.status; - const data = axiosErr.response?.data; - if (status === 422 || status === 403) { - const raw = data as Record | undefined; - const nested = raw?.error as Record | undefined; - const msg = - (raw?.message as string) || - (raw?.error_message as string) || - (nested?.message as string) || - 'We could not find those credentials.'; - setServerError(msg); - } else if (data?.message) { - setServerError(data.message); - } else if (axiosErr.message === 'Network Error') { - setServerError('Unable to reach the server. Check your connection.'); - } else { - setServerError('An unexpected error occurred. Please try again.'); + setServerError(axiosErr.response?.data?.message || 'Failed to cancel the active capture.'); + } finally { + setIsSubmitting(false); + } + }, [capture, stopPolling]); + + const handleRefresh = useCallback(async () => { + setServerError(null); + if (capture?.id) { + stopPolling(); + await pollStatus(capture.id); + return; + } + + try { + const latest = await getLoginCaptureStatus(); + setCapture(latest); + if (!TERMINAL_STATUSES.has(latest.status)) { + void pollStatus(latest.id); } + } catch (err) { + const axiosErr = err as AxiosError; + setServerError(axiosErr.response?.data?.message || 'No capture session is currently available.'); } - }, [from, login, navigate, turnstileToken]); + }, [capture?.id, pollStatus, stopPolling]); + + const statusTone = useMemo(() => { + if (!capture) return 'blue'; + if (capture.status === 'succeeded') return 'teal'; + if (capture.status === 'failed' || capture.status === 'timed_out' || capture.status === 'cancelled') return 'yellow'; + return 'blue'; + }, [capture]); if (isAuthenticated && !isBootstrapping) { return ; @@ -125,47 +178,8 @@ export function LoginPage() { justifyContent: 'center', }} > - - - - -
- + - - OS - + OS - - <Text - component="span" - inherit - variant="gradient" - gradient={{ from: 'brand.3', to: 'accent.4', deg: 135 }} - > + <Title order={1} ta="center" fw={800} style={{ fontSize: '2rem', letterSpacing: '-0.02em' }}> + <Text component="span" inherit variant="gradient" gradient={{ from: 'brand.3', to: 'accent.4', deg: 135 }}> OpenStroid </Text> - - Cloud gaming, open source - + Boosteroid login now runs in a real upstream browser window. -
- - - Sign in to your account - - - - {(styles) => ( - } - withCloseButton - onClose={() => { - setServerError(null); - setTurnstileError(null); - }} - radius="md" - > - {serverError || turnstileError} - - )} - - - } - size="md" - autoComplete="email" - disabled={isLoading} - classNames={{ input: classes.loginInput }} - {...form.getInputProps('email')} - /> - - } - size="md" - autoComplete="current-password" - disabled={isLoading} - classNames={{ input: classes.loginInput }} - {...form.getInputProps('password')} - /> + + + + Sign in with Boosteroid + + OpenStroid launches a visible browser on the backend host, waits for you to finish the real Boosteroid login and Turnstile challenge there, then captures the resulting upstream session into the existing OpenStroid cookie session. + + + + + + - + } + > + Credentials are entered only on Boosteroid’s own page. + The backend-owned browser context is the source of truth for upstream cookies and auth payloads. + Captured cookies, payloads, and request metadata are saved to disk and exposed through a gated debug endpoint. + - - - + {serverError && ( + } color="red" variant="light"> + {serverError} + + )} + + + - - + + + + + {capture ? describeStatus(capture.status) : 'Start a capture to open the browser window and begin manual login.'} + {capture && ( + <> + Capture ID: {capture.id} + Timeout: {new Date(capture.timeoutAt).toLocaleString()} + {capture.finalUrl && Final URL: {capture.finalUrl}} + {capture.errors.length > 0 && ( + {capture.errors[capture.errors.length - 1]} + )} + + )} + {capture && !TERMINAL_STATUSES.has(capture.status) && ( + + + Polling capture status every {POLL_INTERVAL_MS / 1000}s. + + )} + {capture?.status === 'succeeded' && ( + + )} + + +
- - OpenStroid uses a same-origin auth bridge. Browser sessions stay on OpenStroid, - while upstream tokens remain inside secure HttpOnly cookies. - +
diff --git a/src/types/index.ts b/src/types/index.ts index 9285376..808e3b0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,10 +1,3 @@ -export interface LoginCredentials { - email: string; - password: string; - 'cf-turnstile-response'?: string; - remember_me?: boolean; -} - export interface User { id: number; email: string; @@ -18,6 +11,92 @@ export interface AuthSession { user: User | null; } +export interface LoginCaptureStartResponse { + id: string; + status: LoginCaptureStatus; + timeoutAt: string; +} + +export type LoginCaptureStatus = + | 'starting' + | 'awaiting_user' + | 'succeeded' + | 'failed' + | 'cancelled' + | 'timed_out'; + +export interface LoginCaptureSessionStatus { + id: string; + status: LoginCaptureStatus; + startedAt: string; + updatedAt: string; + completedAt: string | null; + timeoutAt: string; + finalUrl: string | null; + errors: string[]; + eventCount: number; + user: User | null; + sessionEstablished: boolean; +} + +export interface CaptureEvent { + timestamp: string; + type: 'page' | 'request' | 'response' | 'note' | 'error'; + method?: string; + url?: string; + status?: number; + payload?: unknown; + headers?: Record; + cookieNames?: string[]; + message?: string; +} + +export interface StoredCookie { + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: string; +} + +export interface CaptureArtifact { + id: string; + status: LoginCaptureStatus; + startedAt: string; + updatedAt: string; + completedAt: string | null; + timeoutAt: string; + loginUrl: string; + finalUrl: string | null; + upstreamBaseUrl: string; + errors: string[]; + eventCount: number; + authCookies: Partial>; + allCookies: StoredCookie[]; + observedResponses: CaptureEvent[]; + userPayload: User | null; + bridgeSession: { + accessToken: string; + refreshToken: string; + userData?: unknown; + user?: User; + createdAt: number; + updatedAt: number; + } | null; +} + +export interface AuthCaptureDebugResponse { + artifact: CaptureArtifact; + artifactPath: string | null; + requestedBy: { + email: string | null; + updatedAt: number; + }; +} + export interface InstalledGame { id: number; name: string; From 22c66f55c90815de3966ed15ad17aacb58854eb9 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:55:07 +0000 Subject: [PATCH 2/6] fix(auth): preserve first-party dev proxy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .env.example | 4 +++- README.md | 3 ++- src/api/config.ts | 26 +++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 561be3f..2f6701e 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ -# Frontend: leave empty when the browser should use same-origin requests. +# Frontend: leave empty for local dev and same-origin deployments so requests stay on +# the OpenStroid origin (`/auth`, `/me`, `/library`) and Vite can proxy to the backend. # Set only when the frontend is deployed separately from the backend bridge. +# Never point this at `https://cloud.boosteroid.com` or any Boosteroid origin. # VITE_API_BASE_URL= # Backend bridge configuration. diff --git a/README.md b/README.md index df2875f..ab06f59 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The backend exposes normalized first-party endpoints: | Variable | Default | Description | |---|---|---| -| `VITE_API_BASE_URL` | *(empty)* | Frontend API origin. Leave empty for same-origin deployments. | +| `VITE_API_BASE_URL` | *(empty)* | Frontend API origin. Leave empty in local dev and same-origin deployments so the browser stays on first-party `/auth`, `/me`, and `/library` routes. Never set this to a Boosteroid origin. | | `SERVER_PORT` | `3001` | Backend bridge port. | | `UPSTREAM_BASE_URL` | `https://cloud.boosteroid.com` | Upstream Boosteroid base URL. | | `SESSION_SECRET` | `openstroid-development-session-secret` | Secret used to encrypt/authenticate the session cookie. Replace in production. | @@ -79,6 +79,7 @@ The backend exposes normalized first-party endpoints: - Build with `npm run build`. - Start the bridge with `npm run start`. - Serve the frontend and backend from the same origin when possible. +- In local dev, keep `VITE_API_BASE_URL` empty so Vite proxies first-party routes to the backend bridge. The frontend must never call `https://cloud.boosteroid.com` directly. - Set a strong `SESSION_SECRET` and keep `COOKIE_SECURE=true` in production. - If you deploy the frontend separately, set `VITE_API_BASE_URL` to the backend origin and `APP_ORIGIN` to the frontend origin. diff --git a/src/api/config.ts b/src/api/config.ts index 7e7d18b..ca5a222 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -1,5 +1,29 @@ +function normalizeApiBaseUrl(rawValue: string | undefined): string { + if (import.meta.env.DEV) { + return ''; + } + + const trimmed = rawValue?.trim(); + if (!trimmed) { + return ''; + } + + try { + const normalizedUrl = new URL(trimmed); + const hostname = normalizedUrl.hostname.toLowerCase(); + + if (hostname === 'boosteroid.com' || hostname.endsWith('.boosteroid.com')) { + return ''; + } + + return normalizedUrl.origin; + } catch { + return ''; + } +} + export const API_CONFIG = { - baseUrl: import.meta.env.VITE_API_BASE_URL || '', + baseUrl: normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL), endpoints: { loginStart: '/auth/login/start', loginStatus: '/auth/login/status', From f6a1902f5a1cf0c08a8dd2a193414c0266c46061 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:01:37 +0000 Subject: [PATCH 3/6] fix(auth): harden browser capture for turnstile Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .env.example | 4 ++- README.md | 17 +++++++++-- server/config.ts | 22 ++++++++++++-- server/lib/authCapture.ts | 64 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 2f6701e..7c12905 100644 --- a/.env.example +++ b/.env.example @@ -13,12 +13,14 @@ # COOKIE_SECURE=false # APP_ORIGIN=http://localhost:3000 # AUTH_CAPTURE_ARTIFACT_DIR=/absolute/path/to/OpenStroid/.runtime/auth-captures +# BROWSER_USER_DATA_DIR=/absolute/path/to/OpenStroid/.runtime/browser-profile # BROWSER_LOGIN_TIMEOUT_MS=300000 # BROWSER_LOGIN_POLL_INTERVAL_MS=1500 # BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS=30000 # BROWSER_HEADLESS=false -# BROWSER_CHANNEL=chromium +# BROWSER_CHANNEL=chrome # BROWSER_EXECUTABLE_PATH=/absolute/path/to/chrome-or-chromium +# BROWSER_LOCALE=en-US # BROWSER_LAUNCH_ARGS=--start-maximized # Optional: override the Vite dev proxy target if your backend runs elsewhere. diff --git a/README.md b/README.md index ab06f59..1fb6bbd 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,14 @@ The backend exposes normalized first-party endpoints: | `COOKIE_SECURE` | `false` in dev, `true` in production | Whether to mark the auth cookie as `Secure`. | | `APP_ORIGIN` | *(unset)* | Optional allowed browser origin when frontend and backend run on different origins. | | `AUTH_CAPTURE_ARTIFACT_DIR` | `/.runtime/auth-captures` | Directory where raw capture JSON artifacts are written. | +| `BROWSER_USER_DATA_DIR` | `/.runtime/browser-profile` | Persistent Chrome profile directory used for the backend-launched login browser. | | `BROWSER_LOGIN_TIMEOUT_MS` | `300000` | Maximum time allowed for manual upstream login before timing out. | | `BROWSER_LOGIN_POLL_INTERVAL_MS` | `1500` | Interval used while checking whether upstream auth cookies/tokens are ready. | | `BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS` | `30000` | Initial page navigation timeout for the launched browser. | -| `BROWSER_HEADLESS` | `false` | Whether to launch the backend browser headlessly. Visible mode is the intended default. | -| `BROWSER_CHANNEL` | *(unset)* | Optional browser channel passed to Playwright, such as `chromium` or `chrome`. | -| `BROWSER_EXECUTABLE_PATH` | *(unset)* | Optional explicit browser executable path. | +| `BROWSER_HEADLESS` | `false` | Whether to launch the backend browser headlessly. Visible mode is strongly recommended for Turnstile. | +| `BROWSER_CHANNEL` | `chrome` when no explicit executable is found | Preferred browser channel passed to Playwright. Use a real Chrome install when possible. | +| `BROWSER_EXECUTABLE_PATH` | auto-detects system Chrome/Chromium | Optional explicit browser executable path. | +| `BROWSER_LOCALE` | `en-US` | Locale for the persistent browser profile. | | `BROWSER_LAUNCH_ARGS` | *(unset)* | Comma-separated extra browser launch arguments. | | `BACKEND_PROXY_TARGET` | `http://localhost:3001` | Vite-only proxy target for local frontend development. | @@ -80,9 +82,18 @@ The backend exposes normalized first-party endpoints: - Start the bridge with `npm run start`. - Serve the frontend and backend from the same origin when possible. - In local dev, keep `VITE_API_BASE_URL` empty so Vite proxies first-party routes to the backend bridge. The frontend must never call `https://cloud.boosteroid.com` directly. +- For the manual Boosteroid login flow, prefer visible system Chrome with the persistent backend profile directory. Turnstile is more likely to reject fresh ephemeral automation profiles or headless Chromium. - Set a strong `SESSION_SECRET` and keep `COOKIE_SECURE=true` in production. - If you deploy the frontend separately, set `VITE_API_BASE_URL` to the backend origin and `APP_ORIGIN` to the frontend origin. +## Turnstile / browser-launch notes + +- The backend now launches a persistent browser profile instead of a throwaway ephemeral context. +- When available, OpenStroid prefers the installed system Chrome binary over bundled Playwright Chromium. +- The launch path removes Playwright's default `--enable-automation` flag, disables obvious automation blink features, keeps the browser visible by default, and preserves a realistic user profile between attempts. +- OpenStroid does not script the Boosteroid login form or Turnstile widget. The page is opened for manual interaction only; capture stays passive and relies on observed network/cookies after login completes. +- Even with these mitigations, anti-bot systems can still be environment-sensitive. Desktop sessions with a real display server and system Chrome are the recommended setup. + ## Project structure ```text diff --git a/server/config.ts b/server/config.ts index 1fa36a0..6e86ac4 100644 --- a/server/config.ts +++ b/server/config.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; function parseNumber(value: string | undefined, fallback: number): number { @@ -11,12 +12,25 @@ function parseBoolean(value: string | undefined, fallback: boolean): boolean { return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); } +function resolvePreferredBrowserExecutable(explicitPath: string | undefined): string | undefined { + const candidatePaths = [ + explicitPath, + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + '/usr/bin/chromium-browser', + '/usr/bin/chromium', + ].filter((value): value is string => Boolean(value && value.trim())); + + return candidatePaths.find((candidatePath) => fs.existsSync(candidatePath)); +} + const nodeEnv = process.env.NODE_ENV ?? 'development'; const configDir = import.meta.dirname; const buildServerSuffix = `${path.sep}build${path.sep}server${path.sep}server`; const projectRoot = configDir.endsWith(buildServerSuffix) ? path.resolve(configDir, '..', '..', '..') : path.resolve(configDir, '..'); +const browserExecutablePath = resolvePreferredBrowserExecutable(process.env.BROWSER_EXECUTABLE_PATH); export const serverConfig = { nodeEnv, @@ -31,6 +45,9 @@ export const serverConfig = { authCaptureArtifactDir: process.env.AUTH_CAPTURE_ARTIFACT_DIR ?? path.resolve(projectRoot, '.runtime', 'auth-captures'), + browserUserDataDir: + process.env.BROWSER_USER_DATA_DIR ?? + path.resolve(projectRoot, '.runtime', 'browser-profile'), browserLoginTimeoutMs: parseNumber(process.env.BROWSER_LOGIN_TIMEOUT_MS, 5 * 60 * 1000), browserLoginPollIntervalMs: parseNumber(process.env.BROWSER_LOGIN_POLL_INTERVAL_MS, 1500), browserLaunchNavigateTimeoutMs: parseNumber( @@ -38,8 +55,9 @@ export const serverConfig = { 30 * 1000, ), browserHeadless: parseBoolean(process.env.BROWSER_HEADLESS, false), - browserExecutablePath: process.env.BROWSER_EXECUTABLE_PATH, - browserChannel: process.env.BROWSER_CHANNEL, + browserExecutablePath, + browserChannel: process.env.BROWSER_CHANNEL ?? (browserExecutablePath ? undefined : 'chrome'), + browserLocale: process.env.BROWSER_LOCALE ?? 'en-US', browserLaunchArgs: (process.env.BROWSER_LAUNCH_ARGS ?? '') .split(',') .map((value) => value.trim()) diff --git a/server/lib/authCapture.ts b/server/lib/authCapture.ts index 9c90579..0221b49 100644 --- a/server/lib/authCapture.ts +++ b/server/lib/authCapture.ts @@ -20,6 +20,12 @@ const RELEVANT_PATH_PATTERNS = [ ]; const FINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled', 'timed_out'] as const); const MAX_CAPTURE_FILES = 25; +const DEFAULT_BROWSER_ARGS = [ + '--start-maximized', + '--window-size=1440,960', + '--disable-blink-features=AutomationControlled', + '--disable-features=IsolateOrigins,site-per-process', +]; type CaptureStatus = 'starting' | 'awaiting_user' | 'succeeded' | 'failed' | 'cancelled' | 'timed_out'; type CaptureTerminalStatus = 'succeeded' | 'failed' | 'cancelled' | 'timed_out'; @@ -118,6 +124,42 @@ async function ensureArtifactDir(): Promise { await fs.mkdir(serverConfig.authCaptureArtifactDir, { recursive: true }); } +async function ensureBrowserProfileDir(): Promise { + await fs.mkdir(serverConfig.browserUserDataDir, { recursive: true }); +} + +function dedupeArgs(...groups: string[][]): string[] { + return [...new Set(groups.flat().filter(Boolean))]; +} + +async function applyStealthDefaults(context: BrowserContext): Promise { + await context.addInitScript(` + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + configurable: true, + }); + + if (!('chrome' in window)) { + Object.defineProperty(window, 'chrome', { + value: { runtime: {} }, + configurable: true, + }); + } + + const originalQuery = navigator.permissions && navigator.permissions.query + ? navigator.permissions.query.bind(navigator.permissions) + : null; + + if (originalQuery) { + navigator.permissions.query = (parameters) => ( + parameters && parameters.name === 'notifications' + ? Promise.resolve({ state: Notification.permission }) + : originalQuery(parameters) + ); + } + `); +} + async function pruneArtifacts(): Promise { const entries = await fs.readdir(serverConfig.authCaptureArtifactDir, { withFileTypes: true }).catch(() => []); const files = await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json')).map(async (entry) => { @@ -411,14 +453,25 @@ class AuthCaptureManager { private async runCapture(capture: CaptureRuntime): Promise { try { await ensureArtifactDir(); - const browser = await chromium.launch({ + await ensureBrowserProfileDir(); + + const context = await chromium.launchPersistentContext(serverConfig.browserUserDataDir, { headless: serverConfig.browserHeadless, channel: serverConfig.browserChannel || undefined, executablePath: serverConfig.browserExecutablePath || undefined, - args: serverConfig.browserLaunchArgs, + locale: serverConfig.browserLocale, + timezoneId: 'UTC', + viewport: null, + ignoreHTTPSErrors: false, + args: dedupeArgs(DEFAULT_BROWSER_ARGS, serverConfig.browserLaunchArgs), + ignoreDefaultArgs: ['--enable-automation'], }); - const context = await browser.newContext({ viewport: { width: 1400, height: 920 } }); - const page = await context.newPage(); + await applyStealthDefaults(context); + + const existingPages = context.pages(); + const page = existingPages.at(-1) ?? await context.newPage(); + const browser = context.browser(); + capture.browser = browser; capture.context = context; capture.page = page; @@ -428,9 +481,10 @@ class AuthCaptureManager { this.pushEvent(capture, { timestamp: new Date().toISOString(), type: 'note', - message: 'Browser launched. Waiting for manual Boosteroid login.', + message: `Browser launched in persistent profile mode using ${serverConfig.browserExecutablePath ?? serverConfig.browserChannel ?? 'default browser'}. Waiting for manual Boosteroid login.`, }); + await page.bringToFront().catch(() => undefined); await page.goto(LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: serverConfig.browserLaunchNavigateTimeoutMs }); while (Date.now() < capture.timeoutAtMs) { From dc3589c1d87e2e201c2ae986d552f3f440865d1f Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:14:13 +0000 Subject: [PATCH 4/6] feat(auth): add chrome extension capture flow Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .env.example | 4 + README.md | 41 +- extension/openstroid-capture/background.js | 207 ++++++++ extension/openstroid-capture/content.js | 26 ++ extension/openstroid-capture/manifest.json | 40 ++ extension/openstroid-capture/page-hook.js | 102 ++++ extension/openstroid-capture/popup.html | 74 +++ extension/openstroid-capture/popup.js | 32 ++ server/index.ts | 45 +- server/lib/authCapture.ts | 520 ++++++++++++++------- src/api/endpoints.ts | 5 +- src/pages/LoginPage.tsx | 125 +++-- src/types/index.ts | 22 +- 13 files changed, 1021 insertions(+), 222 deletions(-) create mode 100644 extension/openstroid-capture/background.js create mode 100644 extension/openstroid-capture/content.js create mode 100644 extension/openstroid-capture/manifest.json create mode 100644 extension/openstroid-capture/page-hook.js create mode 100644 extension/openstroid-capture/popup.html create mode 100644 extension/openstroid-capture/popup.js diff --git a/.env.example b/.env.example index 7c12905..9eeb9a7 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,7 @@ # Optional: override the Vite dev proxy target if your backend runs elsewhere. # BACKEND_PROXY_TARGET=http://localhost:3001 + +# Chrome extension local dev: +# - Load unpacked extension from extension/openstroid-capture/ +# - In the extension popup, keep the backend URL set to http://localhost:3001 diff --git a/README.md b/README.md index 1fb6bbd..d6eadf3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenStroid -Open-source cloud gaming client. Built with React, TypeScript, Mantine, Vite, Express, and a backend-owned browser capture bridge for Boosteroid authentication. +Open-source cloud gaming client. Built with React, TypeScript, Mantine, Vite, Express, and a first-party auth bridge that captures Boosteroid login state from the user’s real Chrome profile via a Chrome extension. ## Quick start @@ -13,15 +13,16 @@ npm run dev - Frontend dev server: [http://localhost:3000](http://localhost:3000) - Backend auth bridge: [http://localhost:3001](http://localhost:3001) -`npm run dev` starts both processes. The browser talks only to first-party `/auth`, `/me`, and `/library` routes. The backend bridge owns the upstream Boosteroid session, launches a real browser window for manual upstream login, and proxies authenticated requests to `https://cloud.boosteroid.com`. +`npm run dev` starts both processes. The browser talks only to first-party `/auth`, `/me`, and `/library` routes. The backend bridge owns the upstream Boosteroid session state after capture and proxies authenticated requests to `https://cloud.boosteroid.com`. ## Auth bridge architecture OpenStroid is no longer a browser-direct or credential-forwarding Boosteroid client. -- The frontend starts a backend login capture instead of posting credentials to OpenStroid. -- The backend launches a visible Playwright-controlled browser window to the real Boosteroid login experience and waits for the user to complete Turnstile manually. -- Once Boosteroid sets authenticated upstream state, the backend captures raw cookies and observed auth/session payloads, validates the upstream session, and converts it into the existing encrypted first-party OpenStroid cookie session. +- The frontend starts a first-party capture session instead of posting credentials to OpenStroid. +- The primary capture path is the unpacked Chrome extension in `extension/openstroid-capture/`, which runs inside the user’s real Chrome profile while they log in normally on `boosteroid.com` / `cloud.boosteroid.com`. +- The extension captures relevant cookies plus observed auth/session response payloads and sends them back to the local OpenStroid backend. +- The backend validates the submitted upstream state, persists raw capture artifacts, and converts successful captures into the existing encrypted first-party OpenStroid cookie session. - The frontend sends session bootstrap, logout, and library requests to first-party endpoints on the OpenStroid origin. - The backend bridge talks to `https://cloud.boosteroid.com` for: - browser-observed `POST /api/v1/auth/login` @@ -34,6 +35,7 @@ OpenStroid is no longer a browser-direct or credential-forwarding Boosteroid cli - The browser no longer stores raw upstream tokens in `localStorage`. - Session bootstrap uses `GET /auth/session` instead of reading browser storage. - Upstream 401s are refreshed server-side with a shared refresh lock to avoid duplicate refresh races. +- A backend-owned browser fallback still exists, but it is secondary to the extension flow because Turnstile is more reliable in the user’s real browser profile. ## API surface @@ -45,6 +47,8 @@ The backend exposes normalized first-party endpoints: | `GET` | `/auth/login/status` | Read the latest capture status and establish the first-party OpenStroid session on success | | `GET` | `/auth/login/status/:id` | Read status for a specific capture session | | `POST` | `/auth/login/cancel` | Cancel the active capture and clean up browser resources | +| `GET` | `/auth/extension/active` | Extension-only route to read the currently active pending extension capture session | +| `POST` | `/auth/extension/capture` | Extension-only route to submit captured upstream cookies/payloads for ingestion | | `GET` | `/auth/debug/capture` | Return the latest raw upstream capture artifact, including cookies and payloads | | `POST` | `/auth/logout` | Clear first-party session and attempt upstream logout | | `GET` | `/auth/session` | Validate/refresh current session and return `{ authenticated, user }` | @@ -82,17 +86,26 @@ The backend exposes normalized first-party endpoints: - Start the bridge with `npm run start`. - Serve the frontend and backend from the same origin when possible. - In local dev, keep `VITE_API_BASE_URL` empty so Vite proxies first-party routes to the backend bridge. The frontend must never call `https://cloud.boosteroid.com` directly. -- For the manual Boosteroid login flow, prefer visible system Chrome with the persistent backend profile directory. Turnstile is more likely to reject fresh ephemeral automation profiles or headless Chromium. +- For local dev, load the unpacked extension from `extension/openstroid-capture/` into Chrome and keep its backend URL pointed at `http://localhost:3001`. - Set a strong `SESSION_SECRET` and keep `COOKIE_SECURE=true` in production. - If you deploy the frontend separately, set `VITE_API_BASE_URL` to the backend origin and `APP_ORIGIN` to the frontend origin. -## Turnstile / browser-launch notes +## Chrome extension setup (local dev) -- The backend now launches a persistent browser profile instead of a throwaway ephemeral context. -- When available, OpenStroid prefers the installed system Chrome binary over bundled Playwright Chromium. -- The launch path removes Playwright's default `--enable-automation` flag, disables obvious automation blink features, keeps the browser visible by default, and preserves a realistic user profile between attempts. -- OpenStroid does not script the Boosteroid login form or Turnstile widget. The page is opened for manual interaction only; capture stays passive and relies on observed network/cookies after login completes. -- Even with these mitigations, anti-bot systems can still be environment-sensitive. Desktop sessions with a real display server and system Chrome are the recommended setup. +1. Open `chrome://extensions`. +2. Enable Developer Mode. +3. Choose **Load unpacked**. +4. Select `extension/openstroid-capture/` from this repo. +5. Open the extension popup and confirm the backend URL is `http://localhost:3001`. +6. In OpenStroid, click **Start extension capture** and then log in on Boosteroid in the same Chrome profile. + +## Turnstile / capture notes + +- The extension flow is primary because it runs in the user’s real Chrome profile rather than an automated backend browser context. +- The extension can read relevant Boosteroid cookies through Chrome’s cookie APIs, including HttpOnly cookies when host permissions are granted. +- Response metadata is captured through `webRequest`, while JSON response payloads are captured from page `fetch`/XHR instrumentation on Boosteroid pages. +- OpenStroid does not automate the login form or Turnstile challenge. The extension only observes requests, responses, cookies, and page visits after the user interacts normally. +- The backend-owned browser fallback remains available as a secondary option for environments where the extension cannot be used. ## Project structure @@ -112,11 +125,13 @@ src/ ├── pages/ # Route-level page components ├── theme/ # Mantine theme customization └── types/ # Shared TypeScript interfaces +extension/ +└── openstroid-capture/ # Unpacked Chrome extension for real-browser Boosteroid capture ``` ## Current features (auth bridge refactor) -- **Manual upstream login capture** — the user completes the real Boosteroid + Turnstile login in a backend-launched browser window +- **Chrome extension capture** — the user completes the real Boosteroid + Turnstile login in their own Chrome profile while the extension captures upstream state - **Server-managed session** — session bootstrap checks `/auth/session` and keeps upstream tokens out of browser JavaScript - **Debug evidence capture** — raw upstream cookies, payloads, and request metadata are inspectable from the UI and saved to `.runtime/auth-captures` - **My Games library** — installed games loaded through first-party backend routes with existing loading, empty, and error states diff --git a/extension/openstroid-capture/background.js b/extension/openstroid-capture/background.js new file mode 100644 index 0000000..ffe704b --- /dev/null +++ b/extension/openstroid-capture/background.js @@ -0,0 +1,207 @@ +const DEFAULT_BACKEND_BASE_URL = 'http://localhost:3001'; +const AUTH_COOKIE_NAMES = ['access_token', 'refresh_token', 'boosteroid_auth', 'qr_auth_code']; +const RELEVANT_PATH_PATTERNS = [ + '/api/v1/auth/login', + '/api/v1/auth/refresh-token', + '/api/v2/auth/logout', + '/api/v1/user', + '/api/v1/boostore/applications/installed', + '/auth', + '/login', + '/session', +]; +const MAX_EVENTS = 120; + +let observedResponses = []; +let lastSubmittedCaptureId = null; +let submissionInFlight = false; + +function isRelevantUrl(url) { + return RELEVANT_PATH_PATTERNS.some((pattern) => url.includes(pattern)); +} + +function pushObservedEvent(event) { + observedResponses.push(event); + if (observedResponses.length > MAX_EVENTS) { + observedResponses = observedResponses.slice(-MAX_EVENTS); + } +} + +async function getBackendBaseUrl() { + const stored = await chrome.storage.local.get(['backendBaseUrl']); + return stored.backendBaseUrl || DEFAULT_BACKEND_BASE_URL; +} + +async function getActiveCapture() { + const backendBaseUrl = await getBackendBaseUrl(); + const response = await fetch(`${backendBaseUrl}/auth/extension/active`); + if (!response.ok) { + return null; + } + + const data = await response.json(); + return { backendBaseUrl, data }; +} + +async function collectCookiesForDomain(domain) { + return chrome.cookies.getAll({ domain }).then((cookies) => cookies.map((cookie) => ({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + expires: typeof cookie.expirationDate === 'number' ? cookie.expirationDate : -1, + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite || 'unspecified', + }))); +} + +async function collectRelevantCookies() { + const cookies = [ + ...(await collectCookiesForDomain('boosteroid.com')), + ...(await collectCookiesForDomain('cloud.boosteroid.com')), + ]; + + const byKey = new Map(); + for (const cookie of cookies) { + byKey.set(`${cookie.domain}:${cookie.path}:${cookie.name}`, cookie); + } + + return [...byKey.values()]; +} + +async function submitCapture(reason) { + if (submissionInFlight) { + return; + } + + submissionInFlight = true; + try { + const active = await getActiveCapture(); + if (!active) { + return; + } + + const { backendBaseUrl, data } = active; + if (lastSubmittedCaptureId === data.id) { + return; + } + + const allCookies = await collectRelevantCookies(); + const authCookiePresent = allCookies.some((cookie) => AUTH_COOKIE_NAMES.includes(cookie.name)); + const sawRelevantAuthPayload = observedResponses.some((event) => + event.url && (event.url.includes('/api/v1/auth/login') || event.url.includes('/api/v1/auth/refresh-token')), + ); + + if (!authCookiePresent && !sawRelevantAuthPayload) { + return; + } + + const payload = { + id: data.id, + ingestToken: data.ingestToken, + finalUrl: observedResponses.at(-1)?.url || data.loginUrl, + allCookies, + observedResponses, + extensionMetadata: { + reason, + backendBaseUrl, + extensionVersion: chrome.runtime.getManifest().version, + userAgent: navigator.userAgent, + }, + }; + + const response = await fetch(`${backendBaseUrl}/auth/extension/capture`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + lastSubmittedCaptureId = data.id; + } + } catch (error) { + console.error('OpenStroid extension capture failed', error); + } finally { + submissionInFlight = false; + } +} + +chrome.runtime.onInstalled.addListener(async () => { + const current = await chrome.storage.local.get(['backendBaseUrl']); + if (!current.backendBaseUrl) { + await chrome.storage.local.set({ backendBaseUrl: DEFAULT_BACKEND_BASE_URL }); + } +}); + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type === 'openstroid:network-event' && message.event?.url && isRelevantUrl(message.event.url)) { + pushObservedEvent(message.event); + void submitCapture('page-network-event'); + sendResponse({ ok: true }); + return true; + } + + if (message?.type === 'openstroid:page-visit' && message.url) { + pushObservedEvent({ + timestamp: new Date().toISOString(), + type: 'page', + source: 'extension', + url: message.url, + message: 'Boosteroid page visited in real browser profile.', + }); + void submitCapture('page-visit'); + sendResponse({ ok: true }); + return true; + } + + if (message?.type === 'openstroid:get-state') { + void getBackendBaseUrl().then((backendBaseUrl) => { + sendResponse({ + backendBaseUrl, + observedEventCount: observedResponses.length, + lastSubmittedCaptureId, + }); + }); + return true; + } + + if (message?.type === 'openstroid:set-backend-base-url' && typeof message.backendBaseUrl === 'string') { + void chrome.storage.local.set({ backendBaseUrl: message.backendBaseUrl.trim() || DEFAULT_BACKEND_BASE_URL }).then(() => { + sendResponse({ ok: true }); + }); + return true; + } + + return false; +}); + +chrome.webRequest.onCompleted.addListener( + (details) => { + if (!isRelevantUrl(details.url)) { + return; + } + + pushObservedEvent({ + timestamp: new Date().toISOString(), + type: 'response', + source: 'extension', + method: details.method, + url: details.url, + status: details.statusCode, + message: 'Observed relevant response metadata via webRequest.', + }); + void submitCapture('webrequest-response'); + }, + { urls: ['https://boosteroid.com/*', 'https://*.boosteroid.com/*'] }, +); + +chrome.cookies.onChanged.addListener((changeInfo) => { + if (!changeInfo.cookie || !AUTH_COOKIE_NAMES.includes(changeInfo.cookie.name)) { + return; + } + + void submitCapture(`cookie:${changeInfo.cookie.name}`); +}); diff --git a/extension/openstroid-capture/content.js b/extension/openstroid-capture/content.js new file mode 100644 index 0000000..b50eea4 --- /dev/null +++ b/extension/openstroid-capture/content.js @@ -0,0 +1,26 @@ +(function bootstrapOpenStroidCapture() { + const script = document.createElement('script'); + script.src = chrome.runtime.getURL('page-hook.js'); + script.async = false; + (document.head || document.documentElement).appendChild(script); + script.remove(); + + function notifyPageVisit() { + chrome.runtime.sendMessage({ + type: 'openstroid:page-visit', + url: window.location.href, + }); + } + + window.addEventListener('openstroid:network-event', (event) => { + const detail = event.detail; + if (!detail) return; + chrome.runtime.sendMessage({ + type: 'openstroid:network-event', + event: detail, + }); + }); + + notifyPageVisit(); + window.addEventListener('load', notifyPageVisit, { once: true }); +})(); diff --git a/extension/openstroid-capture/manifest.json b/extension/openstroid-capture/manifest.json new file mode 100644 index 0000000..ee56e49 --- /dev/null +++ b/extension/openstroid-capture/manifest.json @@ -0,0 +1,40 @@ +{ + "manifest_version": 3, + "name": "OpenStroid Boosteroid Capture", + "description": "Captures Boosteroid auth/session evidence from a real Chrome session for local OpenStroid development.", + "version": "0.1.0", + "permissions": ["cookies", "storage", "webRequest", "tabs"], + "host_permissions": [ + "https://boosteroid.com/*", + "https://*.boosteroid.com/*", + "http://localhost:3001/*", + "http://127.0.0.1:3001/*" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_title": "OpenStroid Capture", + "default_popup": "popup.html" + }, + "content_scripts": [ + { + "matches": [ + "https://boosteroid.com/*", + "https://*.boosteroid.com/*" + ], + "js": ["content.js"], + "run_at": "document_start" + } + ], + "web_accessible_resources": [ + { + "resources": ["page-hook.js"], + "matches": [ + "https://boosteroid.com/*", + "https://*.boosteroid.com/*" + ] + } + ] +} diff --git a/extension/openstroid-capture/page-hook.js b/extension/openstroid-capture/page-hook.js new file mode 100644 index 0000000..3285265 --- /dev/null +++ b/extension/openstroid-capture/page-hook.js @@ -0,0 +1,102 @@ +(() => { + const RELEVANT_PATH_PATTERNS = [ + '/api/v1/auth/login', + '/api/v1/auth/refresh-token', + '/api/v2/auth/logout', + '/api/v1/user', + '/api/v1/boostore/applications/installed', + '/auth', + '/login', + '/session', + ]; + + function isRelevantUrl(url) { + return RELEVANT_PATH_PATTERNS.some((pattern) => url.includes(pattern)); + } + + function dispatchNetworkEvent(event) { + window.dispatchEvent(new CustomEvent('openstroid:network-event', { detail: event })); + } + + function safeParseJson(text) { + try { + return JSON.parse(text); + } catch { + return null; + } + } + + const originalFetch = window.fetch; + window.fetch = async (...args) => { + const response = await originalFetch(...args); + try { + const request = args[0]; + const method = args[1]?.method || (request && typeof request === 'object' && 'method' in request ? request.method : 'GET'); + const url = typeof request === 'string' ? request : request?.url || response.url; + if (url && isRelevantUrl(url)) { + const cloned = response.clone(); + const contentType = cloned.headers.get('content-type') || ''; + let payload = null; + if (contentType.includes('application/json')) { + payload = await cloned.json().catch(() => null); + } + dispatchNetworkEvent({ + timestamp: new Date().toISOString(), + type: 'response', + source: 'extension', + method, + url, + status: response.status, + headers: { + 'content-type': contentType, + }, + payload, + message: 'Observed relevant fetch response from page context.', + }); + } + } catch {} + return response; + }; + + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) { + this.__openstroidMethod = method; + this.__openstroidUrl = url; + return originalOpen.call(this, method, url, ...rest); + }; + + XMLHttpRequest.prototype.send = function patchedSend(body) { + this.addEventListener('loadend', () => { + try { + const url = this.__openstroidUrl || this.responseURL; + if (!url || !isRelevantUrl(url)) { + return; + } + + const contentType = this.getResponseHeader('content-type') || ''; + let payload = null; + if (contentType.includes('application/json') && typeof this.responseText === 'string') { + payload = safeParseJson(this.responseText); + } + + dispatchNetworkEvent({ + timestamp: new Date().toISOString(), + type: 'response', + source: 'extension', + method: this.__openstroidMethod || 'GET', + url, + status: this.status, + headers: { + 'content-type': contentType, + }, + payload, + message: 'Observed relevant XHR response from page context.', + }); + } catch {} + }, { once: true }); + + return originalSend.call(this, body); + }; +})(); diff --git a/extension/openstroid-capture/popup.html b/extension/openstroid-capture/popup.html new file mode 100644 index 0000000..bccb3bb --- /dev/null +++ b/extension/openstroid-capture/popup.html @@ -0,0 +1,74 @@ + + + + + OpenStroid Capture + + + +

OpenStroid Capture

+ + +
+ + +
+
Loading…
+ + + diff --git a/extension/openstroid-capture/popup.js b/extension/openstroid-capture/popup.js new file mode 100644 index 0000000..36ec17c --- /dev/null +++ b/extension/openstroid-capture/popup.js @@ -0,0 +1,32 @@ +const backendInput = document.getElementById('backendBaseUrl'); +const statusElement = document.getElementById('status'); +const saveButton = document.getElementById('save'); +const openButton = document.getElementById('open'); + +function setStatus(state) { + statusElement.textContent = JSON.stringify(state, null, 2); +} + +function refreshState() { + chrome.runtime.sendMessage({ type: 'openstroid:get-state' }, (response) => { + if (!response) { + setStatus({ error: 'Extension state unavailable.' }); + return; + } + backendInput.value = response.backendBaseUrl; + setStatus(response); + }); +} + +saveButton.addEventListener('click', () => { + chrome.runtime.sendMessage( + { type: 'openstroid:set-backend-base-url', backendBaseUrl: backendInput.value }, + () => refreshState(), + ); +}); + +openButton.addEventListener('click', () => { + chrome.tabs.create({ url: 'https://boosteroid.com/' }); +}); + +refreshState(); diff --git a/server/index.ts b/server/index.ts index cadf6ea..3ab11de 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import express, { type NextFunction, type Request, type Response } from 'express'; import cookieParser from 'cookie-parser'; import { serverConfig } from './config.js'; -import { authCaptureManager } from './lib/authCapture.js'; +import { authCaptureManager, type CaptureMethod } from './lib/authCapture.js'; import { clearSession, createSession, readSession, writeSession } from './lib/session.js'; import { getInstalledGamesUpstream, @@ -16,14 +16,19 @@ import { const app = express(); app.set('trust proxy', 1); -app.use(express.json()); +app.use(express.json({ limit: '2mb' })); app.use(cookieParser()); app.use((req, res, next) => { - if (serverConfig.appOrigin) { + const origin = req.header('origin'); + if (origin?.startsWith('chrome-extension://')) { + res.header('Access-Control-Allow-Origin', origin); + res.header('Vary', 'Origin'); + } else if (serverConfig.appOrigin) { res.header('Access-Control-Allow-Origin', serverConfig.appOrigin); res.header('Vary', 'Origin'); } + res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Headers', 'Content-Type'); res.header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); @@ -81,10 +86,12 @@ function sendCaptureStatus(req: Request, res: Response, captureId?: string) { updatedAt: capture.updatedAt, completedAt: capture.completedAt, timeoutAt: capture.timeoutAt, + loginUrl: capture.loginUrl, finalUrl: capture.finalUrl, errors: capture.errors, eventCount: capture.eventCount, user: capture.userPayload, + captureMethod: capture.captureMethod, sessionEstablished, }); } @@ -93,9 +100,11 @@ app.get('/health', (_req, res) => { res.json({ ok: true }); }); -app.post('/auth/login/start', async (_req, res, next) => { +app.post('/auth/login/start', async (req, res, next) => { try { - const capture = await authCaptureManager.start(); + const requestedMethod = req.body?.method; + const method: CaptureMethod = requestedMethod === 'browser' ? 'browser' : 'extension'; + const capture = await authCaptureManager.start(method); clearSession(res); res.status(202).json(capture); } catch (error) { @@ -128,10 +137,12 @@ app.post('/auth/login/cancel', async (req, res, next) => { updatedAt: capture.updatedAt, completedAt: capture.completedAt, timeoutAt: capture.timeoutAt, + loginUrl: capture.loginUrl, finalUrl: capture.finalUrl, errors: capture.errors, eventCount: capture.eventCount, user: capture.userPayload, + captureMethod: capture.captureMethod, sessionEstablished: false, }); } catch (error) { @@ -139,6 +150,30 @@ app.post('/auth/login/cancel', async (req, res, next) => { } }); +app.get('/auth/extension/active', (_req, res) => { + const active = authCaptureManager.getActiveExtensionSession(); + if (!active) { + res.status(404).json({ message: 'No active extension capture session.' }); + return; + } + + res.json(active); +}); + +app.post('/auth/extension/capture', async (req, res, next) => { + try { + const artifact = await authCaptureManager.ingestExtensionCapture(req.body); + res.status(202).json({ + id: artifact.id, + status: artifact.status, + captureMethod: artifact.captureMethod, + completedAt: artifact.completedAt, + }); + } catch (error) { + next(error); + } +}); + app.post('/auth/logout', async (req, res) => { const session = readSession(req); clearSession(res); diff --git a/server/lib/authCapture.ts b/server/lib/authCapture.ts index 0221b49..571cca7 100644 --- a/server/lib/authCapture.ts +++ b/server/lib/authCapture.ts @@ -19,16 +19,17 @@ const RELEVANT_PATH_PATTERNS = [ '/session', ]; const FINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled', 'timed_out'] as const); -const MAX_CAPTURE_FILES = 25; const DEFAULT_BROWSER_ARGS = [ '--start-maximized', '--window-size=1440,960', '--disable-blink-features=AutomationControlled', '--disable-features=IsolateOrigins,site-per-process', ]; +const MAX_CAPTURE_FILES = 25; -type CaptureStatus = 'starting' | 'awaiting_user' | 'succeeded' | 'failed' | 'cancelled' | 'timed_out'; type CaptureTerminalStatus = 'succeeded' | 'failed' | 'cancelled' | 'timed_out'; +export type CaptureStatus = 'starting' | 'awaiting_user' | 'succeeded' | 'failed' | 'cancelled' | 'timed_out'; +export type CaptureMethod = 'extension' | 'browser'; export interface CaptureEvent { timestamp: string; @@ -40,6 +41,7 @@ export interface CaptureEvent { headers?: Record; cookieNames?: string[]; message?: string; + source?: 'browser' | 'extension'; } export interface StoredCookie { @@ -55,6 +57,7 @@ export interface StoredCookie { export interface CaptureArtifact { id: string; + captureMethod: CaptureMethod; status: CaptureStatus; startedAt: string; updatedAt: string; @@ -70,10 +73,38 @@ export interface CaptureArtifact { observedResponses: CaptureEvent[]; userPayload: Record | null; bridgeSession: BridgeSession | null; + ingestSource?: 'browser' | 'extension'; + extensionMetadata?: Record; +} + +export interface CaptureStartResult { + id: string; + status: CaptureStatus; + timeoutAt: string; + captureMethod: CaptureMethod; + loginUrl: string; +} + +export interface ExtensionActiveCapture { + id: string; + ingestToken: string; + timeoutAt: string; + loginUrl: string; +} + +export interface ExtensionCaptureSubmission { + id: string; + ingestToken: string; + finalUrl?: string | null; + observedResponses?: CaptureEvent[]; + allCookies?: StoredCookie[]; + extensionMetadata?: Record; } interface CaptureRuntime { id: string; + method: CaptureMethod; + ingestToken: string; status: CaptureStatus; startedAtMs: number; updatedAtMs: number; @@ -91,6 +122,10 @@ interface CaptureRuntime { waitPromise: Promise | null; } +function isTerminalStatus(status: CaptureStatus): boolean { + return FINAL_STATUSES.has(status as CaptureTerminalStatus); +} + function isRelevantUrl(url: string): boolean { return RELEVANT_PATH_PATTERNS.some((pattern) => url.includes(pattern)); } @@ -120,6 +155,72 @@ function serializeCookie(cookie: Cookie): StoredCookie { }; } +function normalizeCookie(cookie: Partial & Pick): StoredCookie { + return { + name: cookie.name, + value: cookie.value, + domain: cookie.domain ?? '', + path: cookie.path ?? '/', + expires: typeof cookie.expires === 'number' ? cookie.expires : -1, + httpOnly: Boolean(cookie.httpOnly), + secure: Boolean(cookie.secure), + sameSite: cookie.sameSite ?? 'Lax', + }; +} + +function dedupeArgs(...groups: string[][]): string[] { + return [...new Set(groups.flat().filter(Boolean))]; +} + +function pickAuthCookies(cookies: StoredCookie[]): Partial> { + return Object.fromEntries( + AUTH_COOKIE_NAMES.map((name) => { + const found = cookies.find((cookie) => cookie.name === name); + return [name, found]; + }).filter((entry) => entry[1]), + ) as Partial>; +} + +function extractTokensFromCookies(cookies: StoredCookie[]): { accessToken: string | null; refreshToken: string | null } { + return { + accessToken: cookies.find((cookie) => cookie.name === 'access_token')?.value ?? null, + refreshToken: cookies.find((cookie) => cookie.name === 'refresh_token')?.value ?? null, + }; +} + +function extractTokensFromPayload(payload: unknown): { accessToken: string | null; refreshToken: string | null; userData?: unknown } { + const envelope = unwrapRecord(payload); + return { + accessToken: typeof envelope.access_token === 'string' && envelope.access_token ? envelope.access_token : null, + refreshToken: typeof envelope.refresh_token === 'string' && envelope.refresh_token ? envelope.refresh_token : null, + userData: envelope.user_data, + }; +} + +function extractTokensFromEvents(events: CaptureEvent[]): { accessToken: string | null; refreshToken: string | null; userData?: unknown } { + for (const event of events) { + if (event.type !== 'response' || !event.payload || !event.url || !event.url.includes('/api/v1/auth/login')) { + continue; + } + const extracted = extractTokensFromPayload(event.payload); + if (extracted.accessToken && extracted.refreshToken) { + return extracted; + } + } + + for (const event of events) { + if (event.type !== 'response' || !event.payload || !event.url || !event.url.includes('/api/v1/auth/refresh-token')) { + continue; + } + const extracted = extractTokensFromPayload(event.payload); + if (extracted.accessToken && extracted.refreshToken) { + return extracted; + } + } + + return { accessToken: null, refreshToken: null }; +} + async function ensureArtifactDir(): Promise { await fs.mkdir(serverConfig.authCaptureArtifactDir, { recursive: true }); } @@ -128,8 +229,23 @@ async function ensureBrowserProfileDir(): Promise { await fs.mkdir(serverConfig.browserUserDataDir, { recursive: true }); } -function dedupeArgs(...groups: string[][]): string[] { - return [...new Set(groups.flat().filter(Boolean))]; +async function pruneArtifacts(): Promise { + const entries = await fs.readdir(serverConfig.authCaptureArtifactDir, { withFileTypes: true }).catch(() => []); + const files = await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json')).map(async (entry) => { + const filePath = path.join(serverConfig.authCaptureArtifactDir, entry.name); + const stat = await fs.stat(filePath); + return { filePath, mtimeMs: stat.mtimeMs }; + })); + files.sort((a, b) => b.mtimeMs - a.mtimeMs); + await Promise.all(files.slice(MAX_CAPTURE_FILES).map((file) => fs.unlink(file.filePath).catch(() => undefined))); +} + +async function persistArtifact(artifact: CaptureArtifact): Promise { + await ensureArtifactDir(); + const filePath = path.join(serverConfig.authCaptureArtifactDir, `${artifact.startedAt.replace(/[:.]/g, '-')}-${artifact.id}.json`); + await fs.writeFile(filePath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); + await pruneArtifacts(); + return filePath; } async function applyStealthDefaults(context: BrowserContext): Promise { @@ -160,25 +276,6 @@ async function applyStealthDefaults(context: BrowserContext): Promise { `); } -async function pruneArtifacts(): Promise { - const entries = await fs.readdir(serverConfig.authCaptureArtifactDir, { withFileTypes: true }).catch(() => []); - const files = await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith('.json')).map(async (entry) => { - const filePath = path.join(serverConfig.authCaptureArtifactDir, entry.name); - const stat = await fs.stat(filePath); - return { filePath, mtimeMs: stat.mtimeMs }; - })); - files.sort((a, b) => b.mtimeMs - a.mtimeMs); - await Promise.all(files.slice(MAX_CAPTURE_FILES).map((file) => fs.unlink(file.filePath).catch(() => undefined))); -} - -async function persistArtifact(artifact: CaptureArtifact): Promise { - await ensureArtifactDir(); - const filePath = path.join(serverConfig.authCaptureArtifactDir, `${artifact.startedAt.replace(/[:.]/g, '-')}-${artifact.id}.json`); - await fs.writeFile(filePath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); - await pruneArtifacts(); - return filePath; -} - class AuthCaptureManager { private active: CaptureRuntime | null = null; private latestArtifact: CaptureArtifact | null = null; @@ -188,19 +285,21 @@ class AuthCaptureManager { void this.restoreLatestArtifact().catch(() => undefined); } - async start(): Promise<{ id: string; status: CaptureStatus; timeoutAt: string }> { + async start(method: CaptureMethod = 'extension'): Promise { this.cleanupFinishedActive(); - if (this.active && !FINAL_STATUSES.has(this.active.status as CaptureTerminalStatus)) { + if (this.active && !isTerminalStatus(this.active.status)) { const error = new Error('A login capture is already in progress.'); (error as Error & { status?: number; details?: unknown }).status = 409; - (error as Error & { status?: number; details?: unknown }).details = { id: this.active.id, status: this.active.status }; + (error as Error & { status?: number; details?: unknown }).details = { id: this.active.id, status: this.active.status, captureMethod: this.active.method }; throw error; } const now = Date.now(); const capture: CaptureRuntime = { id: randomUUID(), - status: 'starting', + method, + ingestToken: randomUUID(), + status: method === 'extension' ? 'awaiting_user' : 'starting', startedAtMs: now, updatedAtMs: now, timeoutAtMs: now + serverConfig.browserLoginTimeoutMs, @@ -218,8 +317,26 @@ class AuthCaptureManager { }; this.active = capture; - capture.waitPromise = this.runCapture(capture); - return { id: capture.id, status: capture.status, timeoutAt: new Date(capture.timeoutAtMs).toISOString() }; + this.pushEvent(capture, { + timestamp: new Date().toISOString(), + type: 'note', + source: method, + message: method === 'extension' + ? 'Waiting for Chrome extension capture from a real user browser session.' + : 'Launching backend browser capture fallback.', + }); + + if (method === 'browser') { + capture.waitPromise = this.runBrowserCapture(capture); + } + + return { + id: capture.id, + status: capture.status, + timeoutAt: new Date(capture.timeoutAtMs).toISOString(), + captureMethod: capture.method, + loginUrl: LOGIN_URL, + }; } async cancel(id?: string): Promise { @@ -227,13 +344,14 @@ class AuthCaptureManager { if (!capture) return null; if (id && capture.id !== id) return null; capture.cancelled = true; - if (!FINAL_STATUSES.has(capture.status as CaptureTerminalStatus)) { + if (!isTerminalStatus(capture.status)) { await this.finalize(capture, 'cancelled', 'Capture cancelled by user.'); } return capture.artifact; } getStatus(id?: string): CaptureArtifact | null { + this.expireIfTimedOut(this.active); const current = this.active; if (current && (!id || current.id === id)) { return this.toArtifact(current); @@ -248,6 +366,96 @@ class AuthCaptureManager { return { artifact: this.latestArtifact, path: this.latestArtifactPath }; } + getActiveExtensionSession(): ExtensionActiveCapture | null { + this.expireIfTimedOut(this.active); + const capture = this.active; + if (!capture || capture.method !== 'extension' || isTerminalStatus(capture.status)) { + return null; + } + + return { + id: capture.id, + ingestToken: capture.ingestToken, + timeoutAt: new Date(capture.timeoutAtMs).toISOString(), + loginUrl: LOGIN_URL, + }; + } + + async ingestExtensionCapture(submission: ExtensionCaptureSubmission): Promise { + this.expireIfTimedOut(this.active); + const capture = this.active; + if (!capture || capture.method !== 'extension' || capture.id !== submission.id) { + const error = new Error('No matching extension capture session found.'); + (error as Error & { status?: number }).status = 404; + throw error; + } + + if (capture.ingestToken !== submission.ingestToken) { + const error = new Error('Invalid extension capture token.'); + (error as Error & { status?: number }).status = 403; + throw error; + } + + const allCookies = Array.isArray(submission.allCookies) + ? submission.allCookies.map((cookie) => normalizeCookie(cookie)) + : []; + const observedResponses = Array.isArray(submission.observedResponses) + ? submission.observedResponses + .filter((event) => event && typeof event === 'object') + .map((event): CaptureEvent => ({ + ...event, + source: 'extension', + })) + : []; + + capture.events = observedResponses; + capture.updatedAtMs = Date.now(); + + const cookieTokens = extractTokensFromCookies(allCookies); + const payloadTokens = extractTokensFromEvents(observedResponses); + const accessToken = cookieTokens.accessToken ?? payloadTokens.accessToken; + const refreshToken = cookieTokens.refreshToken ?? payloadTokens.refreshToken; + + if (!accessToken || !refreshToken) { + const artifact = this.buildSubmittedArtifact(capture, submission, allCookies, observedResponses); + await this.finalize(capture, 'failed', 'Extension capture did not include usable upstream access and refresh tokens.', artifact); + return capture.artifact as CaptureArtifact; + } + + const user = await getUpstreamUser(accessToken); + capture.bridgeSession = createSession({ + accessToken, + refreshToken, + userData: payloadTokens.userData, + user, + }); + + const artifact = this.buildSubmittedArtifact(capture, submission, allCookies, [ + ...observedResponses, + { + timestamp: new Date().toISOString(), + type: 'note', + source: 'extension', + method: 'GET', + url: `${serverConfig.upstreamBaseUrl}/api/v1/user`, + status: 200, + payload: user, + message: 'Validated extension-captured tokens via upstream user lookup.', + }, + ]); + + await this.finalize(capture, 'succeeded', 'Successfully captured authenticated Boosteroid session via Chrome extension.', artifact); + return capture.artifact as CaptureArtifact; + } + + private expireIfTimedOut(capture: CaptureRuntime | null): void { + if (!capture || isTerminalStatus(capture.status) || Date.now() <= capture.timeoutAtMs) { + return; + } + + void this.finalize(capture, 'timed_out', 'Login capture timed out before an authenticated session was detected.'); + } + private async restoreLatestArtifact(): Promise { await ensureArtifactDir(); const entries = await fs.readdir(serverConfig.authCaptureArtifactDir, { withFileTypes: true }).catch(() => []); @@ -265,7 +473,7 @@ class AuthCaptureManager { } private cleanupFinishedActive(): void { - if (this.active && FINAL_STATUSES.has(this.active.status as CaptureTerminalStatus)) { + if (this.active && isTerminalStatus(this.active.status)) { this.active = null; } } @@ -275,12 +483,111 @@ class AuthCaptureManager { capture.updatedAtMs = Date.now(); } - private attachNetworkListeners(capture: CaptureRuntime, page: Page): void { + private createBaseArtifact(capture: CaptureRuntime): CaptureArtifact { + return { + id: capture.id, + captureMethod: capture.method, + status: capture.status, + startedAt: new Date(capture.startedAtMs).toISOString(), + updatedAt: new Date(capture.updatedAtMs).toISOString(), + completedAt: toIso(capture.completedAtMs), + timeoutAt: new Date(capture.timeoutAtMs).toISOString(), + loginUrl: LOGIN_URL, + finalUrl: capture.page?.url() ?? null, + upstreamBaseUrl: serverConfig.upstreamBaseUrl, + errors: [...capture.errors], + eventCount: capture.events.length, + authCookies: {}, + allCookies: [], + observedResponses: capture.events.filter((event) => event.type === 'response' || event.type === 'note'), + userPayload: (capture.bridgeSession?.user as Record | undefined) ?? null, + bridgeSession: capture.bridgeSession, + ingestSource: capture.method, + }; + } + + private buildSubmittedArtifact( + capture: CaptureRuntime, + submission: ExtensionCaptureSubmission, + allCookies: StoredCookie[], + observedResponses: CaptureEvent[], + ): CaptureArtifact { + const artifact = this.createBaseArtifact(capture); + artifact.finalUrl = submission.finalUrl ?? artifact.finalUrl; + artifact.allCookies = allCookies; + artifact.authCookies = pickAuthCookies(allCookies); + artifact.observedResponses = observedResponses; + artifact.eventCount = observedResponses.length; + artifact.extensionMetadata = submission.extensionMetadata ?? undefined; + artifact.ingestSource = 'extension'; + artifact.userPayload = (capture.bridgeSession?.user as Record | undefined) ?? null; + artifact.bridgeSession = capture.bridgeSession; + artifact.errors = [...capture.errors]; + return artifact; + } + + private toArtifact(capture: CaptureRuntime): CaptureArtifact { + return capture.artifact ?? this.createBaseArtifact(capture); + } + + private async finalize(capture: CaptureRuntime, status: CaptureStatus, message?: string, artifactOverride?: CaptureArtifact): Promise { + if (isTerminalStatus(capture.status)) return; + capture.status = status; + capture.updatedAtMs = Date.now(); + capture.completedAtMs = Date.now(); + if (message) { + if (status !== 'succeeded') { + capture.errors.push(message); + } + this.pushEvent(capture, { + timestamp: new Date().toISOString(), + type: status === 'succeeded' ? 'note' : 'error', + source: capture.method, + message, + }); + } + + let artifact = artifactOverride; + if (!artifact) { + const cookies = capture.context ? (await capture.context.cookies()).map(serializeCookie) : []; + artifact = this.createBaseArtifact(capture); + artifact.finalUrl = capture.page?.url() ?? artifact.finalUrl; + artifact.allCookies = cookies; + artifact.authCookies = pickAuthCookies(cookies); + artifact.eventCount = capture.events.length; + artifact.observedResponses = capture.events.filter((event) => event.type === 'response' || event.type === 'note'); + artifact.userPayload = (capture.bridgeSession?.user as Record | undefined) ?? null; + artifact.bridgeSession = capture.bridgeSession; + } + + artifact.status = status; + artifact.updatedAt = new Date(capture.updatedAtMs).toISOString(); + artifact.completedAt = new Date(capture.completedAtMs).toISOString(); + artifact.errors = [...capture.errors]; + artifact.captureMethod = capture.method; + artifact.bridgeSession = capture.bridgeSession; + artifact.userPayload = (capture.bridgeSession?.user as Record | undefined) ?? null; + + capture.artifact = artifact; + capture.persistedPath = await persistArtifact(artifact); + this.latestArtifact = artifact; + this.latestArtifactPath = capture.persistedPath; + + await capture.page?.close().catch(() => undefined); + await capture.context?.close().catch(() => undefined); + await capture.browser?.close().catch(() => undefined); + capture.page = null; + capture.context = null; + capture.browser = null; + } + + private attachBrowserNetworkListeners(capture: CaptureRuntime, page: Page): void { page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { this.pushEvent(capture, { timestamp: new Date().toISOString(), type: 'page', + source: 'browser', url: frame.url(), message: 'Main frame navigated.', }); @@ -293,6 +600,7 @@ class AuthCaptureManager { this.pushEvent(capture, { timestamp: new Date().toISOString(), type: 'request', + source: 'browser', method: request.method(), url, message: 'Observed relevant request.', @@ -306,6 +614,7 @@ class AuthCaptureManager { const event: CaptureEvent = { timestamp: new Date().toISOString(), type: 'response', + source: 'browser', method: response.request().method(), url, status: response.status(), @@ -337,120 +646,13 @@ class AuthCaptureManager { this.pushEvent(capture, { timestamp: new Date().toISOString(), type: 'error', + source: 'browser', message: error.message, }); }); } - private extractTokens(cookies: Cookie[]): { accessToken: string | null; refreshToken: string | null } { - const accessToken = cookies.find((cookie) => cookie.name === 'access_token')?.value ?? null; - const refreshToken = cookies.find((cookie) => cookie.name === 'refresh_token')?.value ?? null; - return { accessToken, refreshToken }; - } - - private async buildBridgeSession(capture: CaptureRuntime): Promise { - if (!capture.context || !capture.page) return null; - const cookies = await capture.context.cookies(); - const { accessToken, refreshToken } = this.extractTokens(cookies); - if (!accessToken || !refreshToken) return null; - - const user = await getUpstreamUser(accessToken); - this.pushEvent(capture, { - timestamp: new Date().toISOString(), - type: 'note', - method: 'GET', - url: `${serverConfig.upstreamBaseUrl}/api/v1/user`, - status: 200, - payload: user, - message: 'Validated captured tokens via upstream user lookup.', - }); - - const loginResponse = capture.events.find((event) => event.type === 'response' && event.url?.includes('/api/v1/auth/login') && event.payload && typeof event.payload === 'object'); - const loginEnvelope = unwrapRecord(loginResponse?.payload); - - return createSession({ - accessToken, - refreshToken, - userData: loginEnvelope.user_data, - user, - }); - } - - private toArtifact(capture: CaptureRuntime): CaptureArtifact { - const cookies = capture.artifact?.allCookies ?? []; - const authCookies = capture.artifact?.authCookies ?? {}; - return { - id: capture.id, - status: capture.status, - startedAt: new Date(capture.startedAtMs).toISOString(), - updatedAt: new Date(capture.updatedAtMs).toISOString(), - completedAt: toIso(capture.completedAtMs), - timeoutAt: new Date(capture.timeoutAtMs).toISOString(), - loginUrl: LOGIN_URL, - finalUrl: capture.page?.url() ?? capture.artifact?.finalUrl ?? null, - upstreamBaseUrl: serverConfig.upstreamBaseUrl, - errors: [...capture.errors], - eventCount: capture.events.length, - authCookies, - allCookies: cookies, - observedResponses: capture.events.filter((event) => event.type === 'response' || event.type === 'note'), - userPayload: (capture.bridgeSession?.user as Record | undefined) ?? null, - bridgeSession: capture.bridgeSession, - }; - } - - private async finalize(capture: CaptureRuntime, status: CaptureStatus, message?: string): Promise { - if (FINAL_STATUSES.has(capture.status as CaptureTerminalStatus)) return; - capture.status = status; - capture.updatedAtMs = Date.now(); - capture.completedAtMs = Date.now(); - if (message) { - if (status !== 'succeeded') { - capture.errors.push(message); - } - this.pushEvent(capture, { - timestamp: new Date().toISOString(), - type: status === 'succeeded' ? 'note' : 'error', - message, - }); - } - - const cookies = capture.context ? await capture.context.cookies() : []; - const allCookies = cookies.map(serializeCookie); - const authCookies = Object.fromEntries( - AUTH_COOKIE_NAMES.map((name) => { - const found = cookies.find((cookie) => cookie.name === name); - return [name, found ? serializeCookie(found) : undefined]; - }).filter((entry) => entry[1] !== undefined), - ) as Partial>; - - const artifact = this.toArtifact(capture); - artifact.status = status; - artifact.completedAt = new Date(capture.completedAtMs).toISOString(); - artifact.updatedAt = new Date(capture.updatedAtMs).toISOString(); - artifact.finalUrl = capture.page?.url() ?? artifact.finalUrl; - artifact.allCookies = allCookies; - artifact.authCookies = authCookies; - artifact.bridgeSession = capture.bridgeSession; - artifact.userPayload = (capture.bridgeSession?.user as Record | undefined) ?? null; - artifact.errors = [...capture.errors]; - artifact.eventCount = capture.events.length; - artifact.observedResponses = capture.events.filter((event) => event.type === 'response' || event.type === 'note'); - - capture.artifact = artifact; - capture.persistedPath = await persistArtifact(artifact); - this.latestArtifact = artifact; - this.latestArtifactPath = capture.persistedPath; - - await capture.page?.close().catch(() => undefined); - await capture.context?.close().catch(() => undefined); - await capture.browser?.close().catch(() => undefined); - capture.page = null; - capture.context = null; - capture.browser = null; - } - - private async runCapture(capture: CaptureRuntime): Promise { + private async runBrowserCapture(capture: CaptureRuntime): Promise { try { await ensureArtifactDir(); await ensureBrowserProfileDir(); @@ -468,19 +670,17 @@ class AuthCaptureManager { }); await applyStealthDefaults(context); - const existingPages = context.pages(); - const page = existingPages.at(-1) ?? await context.newPage(); - const browser = context.browser(); - - capture.browser = browser; + const page = context.pages().at(-1) ?? await context.newPage(); + capture.browser = context.browser(); capture.context = context; capture.page = page; capture.status = 'awaiting_user'; capture.updatedAtMs = Date.now(); - this.attachNetworkListeners(capture, page); + this.attachBrowserNetworkListeners(capture, page); this.pushEvent(capture, { timestamp: new Date().toISOString(), type: 'note', + source: 'browser', message: `Browser launched in persistent profile mode using ${serverConfig.browserExecutablePath ?? serverConfig.browserChannel ?? 'default browser'}. Waiting for manual Boosteroid login.`, }); @@ -493,19 +693,21 @@ class AuthCaptureManager { return; } - const bridgeSession = await this.buildBridgeSession(capture).catch((error: unknown) => { - const message = error instanceof Error ? error.message : 'Waiting for captured upstream session.'; - this.pushEvent(capture, { - timestamp: new Date().toISOString(), - type: 'note', - message, + const cookies = capture.context ? (await capture.context.cookies()).map(serializeCookie) : []; + const cookieTokens = extractTokensFromCookies(cookies); + const payloadTokens = extractTokensFromEvents(capture.events); + const accessToken = cookieTokens.accessToken ?? payloadTokens.accessToken; + const refreshToken = cookieTokens.refreshToken ?? payloadTokens.refreshToken; + + if (accessToken && refreshToken) { + const user = await getUpstreamUser(accessToken); + capture.bridgeSession = createSession({ + accessToken, + refreshToken, + userData: payloadTokens.userData, + user, }); - return null; - }); - - if (bridgeSession) { - capture.bridgeSession = bridgeSession; - await this.finalize(capture, 'succeeded', 'Successfully captured authenticated Boosteroid session.'); + await this.finalize(capture, 'succeeded', 'Successfully captured authenticated Boosteroid session via backend browser fallback.'); return; } diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 7656943..734c2e3 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -4,6 +4,7 @@ import type { AuthCaptureDebugResponse, AuthSession, InstalledGame, + LoginCaptureMethod, LoginCaptureSessionStatus, LoginCaptureStartResponse, User, @@ -16,8 +17,8 @@ function extractSession(data: Record): AuthSession { }; } -export async function startLoginCapture(): Promise { - const { data } = await apiClient.post(API_CONFIG.endpoints.loginStart); +export async function startLoginCapture(method: LoginCaptureMethod = 'extension'): Promise { + const { data } = await apiClient.post(API_CONFIG.endpoints.loginStart, { method }); return data as LoginCaptureStartResponse; } diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 484c168..4ff72a1 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -6,6 +6,7 @@ import { Button, Center, Code, + Divider, Group, List, Loader, @@ -18,11 +19,12 @@ import { import { IconAlertCircle, IconArrowRight, + IconBrandChrome, IconCheck, IconExternalLink, IconPlayerStop, IconRefresh, - IconSparkles, + IconPuzzle, } from '@tabler/icons-react'; import { AxiosError } from 'axios'; import { @@ -32,23 +34,33 @@ import { } from '../api'; import { useAuth } from '../auth'; import { AuthCaptureDebugPanel } from '../components/AuthCaptureDebugPanel'; -import type { ApiError, LoginCaptureSessionStatus, LoginCaptureStatus } from '../types'; +import type { + ApiError, + LoginCaptureMethod, + LoginCaptureSessionStatus, + LoginCaptureStatus, +} from '../types'; const POLL_INTERVAL_MS = 1500; const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']); +const EXTENSION_PATH = 'extension/openstroid-capture'; -function describeStatus(status: LoginCaptureStatus): string { +function describeStatus(status: LoginCaptureStatus, method: LoginCaptureMethod | undefined): string { switch (status) { case 'starting': - return 'Launching a real browser window on the backend host.'; + return method === 'browser' + ? 'Launching the backend browser fallback.' + : 'Creating an extension capture session.'; case 'awaiting_user': - return 'Finish login in the Boosteroid window. Turnstile stays on Boosteroid.'; + return method === 'browser' + ? 'Complete login in the backend-launched browser window.' + : 'Use the OpenStroid Chrome extension while you log in on Boosteroid in your real browser.'; case 'succeeded': - return 'Authenticated session captured. OpenStroid is establishing its own first-party session.'; + return 'Captured upstream auth state. OpenStroid is establishing its own first-party session.'; case 'failed': - return 'Capture failed before an authenticated upstream session was detected.'; + return 'Capture failed before a usable upstream session was received.'; case 'cancelled': - return 'Capture was cancelled and the browser context was cleaned up.'; + return 'Capture was cancelled.'; case 'timed_out': return 'Capture timed out before login completed.'; default: @@ -97,24 +109,27 @@ export function LoginPage() { } }, [from, navigate, refreshSession]); - useEffect(() => { - return () => stopPolling(); - }, [stopPolling]); + useEffect(() => () => stopPolling(), [stopPolling]); - const handleStart = useCallback(async () => { + const startCapture = useCallback(async (method: LoginCaptureMethod) => { stopPolling(); setIsSubmitting(true); setServerError(null); try { - const started = await startLoginCapture(); + const started = await startLoginCapture(method); const initialStatus = await getLoginCaptureStatus(started.id); setCapture(initialStatus); void pollStatus(started.id); + if (method === 'extension') { + window.open(started.loginUrl, '_blank', 'noopener,noreferrer'); + } } catch (err) { const axiosErr = err as AxiosError; const fallback = axiosErr.response?.status === 409 - ? 'A login capture is already running. Use refresh to follow it or cancel it first.' - : 'Could not start the Boosteroid browser login flow.'; + ? 'A login capture is already running. Follow that session or cancel it first.' + : method === 'browser' + ? 'Could not start the backend browser fallback.' + : 'Could not start the extension capture session.'; setServerError(axiosErr.response?.data?.message || fallback); } finally { setIsSubmitting(false); @@ -179,7 +194,7 @@ export function LoginPage() { }} >
- + - Boosteroid login now runs in a real upstream browser window. + + Primary login capture now runs through a Chrome extension in your real browser profile. + - + Sign in with Boosteroid - OpenStroid launches a visible browser on the backend host, waits for you to finish the real Boosteroid login and Turnstile challenge there, then captures the resulting upstream session into the existing OpenStroid cookie session. + Install the unpacked OpenStroid Chrome extension, start a capture session here, then log in to Boosteroid in your normal Chrome profile. The extension observes the real browser session and sends upstream cookies and auth evidence back to the OpenStroid backend. - + @@ -231,9 +248,9 @@ export function LoginPage() { size="sm" icon={} > - Credentials are entered only on Boosteroid’s own page. - The backend-owned browser context is the source of truth for upstream cookies and auth payloads. - Captured cookies, payloads, and request metadata are saved to disk and exposed through a gated debug endpoint. + Load the unpacked extension from {EXTENSION_PATH} into Chrome. + In the extension popup, keep the backend URL set to http://localhost:3001 for local dev. + Start capture below, then complete login on boosteroid.com in the same Chrome profile. {serverError && ( @@ -247,40 +264,40 @@ export function LoginPage() { size="md" variant="gradient" gradient={{ from: 'brand.5', to: 'accent.6', deg: 135 }} - leftSection={} - onClick={() => void handleStart()} + leftSection={} + onClick={() => void startCapture('extension')} loading={isSubmitting} > - Launch Boosteroid login + Start extension capture - {capture ? describeStatus(capture.status) : 'Start a capture to open the browser window and begin manual login.'} + {capture ? describeStatus(capture.status, capture.captureMethod) : 'Start a capture session, then complete login in Chrome with the extension enabled.'} {capture && ( <> Capture ID: {capture.id} + Method: {capture.captureMethod} Timeout: {new Date(capture.timeoutAt).toLocaleString()} + Login URL: {capture.loginUrl} {capture.finalUrl && Final URL: {capture.finalUrl}} {capture.errors.length > 0 && ( {capture.errors[capture.errors.length - 1]} @@ -307,8 +324,42 @@ export function LoginPage() { Continue to library )} + {capture && !TERMINAL_STATUSES.has(capture.status) && ( + + )} + + + + + Optional backend browser fallback + + Use only if the extension flow is unavailable. This still launches a backend-owned browser and may be less reliable against Turnstile than the extension path. + + + + + Load unpacked extension from {EXTENSION_PATH} + + + diff --git a/src/types/index.ts b/src/types/index.ts index 808e3b0..5dd39bd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,12 +11,6 @@ export interface AuthSession { user: User | null; } -export interface LoginCaptureStartResponse { - id: string; - status: LoginCaptureStatus; - timeoutAt: string; -} - export type LoginCaptureStatus = | 'starting' | 'awaiting_user' @@ -25,6 +19,16 @@ export type LoginCaptureStatus = | 'cancelled' | 'timed_out'; +export type LoginCaptureMethod = 'extension' | 'browser'; + +export interface LoginCaptureStartResponse { + id: string; + status: LoginCaptureStatus; + timeoutAt: string; + captureMethod: LoginCaptureMethod; + loginUrl: string; +} + export interface LoginCaptureSessionStatus { id: string; status: LoginCaptureStatus; @@ -32,10 +36,12 @@ export interface LoginCaptureSessionStatus { updatedAt: string; completedAt: string | null; timeoutAt: string; + loginUrl: string; finalUrl: string | null; errors: string[]; eventCount: number; user: User | null; + captureMethod: LoginCaptureMethod; sessionEstablished: boolean; } @@ -49,6 +55,7 @@ export interface CaptureEvent { headers?: Record; cookieNames?: string[]; message?: string; + source?: 'browser' | 'extension'; } export interface StoredCookie { @@ -64,6 +71,7 @@ export interface StoredCookie { export interface CaptureArtifact { id: string; + captureMethod: LoginCaptureMethod; status: LoginCaptureStatus; startedAt: string; updatedAt: string; @@ -86,6 +94,8 @@ export interface CaptureArtifact { createdAt: number; updatedAt: number; } | null; + ingestSource?: 'browser' | 'extension'; + extensionMetadata?: Record; } export interface AuthCaptureDebugResponse { From 02c3b2c61d31984cffb9fe4e31f156d3ce87dc27 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:24:06 +0000 Subject: [PATCH 5/6] fix(auth): secure extension capture pairing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .env.example | 1 + README.md | 5 +- extension/openstroid-capture/background.js | 53 ++++++++++++---- extension/openstroid-capture/popup.html | 2 + extension/openstroid-capture/popup.js | 8 ++- server/index.ts | 19 ++++-- server/lib/authCapture.ts | 71 ++++++++++++++-------- src/pages/LoginPage.tsx | 13 +++- src/types/index.ts | 1 + 9 files changed, 127 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index 9eeb9a7..4e25a27 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,4 @@ # Chrome extension local dev: # - Load unpacked extension from extension/openstroid-capture/ # - In the extension popup, keep the backend URL set to http://localhost:3001 +# - Paste the pairing code shown on the OpenStroid login page into the extension popup before logging in diff --git a/README.md b/README.md index d6eadf3..e6a62ec 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The backend exposes normalized first-party endpoints: | `GET` | `/auth/login/status` | Read the latest capture status and establish the first-party OpenStroid session on success | | `GET` | `/auth/login/status/:id` | Read status for a specific capture session | | `POST` | `/auth/login/cancel` | Cancel the active capture and clean up browser resources | -| `GET` | `/auth/extension/active` | Extension-only route to read the currently active pending extension capture session | +| `POST` | `/auth/extension/active` | Extension-only route to read the currently active pending extension capture session after presenting the user-issued pairing code | | `POST` | `/auth/extension/capture` | Extension-only route to submit captured upstream cookies/payloads for ingestion | | `GET` | `/auth/debug/capture` | Return the latest raw upstream capture artifact, including cookies and payloads | | `POST` | `/auth/logout` | Clear first-party session and attempt upstream logout | @@ -97,7 +97,8 @@ The backend exposes normalized first-party endpoints: 3. Choose **Load unpacked**. 4. Select `extension/openstroid-capture/` from this repo. 5. Open the extension popup and confirm the backend URL is `http://localhost:3001`. -6. In OpenStroid, click **Start extension capture** and then log in on Boosteroid in the same Chrome profile. +6. In OpenStroid, click **Start extension capture** and copy the pairing code shown on the login page into the extension popup. +7. Log in on Boosteroid in the same Chrome profile. ## Turnstile / capture notes diff --git a/extension/openstroid-capture/background.js b/extension/openstroid-capture/background.js index ffe704b..a353051 100644 --- a/extension/openstroid-capture/background.js +++ b/extension/openstroid-capture/background.js @@ -27,20 +27,33 @@ function pushObservedEvent(event) { } } -async function getBackendBaseUrl() { - const stored = await chrome.storage.local.get(['backendBaseUrl']); - return stored.backendBaseUrl || DEFAULT_BACKEND_BASE_URL; +async function getStoredState() { + const stored = await chrome.storage.local.get(['backendBaseUrl', 'pairingCode']); + return { + backendBaseUrl: stored.backendBaseUrl || DEFAULT_BACKEND_BASE_URL, + pairingCode: typeof stored.pairingCode === 'string' ? stored.pairingCode.trim().toUpperCase() : '', + }; } async function getActiveCapture() { - const backendBaseUrl = await getBackendBaseUrl(); - const response = await fetch(`${backendBaseUrl}/auth/extension/active`); + const { backendBaseUrl, pairingCode } = await getStoredState(); + if (!pairingCode) { + return null; + } + + const response = await fetch(`${backendBaseUrl}/auth/extension/active`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pairingCode }), + }); if (!response.ok) { return null; } const data = await response.json(); - return { backendBaseUrl, data }; + return { backendBaseUrl, pairingCode, data }; } async function collectCookiesForDomain(domain) { @@ -82,7 +95,7 @@ async function submitCapture(reason) { return; } - const { backendBaseUrl, data } = active; + const { backendBaseUrl, pairingCode, data } = active; if (lastSubmittedCaptureId === data.id) { return; } @@ -106,6 +119,7 @@ async function submitCapture(reason) { extensionMetadata: { reason, backendBaseUrl, + pairingCode, extensionVersion: chrome.runtime.getManifest().version, userAgent: navigator.userAgent, }, @@ -130,9 +144,16 @@ async function submitCapture(reason) { } chrome.runtime.onInstalled.addListener(async () => { - const current = await chrome.storage.local.get(['backendBaseUrl']); + const current = await chrome.storage.local.get(['backendBaseUrl', 'pairingCode']); + const nextState = {}; if (!current.backendBaseUrl) { - await chrome.storage.local.set({ backendBaseUrl: DEFAULT_BACKEND_BASE_URL }); + nextState.backendBaseUrl = DEFAULT_BACKEND_BASE_URL; + } + if (!current.pairingCode) { + nextState.pairingCode = ''; + } + if (Object.keys(nextState).length > 0) { + await chrome.storage.local.set(nextState); } }); @@ -158,9 +179,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { } if (message?.type === 'openstroid:get-state') { - void getBackendBaseUrl().then((backendBaseUrl) => { + void getStoredState().then(({ backendBaseUrl, pairingCode }) => { sendResponse({ backendBaseUrl, + pairingCode, observedEventCount: observedResponses.length, lastSubmittedCaptureId, }); @@ -168,8 +190,15 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { return true; } - if (message?.type === 'openstroid:set-backend-base-url' && typeof message.backendBaseUrl === 'string') { - void chrome.storage.local.set({ backendBaseUrl: message.backendBaseUrl.trim() || DEFAULT_BACKEND_BASE_URL }).then(() => { + if (message?.type === 'openstroid:set-settings') { + const backendBaseUrl = typeof message.backendBaseUrl === 'string' && message.backendBaseUrl.trim() + ? message.backendBaseUrl.trim() + : DEFAULT_BACKEND_BASE_URL; + const pairingCode = typeof message.pairingCode === 'string' + ? message.pairingCode.trim().toUpperCase() + : ''; + void chrome.storage.local.set({ backendBaseUrl, pairingCode }).then(() => { + lastSubmittedCaptureId = null; sendResponse({ ok: true }); }); return true; diff --git a/extension/openstroid-capture/popup.html b/extension/openstroid-capture/popup.html index bccb3bb..402f40a 100644 --- a/extension/openstroid-capture/popup.html +++ b/extension/openstroid-capture/popup.html @@ -64,6 +64,8 @@

OpenStroid Capture

+ +
diff --git a/extension/openstroid-capture/popup.js b/extension/openstroid-capture/popup.js index 36ec17c..b6f41cb 100644 --- a/extension/openstroid-capture/popup.js +++ b/extension/openstroid-capture/popup.js @@ -1,4 +1,5 @@ const backendInput = document.getElementById('backendBaseUrl'); +const pairingInput = document.getElementById('pairingCode'); const statusElement = document.getElementById('status'); const saveButton = document.getElementById('save'); const openButton = document.getElementById('open'); @@ -14,13 +15,18 @@ function refreshState() { return; } backendInput.value = response.backendBaseUrl; + pairingInput.value = response.pairingCode || ''; setStatus(response); }); } saveButton.addEventListener('click', () => { chrome.runtime.sendMessage( - { type: 'openstroid:set-backend-base-url', backendBaseUrl: backendInput.value }, + { + type: 'openstroid:set-settings', + backendBaseUrl: backendInput.value, + pairingCode: pairingInput.value, + }, () => refreshState(), ); }); diff --git a/server/index.ts b/server/index.ts index 3ab11de..3a6d8f5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,7 +3,11 @@ import path from 'node:path'; import express, { type NextFunction, type Request, type Response } from 'express'; import cookieParser from 'cookie-parser'; import { serverConfig } from './config.js'; -import { authCaptureManager, type CaptureMethod } from './lib/authCapture.js'; +import { + authCaptureManager, + type CaptureMethod, + type ExtensionPairingRequest, +} from './lib/authCapture.js'; import { clearSession, createSession, readSession, writeSession } from './lib/session.js'; import { getInstalledGamesUpstream, @@ -150,10 +154,17 @@ app.post('/auth/login/cancel', async (req, res, next) => { } }); -app.get('/auth/extension/active', (_req, res) => { - const active = authCaptureManager.getActiveExtensionSession(); +app.post('/auth/extension/active', (req, res) => { + const body = req.body as ExtensionPairingRequest | undefined; + const pairingCode = typeof body?.pairingCode === 'string' ? body.pairingCode : ''; + if (!pairingCode) { + res.status(400).json({ message: 'Extension pairing code is required.' }); + return; + } + + const active = authCaptureManager.getActiveExtensionSession(pairingCode); if (!active) { - res.status(404).json({ message: 'No active extension capture session.' }); + res.status(404).json({ message: 'No active extension capture session for that pairing code.' }); return; } diff --git a/server/lib/authCapture.ts b/server/lib/authCapture.ts index 571cca7..2e2e5ac 100644 --- a/server/lib/authCapture.ts +++ b/server/lib/authCapture.ts @@ -83,6 +83,7 @@ export interface CaptureStartResult { timeoutAt: string; captureMethod: CaptureMethod; loginUrl: string; + extensionPairingCode?: string; } export interface ExtensionActiveCapture { @@ -92,6 +93,10 @@ export interface ExtensionActiveCapture { loginUrl: string; } +export interface ExtensionPairingRequest { + pairingCode: string; +} + export interface ExtensionCaptureSubmission { id: string; ingestToken: string; @@ -105,6 +110,7 @@ interface CaptureRuntime { id: string; method: CaptureMethod; ingestToken: string; + extensionPairingCode: string | null; status: CaptureStatus; startedAtMs: number; updatedAtMs: number; @@ -172,6 +178,10 @@ function dedupeArgs(...groups: string[][]): string[] { return [...new Set(groups.flat().filter(Boolean))]; } +function generatePairingCode(): string { + return randomUUID().replace(/-/g, '').slice(0, 8).toUpperCase(); +} + function pickAuthCookies(cookies: StoredCookie[]): Partial> { return Object.fromEntries( AUTH_COOKIE_NAMES.map((name) => { @@ -299,6 +309,7 @@ class AuthCaptureManager { id: randomUUID(), method, ingestToken: randomUUID(), + extensionPairingCode: method === 'extension' ? generatePairingCode() : null, status: method === 'extension' ? 'awaiting_user' : 'starting', startedAtMs: now, updatedAtMs: now, @@ -336,6 +347,7 @@ class AuthCaptureManager { timeoutAt: new Date(capture.timeoutAtMs).toISOString(), captureMethod: capture.method, loginUrl: LOGIN_URL, + extensionPairingCode: capture.extensionPairingCode ?? undefined, }; } @@ -366,13 +378,17 @@ class AuthCaptureManager { return { artifact: this.latestArtifact, path: this.latestArtifactPath }; } - getActiveExtensionSession(): ExtensionActiveCapture | null { + getActiveExtensionSession(pairingCode: string): ExtensionActiveCapture | null { this.expireIfTimedOut(this.active); const capture = this.active; if (!capture || capture.method !== 'extension' || isTerminalStatus(capture.status)) { return null; } + if (!capture.extensionPairingCode || capture.extensionPairingCode !== pairingCode.trim().toUpperCase()) { + return null; + } + return { id: capture.id, ingestToken: capture.ingestToken, @@ -422,30 +438,37 @@ class AuthCaptureManager { return capture.artifact as CaptureArtifact; } - const user = await getUpstreamUser(accessToken); - capture.bridgeSession = createSession({ - accessToken, - refreshToken, - userData: payloadTokens.userData, - user, - }); + try { + const user = await getUpstreamUser(accessToken); + capture.bridgeSession = createSession({ + accessToken, + refreshToken, + userData: payloadTokens.userData, + user, + }); - const artifact = this.buildSubmittedArtifact(capture, submission, allCookies, [ - ...observedResponses, - { - timestamp: new Date().toISOString(), - type: 'note', - source: 'extension', - method: 'GET', - url: `${serverConfig.upstreamBaseUrl}/api/v1/user`, - status: 200, - payload: user, - message: 'Validated extension-captured tokens via upstream user lookup.', - }, - ]); - - await this.finalize(capture, 'succeeded', 'Successfully captured authenticated Boosteroid session via Chrome extension.', artifact); - return capture.artifact as CaptureArtifact; + const artifact = this.buildSubmittedArtifact(capture, submission, allCookies, [ + ...observedResponses, + { + timestamp: new Date().toISOString(), + type: 'note', + source: 'extension', + method: 'GET', + url: `${serverConfig.upstreamBaseUrl}/api/v1/user`, + status: 200, + payload: user, + message: 'Validated extension-captured tokens via upstream user lookup.', + }, + ]); + + await this.finalize(capture, 'succeeded', 'Successfully captured authenticated Boosteroid session via Chrome extension.', artifact); + return capture.artifact as CaptureArtifact; + } catch (error) { + const artifact = this.buildSubmittedArtifact(capture, submission, allCookies, observedResponses); + const message = error instanceof Error ? `Extension capture token validation failed: ${error.message}` : 'Extension capture token validation failed.'; + await this.finalize(capture, 'failed', message, artifact); + return capture.artifact as CaptureArtifact; + } } private expireIfTimedOut(capture: CaptureRuntime | null): void { diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 4ff72a1..8a545e6 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -75,6 +75,7 @@ export function LoginPage() { const [capture, setCapture] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [serverError, setServerError] = useState(null); + const [extensionPairingCode, setExtensionPairingCode] = useState(null); const pollHandle = useRef(null); const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/library'; @@ -117,6 +118,7 @@ export function LoginPage() { setServerError(null); try { const started = await startLoginCapture(method); + setExtensionPairingCode(started.extensionPairingCode ?? null); const initialStatus = await getLoginCaptureStatus(started.id); setCapture(initialStatus); void pollStatus(started.id); @@ -143,6 +145,7 @@ export function LoginPage() { try { const cancelled = await cancelLoginCapture(capture.id); setCapture(cancelled); + setExtensionPairingCode(null); } catch (err) { const axiosErr = err as AxiosError; setServerError(axiosErr.response?.data?.message || 'Failed to cancel the active capture.'); @@ -248,9 +251,10 @@ export function LoginPage() { size="sm" icon={} > - Load the unpacked extension from {EXTENSION_PATH} into Chrome. - In the extension popup, keep the backend URL set to http://localhost:3001 for local dev. - Start capture below, then complete login on boosteroid.com in the same Chrome profile. + Load the unpacked extension from {EXTENSION_PATH} into Chrome. + In the extension popup, keep the backend URL set to http://localhost:3001 for local dev. + When prompted by the extension popup, paste the pairing code from this page so the extension can fetch the active ingest token. + Start capture below, then complete login on boosteroid.com in the same Chrome profile. {serverError && ( @@ -296,6 +300,9 @@ export function LoginPage() { <> Capture ID: {capture.id} Method: {capture.captureMethod} + {extensionPairingCode && capture.captureMethod === 'extension' && ( + Pairing code: {extensionPairingCode} + )} Timeout: {new Date(capture.timeoutAt).toLocaleString()} Login URL: {capture.loginUrl} {capture.finalUrl && Final URL: {capture.finalUrl}} diff --git a/src/types/index.ts b/src/types/index.ts index 5dd39bd..9cdb34f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,6 +27,7 @@ export interface LoginCaptureStartResponse { timeoutAt: string; captureMethod: LoginCaptureMethod; loginUrl: string; + extensionPairingCode?: string; } export interface LoginCaptureSessionStatus { From 3e86c0811d6740880f1512a36aa61a7192566fc2 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:17:12 +0000 Subject: [PATCH 6/6] feat(desktop): pivot to electron-first client Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .env.example | 22 +- README.md | 195 +++--- electron/main.ts | 69 +++ extension/openstroid-capture/background.js | 2 +- extension/openstroid-capture/popup.html | 2 +- package-lock.json | 684 ++++++++++++++++++++- package.json | 11 +- server/app.ts | 312 ++++++++++ server/index.ts | 305 +-------- src/components/AppHeader.tsx | 2 +- src/pages/LoginPage.tsx | 61 +- tsconfig.electron.json | 19 + vite.config.ts | 2 +- 13 files changed, 1229 insertions(+), 457 deletions(-) create mode 100644 electron/main.ts create mode 100644 server/app.ts create mode 100644 tsconfig.electron.json diff --git a/.env.example b/.env.example index 4e25a27..569c101 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,18 @@ -# Frontend: leave empty for local dev and same-origin deployments so requests stay on -# the OpenStroid origin (`/auth`, `/me`, `/library`) and Vite can proxy to the backend. -# Set only when the frontend is deployed separately from the backend bridge. +# Renderer/frontend API origin. +# Leave empty in local Electron development so the renderer keeps using +# first-party `/auth`, `/me`, and `/library` routes and Vite proxies them to +# the local Electron bridge. # Never point this at `https://cloud.boosteroid.com` or any Boosteroid origin. # VITE_API_BASE_URL= -# Backend bridge configuration. +# Electron/local bridge configuration. # SERVER_PORT=3001 # UPSTREAM_BASE_URL=https://cloud.boosteroid.com # SESSION_SECRET=replace-with-a-long-random-secret # SESSION_COOKIE_NAME=openstroid_session # SESSION_TTL_SECONDS=2592000 # COOKIE_SECURE=false -# APP_ORIGIN=http://localhost:3000 +# APP_ORIGIN=http://127.0.0.1:3000 # AUTH_CAPTURE_ARTIFACT_DIR=/absolute/path/to/OpenStroid/.runtime/auth-captures # BROWSER_USER_DATA_DIR=/absolute/path/to/OpenStroid/.runtime/browser-profile # BROWSER_LOGIN_TIMEOUT_MS=300000 @@ -23,10 +24,13 @@ # BROWSER_LOCALE=en-US # BROWSER_LAUNCH_ARGS=--start-maximized -# Optional: override the Vite dev proxy target if your backend runs elsewhere. -# BACKEND_PROXY_TARGET=http://localhost:3001 +# Electron development renderer URL. +# ELECTRON_RENDERER_URL=http://127.0.0.1:3000 + +# Optional: override the Vite dev proxy target if your local bridge runs elsewhere. +# BACKEND_PROXY_TARGET=http://127.0.0.1:3001 # Chrome extension local dev: # - Load unpacked extension from extension/openstroid-capture/ -# - In the extension popup, keep the backend URL set to http://localhost:3001 -# - Paste the pairing code shown on the OpenStroid login page into the extension popup before logging in +# - In the extension popup, keep the backend URL set to http://127.0.0.1:3001 +# - Paste the pairing code shown in OpenStroid Desktop into the extension popup before logging in diff --git a/README.md b/README.md index e6a62ec..653b457 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,19 @@ -# OpenStroid +# OpenStroid Desktop -Open-source cloud gaming client. Built with React, TypeScript, Mantine, Vite, Express, and a first-party auth bridge that captures Boosteroid login state from the user’s real Chrome profile via a Chrome extension. +Electron-first cloud gaming client. OpenStroid Desktop runs a local bridge on localhost, coordinates Boosteroid auth capture, and displays the user-facing desktop UI. A companion Chrome extension installed in the user's normal Chrome profile captures Boosteroid auth/session state from the real browser session and sends it to the desktop bridge. -## Quick start +## Desktop-first architecture + +OpenStroid is now an Electron-first desktop client. + +- The Electron app is the primary product shell. +- Electron starts and owns the local HTTP bridge on `http://127.0.0.1:3001`. +- The React UI is rendered inside the Electron window. +- The Chrome extension in `extension/openstroid-capture/` runs in the user's real Chrome profile and talks to the Electron bridge over localhost. +- The desktop bridge validates captured upstream state, persists raw artifacts to `.runtime/auth-captures/`, creates the encrypted OpenStroid session, and continues to proxy normalized `/auth`, `/me`, and `/library` routes. +- A backend-owned browser fallback still exists, but it is secondary to the extension flow. + +## Local development ```bash npm install @@ -10,145 +21,125 @@ cp .env.example .env npm run dev ``` -- Frontend dev server: [http://localhost:3000](http://localhost:3000) -- Backend auth bridge: [http://localhost:3001](http://localhost:3001) - -`npm run dev` starts both processes. The browser talks only to first-party `/auth`, `/me`, and `/library` routes. The backend bridge owns the upstream Boosteroid session state after capture and proxies authenticated requests to `https://cloud.boosteroid.com`. +What runs in development: +- Vite renderer: `http://127.0.0.1:3000` +- Electron desktop shell: launched automatically by `npm run dev` +- Electron-managed local bridge: `http://127.0.0.1:3001` -## Auth bridge architecture +Use `npm run dev:bridge` only if you need the local bridge without launching Electron. -OpenStroid is no longer a browser-direct or credential-forwarding Boosteroid client. +## Chrome extension setup -- The frontend starts a first-party capture session instead of posting credentials to OpenStroid. -- The primary capture path is the unpacked Chrome extension in `extension/openstroid-capture/`, which runs inside the user’s real Chrome profile while they log in normally on `boosteroid.com` / `cloud.boosteroid.com`. -- The extension captures relevant cookies plus observed auth/session response payloads and sends them back to the local OpenStroid backend. -- The backend validates the submitted upstream state, persists raw capture artifacts, and converts successful captures into the existing encrypted first-party OpenStroid cookie session. -- The frontend sends session bootstrap, logout, and library requests to first-party endpoints on the OpenStroid origin. -- The backend bridge talks to `https://cloud.boosteroid.com` for: - - browser-observed `POST /api/v1/auth/login` - - `POST /api/v1/auth/refresh-token` - - `POST /api/v2/auth/logout` - - `GET /api/v1/user` - - `GET /api/v1/boostore/applications/installed` -- Upstream access and refresh tokens are stored only inside encrypted, HttpOnly first-party cookies for normal app behavior. -- Raw upstream cookies, login/session payloads, and request metadata are also persisted under `.runtime/auth-captures/` and exposed through a dedicated authenticated debug endpoint. -- The browser no longer stores raw upstream tokens in `localStorage`. -- Session bootstrap uses `GET /auth/session` instead of reading browser storage. -- Upstream 401s are refreshed server-side with a shared refresh lock to avoid duplicate refresh races. -- A backend-owned browser fallback still exists, but it is secondary to the extension flow because Turnstile is more reliable in the user’s real browser profile. - -## API surface - -The backend exposes normalized first-party endpoints: +1. Open `chrome://extensions` in your normal Chrome profile. +2. Enable Developer Mode. +3. Click **Load unpacked**. +4. Select `extension/openstroid-capture/` from this repo. +5. Open the extension popup. +6. Set the backend URL to `http://127.0.0.1:3001`. +7. When OpenStroid Desktop shows a pairing code, paste it into the extension popup. +8. Start desktop extension capture from the Electron app. +9. Log in on `https://boosteroid.com` in the same Chrome profile. + +## Desktop capture flow + +1. OpenStroid Desktop starts an extension capture session. +2. The desktop UI shows a user-issued pairing code. +3. The user pastes that code into the Chrome extension popup. +4. The extension requests the active ingest session from the Electron bridge using that pairing code. +5. The user logs in to Boosteroid in real Chrome. +6. The extension captures relevant cookies, response metadata, and observed JSON auth/session payloads. +7. The extension submits the capture artifact to `POST /auth/extension/capture` on the Electron bridge. +8. The bridge validates upstream state with `GET /api/v1/user`, persists the raw artifact, and establishes the encrypted OpenStroid session. +9. The desktop UI polls `/auth/login/status` and transitions into the game library after success. + +## Local bridge API surface | Method | Route | Description | |---|---|---| -| `POST` | `/auth/login/start` | Start a browser-backed Boosteroid login capture and launch the upstream login window | -| `GET` | `/auth/login/status` | Read the latest capture status and establish the first-party OpenStroid session on success | +| `POST` | `/auth/login/start` | Start a capture session from the desktop UI (`extension` primary, `browser` fallback) | +| `GET` | `/auth/login/status` | Read latest capture status and establish the local OpenStroid session on success | | `GET` | `/auth/login/status/:id` | Read status for a specific capture session | -| `POST` | `/auth/login/cancel` | Cancel the active capture and clean up browser resources | -| `POST` | `/auth/extension/active` | Extension-only route to read the currently active pending extension capture session after presenting the user-issued pairing code | -| `POST` | `/auth/extension/capture` | Extension-only route to submit captured upstream cookies/payloads for ingestion | -| `GET` | `/auth/debug/capture` | Return the latest raw upstream capture artifact, including cookies and payloads | -| `POST` | `/auth/logout` | Clear first-party session and attempt upstream logout | +| `POST` | `/auth/login/cancel` | Cancel the active capture | +| `POST` | `/auth/extension/active` | Extension-only route to fetch the active pending capture after presenting the user pairing code | +| `POST` | `/auth/extension/capture` | Extension-only route to submit captured upstream cookies/payloads | +| `GET` | `/auth/debug/capture` | Return the latest raw upstream capture artifact for inspection | +| `POST` | `/auth/logout` | Clear the OpenStroid session and attempt upstream logout | | `GET` | `/auth/session` | Validate/refresh current session and return `{ authenticated, user }` | | `GET` | `/me` | Return `{ user }` for authenticated clients | | `GET` | `/library/installed` | Return `{ games }` from the upstream installed library API | -| `GET` | `/health` | Lightweight backend health check | +| `GET` | `/health` | Local desktop bridge health check | ## Environment variables | Variable | Default | Description | |---|---|---| -| `VITE_API_BASE_URL` | *(empty)* | Frontend API origin. Leave empty in local dev and same-origin deployments so the browser stays on first-party `/auth`, `/me`, and `/library` routes. Never set this to a Boosteroid origin. | -| `SERVER_PORT` | `3001` | Backend bridge port. | +| `VITE_API_BASE_URL` | *(empty)* | Renderer API origin. Leave empty in local Electron dev so Vite proxies first-party routes. Never point this at a Boosteroid origin. | +| `SERVER_PORT` | `3001` | Local Electron bridge port. | | `UPSTREAM_BASE_URL` | `https://cloud.boosteroid.com` | Upstream Boosteroid base URL. | -| `SESSION_SECRET` | `openstroid-development-session-secret` | Secret used to encrypt/authenticate the session cookie. Replace in production. | +| `SESSION_SECRET` | `openstroid-development-session-secret` | Secret used to encrypt/authenticate the OpenStroid session cookie. Replace in production. | | `SESSION_COOKIE_NAME` | `openstroid_session` | First-party auth cookie name. | | `SESSION_TTL_SECONDS` | `2592000` | Cookie/session lifetime in seconds. | | `COOKIE_SECURE` | `false` in dev, `true` in production | Whether to mark the auth cookie as `Secure`. | -| `APP_ORIGIN` | *(unset)* | Optional allowed browser origin when frontend and backend run on different origins. | +| `APP_ORIGIN` | *(unset)* | Optional allowed renderer/browser origin if frontend and bridge are split. | | `AUTH_CAPTURE_ARTIFACT_DIR` | `/.runtime/auth-captures` | Directory where raw capture JSON artifacts are written. | -| `BROWSER_USER_DATA_DIR` | `/.runtime/browser-profile` | Persistent Chrome profile directory used for the backend-launched login browser. | +| `BROWSER_USER_DATA_DIR` | `/.runtime/browser-profile` | Persistent profile directory for the browser fallback path. | | `BROWSER_LOGIN_TIMEOUT_MS` | `300000` | Maximum time allowed for manual upstream login before timing out. | | `BROWSER_LOGIN_POLL_INTERVAL_MS` | `1500` | Interval used while checking whether upstream auth cookies/tokens are ready. | -| `BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS` | `30000` | Initial page navigation timeout for the launched browser. | -| `BROWSER_HEADLESS` | `false` | Whether to launch the backend browser headlessly. Visible mode is strongly recommended for Turnstile. | -| `BROWSER_CHANNEL` | `chrome` when no explicit executable is found | Preferred browser channel passed to Playwright. Use a real Chrome install when possible. | +| `BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS` | `30000` | Initial page navigation timeout for the browser fallback. | +| `BROWSER_HEADLESS` | `false` | Whether to launch the browser fallback headlessly. Visible mode is recommended. | +| `BROWSER_CHANNEL` | `chrome` when no explicit executable is found | Preferred browser channel for the fallback path. | | `BROWSER_EXECUTABLE_PATH` | auto-detects system Chrome/Chromium | Optional explicit browser executable path. | -| `BROWSER_LOCALE` | `en-US` | Locale for the persistent browser profile. | +| `BROWSER_LOCALE` | `en-US` | Locale for the browser fallback profile. | | `BROWSER_LAUNCH_ARGS` | *(unset)* | Comma-separated extra browser launch arguments. | -| `BACKEND_PROXY_TARGET` | `http://localhost:3001` | Vite-only proxy target for local frontend development. | +| `BACKEND_PROXY_TARGET` | `http://127.0.0.1:3001` | Vite-only proxy target for local renderer development. | +| `ELECTRON_RENDERER_URL` | `http://127.0.0.1:3000` | Dev-only renderer URL opened by Electron. | -## Production notes +## Turnstile and capture notes -- Build with `npm run build`. -- Start the bridge with `npm run start`. -- Serve the frontend and backend from the same origin when possible. -- In local dev, keep `VITE_API_BASE_URL` empty so Vite proxies first-party routes to the backend bridge. The frontend must never call `https://cloud.boosteroid.com` directly. -- For local dev, load the unpacked extension from `extension/openstroid-capture/` into Chrome and keep its backend URL pointed at `http://localhost:3001`. -- Set a strong `SESSION_SECRET` and keep `COOKIE_SECURE=true` in production. -- If you deploy the frontend separately, set `VITE_API_BASE_URL` to the backend origin and `APP_ORIGIN` to the frontend origin. - -## Chrome extension setup (local dev) - -1. Open `chrome://extensions`. -2. Enable Developer Mode. -3. Choose **Load unpacked**. -4. Select `extension/openstroid-capture/` from this repo. -5. Open the extension popup and confirm the backend URL is `http://localhost:3001`. -6. In OpenStroid, click **Start extension capture** and copy the pairing code shown on the login page into the extension popup. -7. Log in on Boosteroid in the same Chrome profile. - -## Turnstile / capture notes - -- The extension flow is primary because it runs in the user’s real Chrome profile rather than an automated backend browser context. -- The extension can read relevant Boosteroid cookies through Chrome’s cookie APIs, including HttpOnly cookies when host permissions are granted. -- Response metadata is captured through `webRequest`, while JSON response payloads are captured from page `fetch`/XHR instrumentation on Boosteroid pages. -- OpenStroid does not automate the login form or Turnstile challenge. The extension only observes requests, responses, cookies, and page visits after the user interacts normally. -- The backend-owned browser fallback remains available as a secondary option for environments where the extension cannot be used. +- The extension-first path is the primary auth mechanism because it operates inside the user's real Chrome profile. +- The extension can read relevant Boosteroid cookies via Chrome cookie APIs, including HttpOnly cookies when host permissions are granted. +- Response metadata is captured via `webRequest`, while JSON auth/session payloads are captured from page `fetch`/XHR instrumentation on Boosteroid pages. +- The extension never automates the Turnstile widget or login form. It passively observes the real session after the user acts normally. +- The pairing code remains required before the extension can discover an active ingest token from the local bridge. +- The Electron-managed browser fallback remains secondary and should not be treated as the primary product path. ## Project structure ```text +electron/ +└── main.ts # Electron main process, window creation, bridge startup server/ -├── config.ts # Runtime config for the auth bridge -├── index.ts # Express server + first-party endpoints +├── app.ts # Reusable local bridge app and startup helpers +├── config.ts # Runtime config for bridge + fallback browser +├── index.ts # Standalone bridge entrypoint for non-Electron use └── lib/ - ├── crypto.ts # Encrypted cookie helpers - ├── session.ts # Session cookie read/write helpers - └── upstream.ts # Boosteroid upstream client + refresh handling + ├── crypto.ts # Encrypted cookie helpers + ├── session.ts # Session cookie read/write helpers + ├── upstream.ts # Boosteroid upstream client + refresh handling + └── authCapture.ts # Extension-first capture/session orchestration src/ -├── api/ # First-party API client and endpoint wrappers -├── auth/ # AuthContext + legacy storage cleanup -├── components/ # Shared UI components -├── layouts/ # Page layout shells -├── pages/ # Route-level page components -├── theme/ # Mantine theme customization -└── types/ # Shared TypeScript interfaces +├── api/ # First-party API client and endpoint wrappers +├── auth/ # AuthContext + legacy storage cleanup +├── components/ # Shared UI components +├── layouts/ # Desktop page layout shells +├── pages/ # Desktop route-level page components +├── theme/ # Mantine theme customization +└── types/ # Shared TypeScript interfaces extension/ -└── openstroid-capture/ # Unpacked Chrome extension for real-browser Boosteroid capture +└── openstroid-capture/ # Unpacked Chrome extension for real-browser Boosteroid capture ``` -## Current features (auth bridge refactor) - -- **Chrome extension capture** — the user completes the real Boosteroid + Turnstile login in their own Chrome profile while the extension captures upstream state -- **Server-managed session** — session bootstrap checks `/auth/session` and keeps upstream tokens out of browser JavaScript -- **Debug evidence capture** — raw upstream cookies, payloads, and request metadata are inspectable from the UI and saved to `.runtime/auth-captures` -- **My Games library** — installed games loaded through first-party backend routes with existing loading, empty, and error states -- **Logout** — clears the OpenStroid session and attempts upstream logout -- **Server-side refresh** — upstream token refresh happens on the backend with refresh de-duplication for concurrent requests -- **Local dev proxy** — Vite proxies first-party backend routes to the local bridge instead of proxying directly to Boosteroid - ## Scripts | Command | Description | |---|---| -| `npm run dev` | Start backend bridge and frontend dev server together | -| `npm run dev:server` | Start backend bridge in watch mode | -| `npm run dev:web` | Start Vite frontend dev server | -| `npm run build` | Type-check and build backend + frontend for production | -| `npm run start` | Run the built backend bridge | +| `npm run dev` | Start Vite and launch the Electron desktop shell | +| `npm run dev:web` | Start the Vite renderer only | +| `npm run dev:electron` | Launch Electron against the dev renderer | +| `npm run dev:bridge` | Run the local bridge without Electron | +| `npm run build` | Type-check and build renderer, bridge, and Electron main process | +| `npm run start` | Run the built Electron desktop app | +| `npm run start:bridge` | Run only the built local bridge | | `npm run preview` | Preview the frontend build | | `npm run lint` | Run ESLint | diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 0000000..e45db32 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,69 @@ +import type { AddressInfo } from 'node:net'; +import { app, BrowserWindow, shell } from 'electron'; +import { startBridgeServer } from '../server/app.js'; +import { serverConfig } from '../server/config.js'; + +const DEV_RENDERER_URL = process.env.ELECTRON_RENDERER_URL ?? 'http://127.0.0.1:3000'; + +let bridgePort = serverConfig.port; + +function createMainWindow() { + const window = new BrowserWindow({ + width: 1440, + height: 960, + minWidth: 1100, + minHeight: 760, + title: 'OpenStroid', + autoHideMenuBar: true, + backgroundColor: '#11131a', + webPreferences: { + contextIsolation: true, + sandbox: true, + }, + }); + + window.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: 'deny' }; + }); + + if (serverConfig.isProduction) { + void window.loadURL(`http://127.0.0.1:${bridgePort}`); + } else { + void window.loadURL(DEV_RENDERER_URL); + } +} + +async function bootstrapDesktopApp() { + const server = startBridgeServer(serverConfig.port); + const address = server.address(); + if (address && typeof address !== 'string') { + bridgePort = (address as AddressInfo).port; + } + + createMainWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createMainWindow(); + } + }); + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); + + app.on('before-quit', () => { + server.close(); + }); +} + +app.whenReady().then(() => { + app.setName('OpenStroid'); + if (process.platform === 'win32') { + app.setAppUserModelId('ai.capy.openstroid'); + } + void bootstrapDesktopApp(); +}); diff --git a/extension/openstroid-capture/background.js b/extension/openstroid-capture/background.js index a353051..b00d2f8 100644 --- a/extension/openstroid-capture/background.js +++ b/extension/openstroid-capture/background.js @@ -1,4 +1,4 @@ -const DEFAULT_BACKEND_BASE_URL = 'http://localhost:3001'; +const DEFAULT_BACKEND_BASE_URL = 'http://127.0.0.1:3001'; const AUTH_COOKIE_NAMES = ['access_token', 'refresh_token', 'boosteroid_auth', 'qr_auth_code']; const RELEVANT_PATH_PATTERNS = [ '/api/v1/auth/login', diff --git a/extension/openstroid-capture/popup.html b/extension/openstroid-capture/popup.html index 402f40a..05a4b9a 100644 --- a/extension/openstroid-capture/popup.html +++ b/extension/openstroid-capture/popup.html @@ -63,7 +63,7 @@

OpenStroid Capture

- +
diff --git a/package-lock.json b/package-lock.json index 0ed6ba5..0e66c1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@tabler/icons-react": "^3.41.1", "axios": "^1.15.0", "cookie-parser": "^1.4.7", + "electron": "^37.2.1", "express": "^5.2.1", "playwright": "^1.53.1", "react": "^19.2.4", @@ -292,6 +293,27 @@ "node": ">=6.9.0" } }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1543,12 +1565,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@tabler/icons": { "version": "3.41.1", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.1.tgz", @@ -1597,6 +1643,18 @@ "@types/node": "*" } }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1650,6 +1708,12 @@ "@types/send": "*" } }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -1664,11 +1728,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1715,6 +1787,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -1736,6 +1817,16 @@ "@types/node": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", @@ -2246,6 +2337,14 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "optional": true + }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -2292,6 +2391,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2301,6 +2409,33 @@ "node": ">= 0.8" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2402,6 +2537,18 @@ "node": ">=12" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2636,6 +2783,33 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2643,6 +2817,51 @@ "dev": true, "license": "MIT" }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2671,6 +2890,13 @@ "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT", + "optional": true + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -2707,6 +2933,24 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron": { + "version": "37.10.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-37.10.3.tgz", + "integrity": "sha512-3IjCGSjQmH50IbW2PFveaTzK+KwcFX9PEhE7KXb9v5IT8cLAiryAN7qezm/XzODhDRlLu0xKG1j8xWBtZ/bx/g==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.335", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", @@ -2714,6 +2958,21 @@ "dev": true, "license": "ISC" }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2730,6 +2989,24 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -2784,6 +3061,13 @@ "node": ">= 0.4" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT", + "optional": true + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3134,6 +3418,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3154,6 +3458,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3304,6 +3617,20 @@ "node": ">= 0.8" } }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3394,6 +3721,21 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -3420,6 +3762,37 @@ "node": ">=10.13.0" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/globals": { "version": "17.5.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", @@ -3433,6 +3806,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3445,6 +3835,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3455,6 +3876,19 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3520,6 +3954,12 @@ "react-is": "^16.7.0" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3540,6 +3980,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -3709,7 +4162,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { @@ -3732,6 +4184,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3745,11 +4204,19 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -4080,6 +4547,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4090,6 +4566,19 @@ "yallist": "^3.0.2" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4141,6 +4630,15 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4202,6 +4700,18 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4223,6 +4733,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4262,6 +4782,15 @@ "node": ">= 0.8.0" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4378,6 +4907,12 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4608,6 +5143,15 @@ "node": ">= 0.8.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4641,6 +5185,16 @@ "node": ">=10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4666,6 +5220,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4883,6 +5449,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4902,6 +5474,36 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", @@ -4985,12 +5587,18 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT", + "optional": true + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -5042,6 +5650,35 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -5200,6 +5837,13 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5279,6 +5923,18 @@ "postcss": "^8.3.3" } }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5508,9 +6164,17 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5795,6 +6459,16 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index e3e41f2..20a30fa 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,15 @@ "description": "Open-source cloud gaming client", "type": "module", "scripts": { - "dev": "concurrently -k \"npm:dev:server\" \"npm:dev:web\"", - "dev:server": "tsx watch server/index.ts", + "dev": "concurrently -k \"npm:dev:web\" \"npm:dev:electron\"", "dev:web": "vite", - "build": "tsc -b && tsc -p tsconfig.server.json && vite build", + "dev:electron": "sh -c 'until curl -sf http://127.0.0.1:3000 >/dev/null 2>&1; do sleep 1; done; NODE_OPTIONS=\"--import tsx\" ELECTRON_RENDERER_URL=http://127.0.0.1:3000 electron electron/main.ts'", + "dev:bridge": "tsx watch server/index.ts", + "build": "tsc -b && tsc -p tsconfig.server.json && tsc -p tsconfig.electron.json && vite build", "lint": "eslint .", "preview": "vite preview", - "start": "node build/server/server/index.js" + "start": "electron build/electron/electron/main.js", + "start:bridge": "node build/server/server/index.js" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -23,6 +25,7 @@ "@tabler/icons-react": "^3.41.1", "axios": "^1.15.0", "cookie-parser": "^1.4.7", + "electron": "^37.2.1", "express": "^5.2.1", "playwright": "^1.53.1", "react": "^19.2.4", diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..423589f --- /dev/null +++ b/server/app.ts @@ -0,0 +1,312 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import express, { type NextFunction, type Request, type Response } from 'express'; +import cookieParser from 'cookie-parser'; +import { serverConfig } from './config.js'; +import { + authCaptureManager, + type CaptureMethod, + type ExtensionPairingRequest, +} from './lib/authCapture.js'; +import { clearSession, createSession, readSession, writeSession } from './lib/session.js'; +import { + getInstalledGamesUpstream, + getUpstreamUser, + logoutUpstream, + normalizeError, + withRefresh, +} from './lib/upstream.js'; + +export function createBridgeApp() { + const app = express(); + + app.set('trust proxy', 1); + app.use(express.json({ limit: '2mb' })); + app.use(cookieParser()); + + app.use((req, res, next) => { + const origin = req.header('origin'); + if (origin?.startsWith('chrome-extension://')) { + res.header('Access-Control-Allow-Origin', origin); + res.header('Vary', 'Origin'); + } else if (serverConfig.appOrigin) { + res.header('Access-Control-Allow-Origin', serverConfig.appOrigin); + res.header('Vary', 'Origin'); + } + + res.header('Access-Control-Allow-Credentials', 'true'); + res.header('Access-Control-Allow-Headers', 'Content-Type'); + res.header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + + if (req.method === 'OPTIONS') { + res.sendStatus(204); + return; + } + + next(); + }); + + function sendSession(res: Response, user: Record | null) { + res.json({ + authenticated: Boolean(user), + user, + }); + } + + function requireSession(req: Request, res: Response) { + const session = readSession(req); + if (!session) { + clearSession(res); + res.status(401).json({ message: 'Authentication required.' }); + return null; + } + return session; + } + + function sendCaptureStatus(req: Request, res: Response, captureId?: string) { + const capture = authCaptureManager.getStatus(captureId); + if (!capture) { + res.status(404).json({ message: 'No login capture session found.' }); + return; + } + + let sessionEstablished = false; + if (capture.status === 'succeeded' && capture.bridgeSession) { + const existingSession = readSession(req); + const nextSession = createSession({ + accessToken: capture.bridgeSession.accessToken, + refreshToken: capture.bridgeSession.refreshToken, + userData: capture.bridgeSession.userData, + user: capture.bridgeSession.user, + existing: existingSession ?? capture.bridgeSession, + }); + writeSession(res, nextSession); + sessionEstablished = true; + } + + res.json({ + id: capture.id, + status: capture.status, + startedAt: capture.startedAt, + updatedAt: capture.updatedAt, + completedAt: capture.completedAt, + timeoutAt: capture.timeoutAt, + loginUrl: capture.loginUrl, + finalUrl: capture.finalUrl, + errors: capture.errors, + eventCount: capture.eventCount, + user: capture.userPayload, + captureMethod: capture.captureMethod, + sessionEstablished, + }); + } + + app.get('/health', (_req, res) => { + res.json({ ok: true, desktopBridge: true }); + }); + + app.post('/auth/login/start', async (req, res, next) => { + try { + const requestedMethod = req.body?.method; + const method: CaptureMethod = requestedMethod === 'browser' ? 'browser' : 'extension'; + const capture = await authCaptureManager.start(method); + clearSession(res); + res.status(202).json(capture); + } catch (error) { + next(error); + } + }); + + app.get('/auth/login/status', (req, res) => { + sendCaptureStatus(req, res); + }); + + app.get('/auth/login/status/:id', (req, res) => { + sendCaptureStatus(req, res, req.params.id); + }); + + app.post('/auth/login/cancel', async (req, res, next) => { + try { + const captureId = typeof req.body?.id === 'string' ? req.body.id : undefined; + const capture = await authCaptureManager.cancel(captureId); + if (!capture) { + res.status(404).json({ message: 'No active login capture session found.' }); + return; + } + + clearSession(res); + res.json({ + id: capture.id, + status: capture.status, + startedAt: capture.startedAt, + updatedAt: capture.updatedAt, + completedAt: capture.completedAt, + timeoutAt: capture.timeoutAt, + loginUrl: capture.loginUrl, + finalUrl: capture.finalUrl, + errors: capture.errors, + eventCount: capture.eventCount, + user: capture.userPayload, + captureMethod: capture.captureMethod, + sessionEstablished: false, + }); + } catch (error) { + next(error); + } + }); + + app.post('/auth/extension/active', (req, res) => { + const body = req.body as ExtensionPairingRequest | undefined; + const pairingCode = typeof body?.pairingCode === 'string' ? body.pairingCode : ''; + if (!pairingCode) { + res.status(400).json({ message: 'Extension pairing code is required.' }); + return; + } + + const active = authCaptureManager.getActiveExtensionSession(pairingCode); + if (!active) { + res.status(404).json({ message: 'No active extension capture session for that pairing code.' }); + return; + } + + res.json(active); + }); + + app.post('/auth/extension/capture', async (req, res, next) => { + try { + const artifact = await authCaptureManager.ingestExtensionCapture(req.body); + res.status(202).json({ + id: artifact.id, + status: artifact.status, + captureMethod: artifact.captureMethod, + completedAt: artifact.completedAt, + }); + } catch (error) { + next(error); + } + }); + + app.post('/auth/logout', async (req, res) => { + const session = readSession(req); + clearSession(res); + + if (session) { + try { + await logoutUpstream(session.accessToken); + } catch { + res.status(204).end(); + return; + } + } + + res.status(204).end(); + }); + + app.get('/auth/session', async (req, res, next) => { + const session = readSession(req); + if (!session) { + clearSession(res); + sendSession(res, null); + return; + } + + try { + const refreshed = await withRefresh(session, getUpstreamUser); + const nextSession = createSession({ + accessToken: refreshed.session.accessToken, + refreshToken: refreshed.session.refreshToken, + userData: refreshed.session.userData, + user: refreshed.result, + existing: refreshed.session, + }); + + writeSession(res, nextSession); + sendSession(res, refreshed.result); + } catch (error) { + clearSession(res); + next(error); + } + }); + + app.get('/auth/debug/capture', (req, res) => { + const session = requireSession(req, res); + if (!session) return; + + const { artifact, path: artifactPath } = authCaptureManager.getLatestArtifact(); + if (!artifact) { + res.status(404).json({ message: 'No auth capture artifact available yet.' }); + return; + } + + res.json({ + artifact, + artifactPath, + requestedBy: { + email: session.user?.email ?? null, + updatedAt: session.updatedAt, + }, + }); + }); + + app.get('/me', async (req, res, next) => { + const session = requireSession(req, res); + if (!session) return; + + try { + const refreshed = await withRefresh(session, getUpstreamUser); + const nextSession = createSession({ + accessToken: refreshed.session.accessToken, + refreshToken: refreshed.session.refreshToken, + userData: refreshed.session.userData, + user: refreshed.result, + existing: refreshed.session, + }); + + writeSession(res, nextSession); + res.json({ user: refreshed.result }); + } catch (error) { + clearSession(res); + next(error); + } + }); + + app.get('/library/installed', async (req, res, next) => { + const session = requireSession(req, res); + if (!session) return; + + try { + const refreshed = await withRefresh(session, getInstalledGamesUpstream); + writeSession(res, refreshed.session); + res.json({ games: refreshed.result }); + } catch (error) { + clearSession(res); + next(error); + } + }); + + const indexFile = path.join(serverConfig.distDir, 'index.html'); + if (fs.existsSync(indexFile)) { + app.use(express.static(serverConfig.distDir)); + app.get(/.*/, (_req, res) => { + res.sendFile(indexFile); + }); + } + + app.use((error: unknown, _req: Request, res: Response, next: NextFunction) => { + void next; + const normalized = normalizeError(error); + res.status(normalized.status).json({ + message: normalized.message, + error: normalized.details, + }); + }); + + return app; +} + +export function startBridgeServer(port = serverConfig.port) { + const app = createBridgeApp(); + return app.listen(port, '127.0.0.1', () => { + console.log(`OpenStroid desktop bridge listening on http://127.0.0.1:${port}`); + }); +} diff --git a/server/index.ts b/server/index.ts index 3a6d8f5..76c11e6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,305 +1,4 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import express, { type NextFunction, type Request, type Response } from 'express'; -import cookieParser from 'cookie-parser'; +import { startBridgeServer } from './app.js'; import { serverConfig } from './config.js'; -import { - authCaptureManager, - type CaptureMethod, - type ExtensionPairingRequest, -} from './lib/authCapture.js'; -import { clearSession, createSession, readSession, writeSession } from './lib/session.js'; -import { - getInstalledGamesUpstream, - getUpstreamUser, - logoutUpstream, - normalizeError, - withRefresh, -} from './lib/upstream.js'; -const app = express(); - -app.set('trust proxy', 1); -app.use(express.json({ limit: '2mb' })); -app.use(cookieParser()); - -app.use((req, res, next) => { - const origin = req.header('origin'); - if (origin?.startsWith('chrome-extension://')) { - res.header('Access-Control-Allow-Origin', origin); - res.header('Vary', 'Origin'); - } else if (serverConfig.appOrigin) { - res.header('Access-Control-Allow-Origin', serverConfig.appOrigin); - res.header('Vary', 'Origin'); - } - - res.header('Access-Control-Allow-Credentials', 'true'); - res.header('Access-Control-Allow-Headers', 'Content-Type'); - res.header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); - - if (req.method === 'OPTIONS') { - res.sendStatus(204); - return; - } - - next(); -}); - -function sendSession(res: Response, user: Record | null) { - res.json({ - authenticated: Boolean(user), - user, - }); -} - -function requireSession(req: Request, res: Response) { - const session = readSession(req); - if (!session) { - clearSession(res); - res.status(401).json({ message: 'Authentication required.' }); - return null; - } - return session; -} - -function sendCaptureStatus(req: Request, res: Response, captureId?: string) { - const capture = authCaptureManager.getStatus(captureId); - if (!capture) { - res.status(404).json({ message: 'No login capture session found.' }); - return; - } - - let sessionEstablished = false; - if (capture.status === 'succeeded' && capture.bridgeSession) { - const existingSession = readSession(req); - const nextSession = createSession({ - accessToken: capture.bridgeSession.accessToken, - refreshToken: capture.bridgeSession.refreshToken, - userData: capture.bridgeSession.userData, - user: capture.bridgeSession.user, - existing: existingSession ?? capture.bridgeSession, - }); - writeSession(res, nextSession); - sessionEstablished = true; - } - - res.json({ - id: capture.id, - status: capture.status, - startedAt: capture.startedAt, - updatedAt: capture.updatedAt, - completedAt: capture.completedAt, - timeoutAt: capture.timeoutAt, - loginUrl: capture.loginUrl, - finalUrl: capture.finalUrl, - errors: capture.errors, - eventCount: capture.eventCount, - user: capture.userPayload, - captureMethod: capture.captureMethod, - sessionEstablished, - }); -} - -app.get('/health', (_req, res) => { - res.json({ ok: true }); -}); - -app.post('/auth/login/start', async (req, res, next) => { - try { - const requestedMethod = req.body?.method; - const method: CaptureMethod = requestedMethod === 'browser' ? 'browser' : 'extension'; - const capture = await authCaptureManager.start(method); - clearSession(res); - res.status(202).json(capture); - } catch (error) { - next(error); - } -}); - -app.get('/auth/login/status', (req, res) => { - sendCaptureStatus(req, res); -}); - -app.get('/auth/login/status/:id', (req, res) => { - sendCaptureStatus(req, res, req.params.id); -}); - -app.post('/auth/login/cancel', async (req, res, next) => { - try { - const captureId = typeof req.body?.id === 'string' ? req.body.id : undefined; - const capture = await authCaptureManager.cancel(captureId); - if (!capture) { - res.status(404).json({ message: 'No active login capture session found.' }); - return; - } - - clearSession(res); - res.json({ - id: capture.id, - status: capture.status, - startedAt: capture.startedAt, - updatedAt: capture.updatedAt, - completedAt: capture.completedAt, - timeoutAt: capture.timeoutAt, - loginUrl: capture.loginUrl, - finalUrl: capture.finalUrl, - errors: capture.errors, - eventCount: capture.eventCount, - user: capture.userPayload, - captureMethod: capture.captureMethod, - sessionEstablished: false, - }); - } catch (error) { - next(error); - } -}); - -app.post('/auth/extension/active', (req, res) => { - const body = req.body as ExtensionPairingRequest | undefined; - const pairingCode = typeof body?.pairingCode === 'string' ? body.pairingCode : ''; - if (!pairingCode) { - res.status(400).json({ message: 'Extension pairing code is required.' }); - return; - } - - const active = authCaptureManager.getActiveExtensionSession(pairingCode); - if (!active) { - res.status(404).json({ message: 'No active extension capture session for that pairing code.' }); - return; - } - - res.json(active); -}); - -app.post('/auth/extension/capture', async (req, res, next) => { - try { - const artifact = await authCaptureManager.ingestExtensionCapture(req.body); - res.status(202).json({ - id: artifact.id, - status: artifact.status, - captureMethod: artifact.captureMethod, - completedAt: artifact.completedAt, - }); - } catch (error) { - next(error); - } -}); - -app.post('/auth/logout', async (req, res) => { - const session = readSession(req); - clearSession(res); - - if (session) { - try { - await logoutUpstream(session.accessToken); - } catch { - res.status(204).end(); - return; - } - } - - res.status(204).end(); -}); - -app.get('/auth/session', async (req, res, next) => { - const session = readSession(req); - if (!session) { - clearSession(res); - sendSession(res, null); - return; - } - - try { - const refreshed = await withRefresh(session, getUpstreamUser); - const nextSession = createSession({ - accessToken: refreshed.session.accessToken, - refreshToken: refreshed.session.refreshToken, - userData: refreshed.session.userData, - user: refreshed.result, - existing: refreshed.session, - }); - - writeSession(res, nextSession); - sendSession(res, refreshed.result); - } catch (error) { - clearSession(res); - next(error); - } -}); - -app.get('/auth/debug/capture', (req, res) => { - const session = requireSession(req, res); - if (!session) return; - - const { artifact, path: artifactPath } = authCaptureManager.getLatestArtifact(); - if (!artifact) { - res.status(404).json({ message: 'No auth capture artifact available yet.' }); - return; - } - - res.json({ - artifact, - artifactPath, - requestedBy: { - email: session.user?.email ?? null, - updatedAt: session.updatedAt, - }, - }); -}); - -app.get('/me', async (req, res, next) => { - const session = requireSession(req, res); - if (!session) return; - - try { - const refreshed = await withRefresh(session, getUpstreamUser); - const nextSession = createSession({ - accessToken: refreshed.session.accessToken, - refreshToken: refreshed.session.refreshToken, - userData: refreshed.session.userData, - user: refreshed.result, - existing: refreshed.session, - }); - - writeSession(res, nextSession); - res.json({ user: refreshed.result }); - } catch (error) { - clearSession(res); - next(error); - } -}); - -app.get('/library/installed', async (req, res, next) => { - const session = requireSession(req, res); - if (!session) return; - - try { - const refreshed = await withRefresh(session, getInstalledGamesUpstream); - writeSession(res, refreshed.session); - res.json({ games: refreshed.result }); - } catch (error) { - clearSession(res); - next(error); - } -}); - -const indexFile = path.join(serverConfig.distDir, 'index.html'); -if (fs.existsSync(indexFile)) { - app.use(express.static(serverConfig.distDir)); - app.get(/.*/, (_req, res) => { - res.sendFile(indexFile); - }); -} - -app.use((error: unknown, _req: Request, res: Response, next: NextFunction) => { - void next; - const normalized = normalizeError(error); - res.status(normalized.status).json({ - message: normalized.message, - error: normalized.details, - }); -}); - -app.listen(serverConfig.port, () => { - console.log(`OpenStroid auth bridge listening on http://localhost:${serverConfig.port}`); -}); +startBridgeServer(serverConfig.port); diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index a989094..56fc998 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -29,7 +29,7 @@ export function AppHeader() { variant="gradient" gradient={{ from: 'brand.4', to: 'accent.4', deg: 135 }} > - OpenStroid + OpenStroid Desktop diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 8a545e6..c60ed6b 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -21,6 +21,7 @@ import { IconArrowRight, IconBrandChrome, IconCheck, + IconDeviceDesktop, IconExternalLink, IconPlayerStop, IconRefresh, @@ -49,14 +50,14 @@ function describeStatus(status: LoginCaptureStatus, method: LoginCaptureMethod | switch (status) { case 'starting': return method === 'browser' - ? 'Launching the backend browser fallback.' - : 'Creating an extension capture session.'; + ? 'Launching the Electron-managed backend browser fallback.' + : 'Creating an extension capture session in the desktop bridge.'; case 'awaiting_user': return method === 'browser' - ? 'Complete login in the backend-launched browser window.' - : 'Use the OpenStroid Chrome extension while you log in on Boosteroid in your real browser.'; + ? 'Complete login in the Electron-managed backend browser window.' + : 'Use the OpenStroid Chrome extension while you log in on Boosteroid in your normal Chrome profile.'; case 'succeeded': - return 'Captured upstream auth state. OpenStroid is establishing its own first-party session.'; + return 'Captured upstream auth state. OpenStroid Desktop is establishing its local first-party session.'; case 'failed': return 'Capture failed before a usable upstream session was received.'; case 'cancelled': @@ -130,8 +131,8 @@ export function LoginPage() { const fallback = axiosErr.response?.status === 409 ? 'A login capture is already running. Follow that session or cancel it first.' : method === 'browser' - ? 'Could not start the backend browser fallback.' - : 'Could not start the extension capture session.'; + ? 'Could not start the Electron-managed backend browser fallback.' + : 'Could not start the desktop extension capture session.'; setServerError(axiosErr.response?.data?.message || fallback); } finally { setIsSubmitting(false); @@ -197,7 +198,7 @@ export function LoginPage() { }} >
- + <Text component="span" inherit variant="gradient" gradient={{ from: 'brand.3', to: 'accent.4', deg: 135 }}> - OpenStroid + OpenStroid Desktop </Text> - Primary login capture now runs through a Chrome extension in your real browser profile. + Electron coordinates local bridge capture while your Chrome extension watches the real Boosteroid browser session. @@ -235,14 +236,14 @@ export function LoginPage() { > - - Sign in with Boosteroid + + Connect your Boosteroid session - Install the unpacked OpenStroid Chrome extension, start a capture session here, then log in to Boosteroid in your normal Chrome profile. The extension observes the real browser session and sends upstream cookies and auth evidence back to the OpenStroid backend. + This desktop app is the primary OpenStroid client. Start a capture session here, paste the pairing code into the companion Chrome extension, then log in on Boosteroid in your normal Chrome profile. The extension sends upstream cookies and auth evidence to the local Electron bridge. - + @@ -251,10 +252,10 @@ export function LoginPage() { size="sm" icon={} > - Load the unpacked extension from {EXTENSION_PATH} into Chrome. - In the extension popup, keep the backend URL set to http://localhost:3001 for local dev. - When prompted by the extension popup, paste the pairing code from this page so the extension can fetch the active ingest token. - Start capture below, then complete login on boosteroid.com in the same Chrome profile. + Run OpenStroid Desktop so the local bridge is available on http://127.0.0.1:3001. + Load the unpacked Chrome extension from {EXTENSION_PATH}. + Set the extension backend URL to http://127.0.0.1:3001 and paste the pairing code shown below. + Log in on boosteroid.com in that same Chrome profile. {serverError && ( @@ -272,15 +273,15 @@ export function LoginPage() { onClick={() => void startCapture('extension')} loading={isSubmitting} > - Start extension capture + Start desktop extension capture - {capture ? describeStatus(capture.status, capture.captureMethod) : 'Start a capture session, then complete login in Chrome with the extension enabled.'} + {capture ? describeStatus(capture.status, capture.captureMethod) : 'Start a capture session in OpenStroid Desktop, then finish the pairing flow in Chrome.'} {capture && ( <> Capture ID: {capture.id} @@ -314,7 +315,7 @@ export function LoginPage() { {capture && !TERMINAL_STATUSES.has(capture.status) && ( - Polling capture status every {POLL_INTERVAL_MS / 1000}s. + Electron is polling capture status every {POLL_INTERVAL_MS / 1000}s. )} {capture?.status === 'succeeded' && ( @@ -328,7 +329,7 @@ export function LoginPage() { navigate(from, { replace: true }); }} > - Continue to library + Continue to my library )} {capture && !TERMINAL_STATUSES.has(capture.status) && ( @@ -348,9 +349,9 @@ export function LoginPage() { - Optional backend browser fallback + Fallback: Electron-managed browser capture - Use only if the extension flow is unavailable. This still launches a backend-owned browser and may be less reliable against Turnstile than the extension path. + Use only if the extension path is temporarily unavailable. This launches a backend-owned browser from the desktop bridge and remains secondary to the Chrome-extension flow. - Load unpacked extension from {EXTENSION_PATH} + Companion extension folder: {EXTENSION_PATH} - +
diff --git a/tsconfig.electron.json b/tsconfig.electron.json new file mode 100644 index 0000000..045dc6f --- /dev/null +++ b/tsconfig.electron.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "outDir": "./build/electron", + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["electron/**/*.ts", "server/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts index 86d449b..bafed51 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -const backendUrl = process.env.BACKEND_PROXY_TARGET || 'http://localhost:3001'; +const backendUrl = process.env.BACKEND_PROXY_TARGET || 'http://127.0.0.1:3001'; export default defineConfig({ plugins: [react()],