diff --git a/.env.example b/.env.example index f34607c..569c101 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,36 @@ -# Frontend: leave empty when the browser should use same-origin requests. -# 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= -# 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. +# 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 +# BROWSER_LOGIN_POLL_INTERVAL_MS=1500 +# BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS=30000 +# BROWSER_HEADLESS=false +# BROWSER_CHANNEL=chrome +# BROWSER_EXECUTABLE_PATH=/absolute/path/to/chrome-or-chromium +# BROWSER_LOCALE=en-US +# BROWSER_LAUNCH_ARGS=--start-maximized + +# 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 -# 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://127.0.0.1:3001 +# - Paste the pairing code shown in OpenStroid Desktop into the extension popup before logging in 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..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, and an Express auth bridge. +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,101 +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) +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` -`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`. +Use `npm run dev:bridge` only if you need the local bridge without launching Electron. -## Auth bridge architecture +## Chrome extension setup -OpenStroid is no longer a browser-direct Boosteroid client. +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. -- The frontend sends login, 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` - - `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. -- 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. +## Desktop capture flow -## API surface +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. -The backend exposes normalized first-party endpoints: +## Local bridge API surface | Method | Route | Description | |---|---|---| -| `POST` | `/auth/login` | Login with email/password/Turnstile, create first-party session cookie, return `{ authenticated, user }` | -| `POST` | `/auth/logout` | Clear first-party session and attempt upstream logout | +| `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 | +| `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 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. | +| `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. | -| `BACKEND_PROXY_TARGET` | `http://localhost:3001` | Vite-only proxy target for local frontend development. | - -## Production notes - -- Build with `npm run build`. -- Start the bridge with `npm run start`. -- Serve the frontend and backend from the same origin when possible. -- 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. +| `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 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 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 browser fallback profile. | +| `BROWSER_LAUNCH_ARGS` | *(unset)* | Comma-separated extra browser launch arguments. | +| `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. | + +## Turnstile and capture notes + +- 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 ``` -## Current features (auth bridge refactor) - -- **Secure login flow** — email/password authentication with Cloudflare Turnstile routed through the backend bridge -- **Server-managed session** — session bootstrap checks `/auth/session` and keeps upstream tokens out of browser JavaScript -- **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 new file mode 100644 index 0000000..b00d2f8 --- /dev/null +++ b/extension/openstroid-capture/background.js @@ -0,0 +1,236 @@ +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', + '/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 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, 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, pairingCode, 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, pairingCode, 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, + pairingCode, + 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', 'pairingCode']); + const nextState = {}; + if (!current.backendBaseUrl) { + nextState.backendBaseUrl = DEFAULT_BACKEND_BASE_URL; + } + if (!current.pairingCode) { + nextState.pairingCode = ''; + } + if (Object.keys(nextState).length > 0) { + await chrome.storage.local.set(nextState); + } +}); + +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 getStoredState().then(({ backendBaseUrl, pairingCode }) => { + sendResponse({ + backendBaseUrl, + pairingCode, + observedEventCount: observedResponses.length, + lastSubmittedCaptureId, + }); + }); + return true; + } + + 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; + } + + 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..05a4b9a --- /dev/null +++ b/extension/openstroid-capture/popup.html @@ -0,0 +1,76 @@ + + + + + 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..b6f41cb --- /dev/null +++ b/extension/openstroid-capture/popup.js @@ -0,0 +1,38 @@ +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'); + +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; + pairingInput.value = response.pairingCode || ''; + setStatus(response); + }); +} + +saveButton.addEventListener('click', () => { + chrome.runtime.sendMessage( + { + type: 'openstroid:set-settings', + backendBaseUrl: backendInput.value, + pairingCode: pairingInput.value, + }, + () => refreshState(), + ); +}); + +openButton.addEventListener('click', () => { + chrome.tabs.create({ url: 'https://boosteroid.com/' }); +}); + +refreshState(); diff --git a/package-lock.json b/package-lock.json index 69d76d5..0e66c1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,11 +17,12 @@ "@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", "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", @@ -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", @@ -2791,7 +3075,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3135,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", @@ -3155,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", @@ -3305,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", @@ -3395,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", @@ -3421,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", @@ -3434,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", @@ -3446,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", @@ -3456,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", @@ -3521,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", @@ -3541,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", @@ -3710,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": { @@ -3733,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", @@ -3746,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" @@ -4081,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", @@ -4091,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", @@ -4142,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", @@ -4203,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", @@ -4224,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", @@ -4263,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", @@ -4379,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", @@ -4399,6 +4933,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", @@ -4565,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", @@ -4598,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", @@ -4623,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", @@ -4809,16 +5418,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", @@ -4850,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", @@ -4869,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", @@ -4952,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", @@ -5009,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", @@ -5167,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", @@ -5246,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", @@ -5475,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", @@ -5762,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 c41c6b2..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,11 +25,12 @@ "@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", "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/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/config.ts b/server/config.ts index dd5c265..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,8 +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 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, '..'); +const browserExecutablePath = resolvePreferredBrowserExecutable(process.env.BROWSER_EXECUTABLE_PATH); export const serverConfig = { nodeEnv, @@ -24,5 +42,25 @@ 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'), + 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( + process.env.BROWSER_LAUNCH_NAVIGATE_TIMEOUT_MS, + 30 * 1000, + ), + browserHeadless: parseBoolean(process.env.BROWSER_HEADLESS, false), + 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()) + .filter(Boolean), distDir: path.resolve(projectRoot, 'dist'), } as const; diff --git a/server/index.ts b/server/index.ts index 3ac1640..76c11e6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,187 +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 { clearSession, createSession, readSession, writeSession } from './lib/session.js'; -import { - getInstalledGamesUpstream, - getUpstreamUser, - loginUpstream, - logoutUpstream, - normalizeError, - withRefresh, -} from './lib/upstream.js'; -const app = express(); - -app.set('trust proxy', 1); -app.use(express.json()); -app.use(cookieParser()); - -app.use((req, res, next) => { - 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; -} - -app.get('/health', (_req, res) => { - res.json({ ok: true }); -}); - -app.post('/auth/login', 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 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, - }); - - writeSession(res, session); - sendSession(res, user); - } 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('/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/server/lib/authCapture.ts b/server/lib/authCapture.ts new file mode 100644 index 0000000..2e2e5ac --- /dev/null +++ b/server/lib/authCapture.ts @@ -0,0 +1,752 @@ +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 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 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; + type: 'page' | 'request' | 'response' | 'note' | 'error'; + method?: string; + url?: string; + status?: number; + payload?: unknown; + headers?: Record; + cookieNames?: string[]; + message?: string; + source?: 'browser' | 'extension'; +} + +export interface StoredCookie { + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: string; +} + +export interface CaptureArtifact { + id: string; + captureMethod: CaptureMethod; + 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; + ingestSource?: 'browser' | 'extension'; + extensionMetadata?: Record; +} + +export interface CaptureStartResult { + id: string; + status: CaptureStatus; + timeoutAt: string; + captureMethod: CaptureMethod; + loginUrl: string; + extensionPairingCode?: string; +} + +export interface ExtensionActiveCapture { + id: string; + ingestToken: string; + timeoutAt: string; + loginUrl: string; +} + +export interface ExtensionPairingRequest { + pairingCode: 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; + extensionPairingCode: string | null; + 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 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)); +} + +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, + }; +} + +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 generatePairingCode(): string { + return randomUUID().replace(/-/g, '').slice(0, 8).toUpperCase(); +} + +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 }); +} + +async function ensureBrowserProfileDir(): Promise { + await fs.mkdir(serverConfig.browserUserDataDir, { 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; +} + +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) + ); + } + `); +} + +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(method: CaptureMethod = 'extension'): Promise { + this.cleanupFinishedActive(); + 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, captureMethod: this.active.method }; + throw error; + } + + const now = Date.now(); + const capture: CaptureRuntime = { + id: randomUUID(), + method, + ingestToken: randomUUID(), + extensionPairingCode: method === 'extension' ? generatePairingCode() : null, + status: method === 'extension' ? 'awaiting_user' : '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; + 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, + extensionPairingCode: capture.extensionPairingCode ?? undefined, + }; + } + + async cancel(id?: string): Promise { + const capture = this.active; + if (!capture) return null; + if (id && capture.id !== id) return null; + capture.cancelled = true; + 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); + } + 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 }; + } + + 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, + 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; + } + + 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; + } 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 { + 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(() => []); + 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 && isTerminalStatus(this.active.status)) { + this.active = null; + } + } + + private pushEvent(capture: CaptureRuntime, event: CaptureEvent): void { + capture.events.push(event); + capture.updatedAtMs = Date.now(); + } + + 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.', + }); + } + }); + + page.on('request', (request) => { + const url = request.url(); + if (!isRelevantUrl(url)) return; + this.pushEvent(capture, { + timestamp: new Date().toISOString(), + type: 'request', + source: 'browser', + 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', + source: 'browser', + 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', + source: 'browser', + message: error.message, + }); + }); + } + + private async runBrowserCapture(capture: CaptureRuntime): Promise { + try { + await ensureArtifactDir(); + await ensureBrowserProfileDir(); + + const context = await chromium.launchPersistentContext(serverConfig.browserUserDataDir, { + headless: serverConfig.browserHeadless, + channel: serverConfig.browserChannel || undefined, + executablePath: serverConfig.browserExecutablePath || undefined, + locale: serverConfig.browserLocale, + timezoneId: 'UTC', + viewport: null, + ignoreHTTPSErrors: false, + args: dedupeArgs(DEFAULT_BROWSER_ARGS, serverConfig.browserLaunchArgs), + ignoreDefaultArgs: ['--enable-automation'], + }); + await applyStealthDefaults(context); + + 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.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.`, + }); + + await page.bringToFront().catch(() => undefined); + 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 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, + }); + await this.finalize(capture, 'succeeded', 'Successfully captured authenticated Boosteroid session via backend browser fallback.'); + 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..ca5a222 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -1,12 +1,37 @@ +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: { - 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..734c2e3 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -1,7 +1,14 @@ 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, + LoginCaptureMethod, + LoginCaptureSessionStatus, + LoginCaptureStartResponse, + User, +} from '../types'; function extractSession(data: Record): AuthSession { return { @@ -10,10 +17,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(method: LoginCaptureMethod = 'extension'): Promise { + const { data } = await apiClient.post(API_CONFIG.endpoints.loginStart, { method }); + 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/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/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..c60ed6b 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,114 +1,186 @@ -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, + Divider, + 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, + IconBrandChrome, + IconCheck, + IconDeviceDesktop, + IconExternalLink, + IconPlayerStop, + IconRefresh, + IconPuzzle, +} 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, + 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, method: LoginCaptureMethod | undefined): string { + switch (status) { + case 'starting': + return method === 'browser' + ? '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 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 Desktop is establishing its local first-party session.'; + case 'failed': + return 'Capture failed before a usable upstream session was received.'; + case 'cancelled': + return 'Capture was cancelled.'; + 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 [extensionPairingCode, setExtensionPairingCode] = useState(null); + 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(() => () => stopPolling(), [stopPolling]); + const startCapture = useCallback(async (method: LoginCaptureMethod) => { + 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(method); + setExtensionPairingCode(started.extensionPairingCode ?? null); + const initialStatus = await getLoginCaptureStatus(started.id); + setCapture(initialStatus); + void pollStatus(started.id); + if (method === 'extension') { + window.open(started.loginUrl, '_blank', 'noopener,noreferrer'); + } } 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. Follow that session or cancel it first.' + : method === 'browser' + ? '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); + } + }, [pollStatus, stopPolling]); + const handleCancel = useCallback(async () => { + if (!capture) return; + stopPolling(); + setIsSubmitting(true); + try { + const cancelled = await cancelLoginCapture(capture.id); + setCapture(cancelled); + setExtensionPairingCode(null); + } 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 +197,8 @@ export function LoginPage() { justifyContent: 'center', }} > - - - - -
- + - - OS - + OS - - <Text - component="span" - inherit - variant="gradient" - gradient={{ from: 'brand.3', to: 'accent.4', deg: 135 }} - > - OpenStroid + <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 Desktop </Text> - Cloud gaming, open source + Electron coordinates local bridge capture while your Chrome extension watches the real Boosteroid browser session. @@ -213,115 +234,144 @@ export function LoginPage() { backdropFilter: 'blur(20px)', }} > -
- - - 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')} - /> + + + + Connect your Boosteroid session + + 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. + + + + + + - } - size="md" - autoComplete="current-password" - disabled={isLoading} - classNames={{ input: classes.loginInput }} - {...form.getInputProps('password')} - /> + } + > + 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 && ( + } color="red" variant="light"> + {serverError} + + )} + + + + + + + + {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} + 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}} + {capture.errors.length > 0 && ( + {capture.errors[capture.errors.length - 1]} + )} + + )} + {capture && !TERMINAL_STATUSES.has(capture.status) && ( + + + Electron is polling capture status every {POLL_INTERVAL_MS / 1000}s. + + )} + {capture?.status === 'succeeded' && ( + + )} + {capture && !TERMINAL_STATUSES.has(capture.status) && ( + + )} + + + + + + + Fallback: Electron-managed browser capture + + 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. + + + + + Companion extension folder: {EXTENSION_PATH} + + - + - - 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..9cdb34f 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,103 @@ export interface AuthSession { user: User | null; } +export type LoginCaptureStatus = + | 'starting' + | 'awaiting_user' + | 'succeeded' + | 'failed' + | 'cancelled' + | 'timed_out'; + +export type LoginCaptureMethod = 'extension' | 'browser'; + +export interface LoginCaptureStartResponse { + id: string; + status: LoginCaptureStatus; + timeoutAt: string; + captureMethod: LoginCaptureMethod; + loginUrl: string; + extensionPairingCode?: string; +} + +export interface LoginCaptureSessionStatus { + id: string; + status: LoginCaptureStatus; + startedAt: string; + updatedAt: string; + completedAt: string | null; + timeoutAt: string; + loginUrl: string; + finalUrl: string | null; + errors: string[]; + eventCount: number; + user: User | null; + captureMethod: LoginCaptureMethod; + 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; + source?: 'browser' | 'extension'; +} + +export interface StoredCookie { + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: string; +} + +export interface CaptureArtifact { + id: string; + captureMethod: LoginCaptureMethod; + 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; + ingestSource?: 'browser' | 'extension'; + extensionMetadata?: Record; +} + +export interface AuthCaptureDebugResponse { + artifact: CaptureArtifact; + artifactPath: string | null; + requestedBy: { + email: string | null; + updatedAt: number; + }; +} + export interface InstalledGame { id: number; name: string; 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()],