feat(auth): implement browser capture login flow#2
feat(auth): implement browser capture login flow#2zortos293 wants to merge 6 commits intocapy/scaffold-login-my-gamesfrom
Conversation
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
| body: JSON.stringify(payload), | ||
| }); | ||
|
|
||
| if (response.ok) { |
There was a problem hiding this comment.
[🟡 Medium] [🔵 Bug]
@extension/openstroid-capture/background.js treats any 2xx from /auth/extension/capture as a final success marker:
// extension/openstroid-capture/background.js
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;
}I verified in @server/app.ts:175-183 that this endpoint always returns HTTP 202, and in @server/lib/authCapture.ts:435-438 that uploads missing either token are finalized as failed. Because submitCapture() short-circuits when lastSubmittedCaptureId === data.id on later events, the first partial/invalid upload bricks the active capture and drops the real cookie/network evidence that arrives afterward. Parse the response body and only mark the capture as submitted when the backend reports a terminal success, or keep retrying until success/timeout.
| artifact.userPayload = (capture.bridgeSession?.user as Record<string, unknown> | undefined) ?? null; | ||
|
|
||
| capture.artifact = artifact; | ||
| capture.persistedPath = await persistArtifact(artifact); |
There was a problem hiding this comment.
[🟡 Medium] [🔵 Bug]
finalize() persists the artifact before closing the Playwright resources, with no try/finally around cleanup:
// server/lib/authCapture.ts
capture.artifact = artifact;
capture.persistedPath = await persistArtifact(artifact);
this.latestArtifact = artifact;
this.latestArtifactPath = capture.persistedPath;If persistArtifact() fails (permissions, disk full, prune race), the function throws after marking the capture terminal but before page/context/browser.close() runs. In the browser fallback path that leaves an orphaned Chromium process and a locked persistent profile behind, and subsequent finalize() retries are no-ops because the status is already terminal. Move the Playwright shutdown into a finally block so resource cleanup still happens when persistence fails.
| }); | ||
| } | ||
|
|
||
| window.addEventListener('openstroid:network-event', (event) => { |
There was a problem hiding this comment.
[🟡 Medium] [🟡 Investigate]
The content script forwards any openstroid:network-event dispatched on window without proving it came from the injected hook:
// extension/openstroid-capture/content.js
window.addEventListener('openstroid:network-event', (event) => {
const detail = event.detail;
if (!detail) return;
chrome.runtime.sendMessage({
type: 'openstroid:network-event',
event: detail,
});
});After verifying @extension/openstroid-capture/background.js:161-163, any script running on a matched Boosteroid page can synthesize a fake /api/v1/auth/login or /api/v1/auth/refresh-token event and have it accepted as real capture evidence. On the server, @server/lib/authCapture.ts:435-438 turns a bogus upload into a terminal failed capture, so an injected or third-party page script can sabotage the login flow. Authenticate the bridge between page-hook.js and content.js (for example with a per-load secret embedded into the injected script) or only use page-sourced events as supplemental data after independently observed cookies prove the browser is actually authenticated.
| if (current && (!id || current.id === id)) { | ||
| return this.toArtifact(current); | ||
| } | ||
| if (this.latestArtifact && (!id || this.latestArtifact.id === id)) { |
There was a problem hiding this comment.
[🟡 Medium] [🔵 Bug]
getStatus() falls back to the last persisted artifact whenever no id is provided:
// server/lib/authCapture.ts
if (this.latestArtifact && (!id || this.latestArtifact.id === id)) {
return this.latestArtifact;
}Because this manager also restores latestArtifact from disk on startup, the public no-id /auth/login/status path can surface an old succeeded capture long after logout or restart. The caller in @server/app.ts writes a fresh first-party session for any successful capture, and @src/pages/LoginPage.tsx exposes a visible “Refresh capture status” action that calls the no-id endpoint, so a user can be logged back into the stale captured account without starting a new auth flow. Restrict no-id status reads to the active capture only, and keep historical artifacts behind the dedicated debug endpoint.
| } | ||
| }, []); | ||
|
|
||
| useEffect(() => { |
There was a problem hiding this comment.
[🟠 High] [🔵 Bug]
This mount-time effect immediately fetches and displays AuthCaptureDebugResponse, which includes raw upstream cookies, payloads, and bridge session data:
// src/components/AuthCaptureDebugPanel.tsx
useEffect(() => {
void load();
}, [load]);I verified that the component is mounted unconditionally in @src/pages/LoginPage.tsx:374 and @src/pages/LibraryPage.tsx:128, and the backend route at @server/app.ts:231 serves /auth/debug/capture for any authenticated session with no development/feature-flag guard. That means a normal production user can open the library page and copy raw Boosteroid auth artifacts from the UI. Gate this panel behind an explicit debug-only flag and enforce the same restriction server-side (or redact secrets) so captured credentials never reach standard frontend builds.
| "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'", |
There was a problem hiding this comment.
[🟡 Medium] [🔵 Bug]
The new primary dev entrypoint shells out through sh -c, waits with curl, and sets env vars with POSIX inline syntax. npm runs scripts under cmd.exe on Windows by default, so on a standard Windows setup npm run dev cannot execute this command even though the repo clearly targets Windows too (@electron/main.ts contains win32-specific handling). Replace this with a cross-platform launcher (cross-env/cross-env-shell, a small Node wait script, or a platform-neutral wait-on utility) so the documented Electron-first dev flow works on supported desktop platforms.
// package.json
"dev": "concurrently -k \"npm:dev:web\" \"npm:dev:electron\"",
"dev:web": "vite",
"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"| 7. When OpenStroid Desktop shows a pairing code, paste it into the extension popup. | ||
| 8. Start desktop extension capture from the Electron app. |
There was a problem hiding this comment.
[🟡 Medium] [🔵 Bug]
The setup sequence in @README.md is reversed: the pairing code does not exist until the desktop app starts an extension capture (startLoginCapture('extension') returns extensionPairingCode and LoginPage only renders it after that call succeeds), so a user following these steps literally cannot complete step 7 before step 8. This breaks the primary documented onboarding flow for the extension path. Swap the steps so capture starts first and the pairing code is entered second.
# README.md
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.| 7. When OpenStroid Desktop shows a pairing code, paste it into the extension popup. | |
| 8. Start desktop extension capture from the Electron app. | |
| 7. Start desktop extension capture from the Electron app. | |
| 8. When OpenStroid Desktop shows a pairing code, paste it into the extension popup. |
| "lint": "eslint .", | ||
| "preview": "vite preview", | ||
| "start": "node build/server/server/index.js" | ||
| "start": "electron build/electron/electron/main.js", |
There was a problem hiding this comment.
[🟠 High] [🔵 Bug]
npm run start is documented in @README.md as the built desktop entrypoint, but this script never sets NODE_ENV=production. Tracing the runtime shows @electron/main.ts only loads the built app when serverConfig.isProduction is true, and @server/config.ts derives that flag solely from process.env.NODE_ENV; without it, Electron falls back to http://127.0.0.1:3000 and the packaged app will not start unless a dev server is already running. Set production mode explicitly in the script (preferably cross-platform) or change the main-process production check so the built launcher does not depend on an external env var.
// package.json
"build": "tsc -b && tsc -p tsconfig.server.json && tsc -p tsconfig.electron.json && vite build",
"lint": "eslint .",
"preview": "vite preview",
"start": "electron build/electron/electron/main.js"| )} | ||
|
|
||
| <Box mt="xl"> | ||
| <AuthCaptureDebugPanel compact title="Debug: latest upstream capture" /> |
There was a problem hiding this comment.
[🟠 High] [🔵 Bug]
This new unconditional mount puts the debug panel on the normal authenticated /library route, so every logged-in user now triggers getAuthCaptureDebug() and can view/copy the latest capture artifact. I verified that AuthCaptureDebugPanel fetches on mount and that GET /auth/debug/capture only checks for an authenticated session before returning the latest persisted artifact, which includes raw cookies / bridge session data. That makes this a real production data-exposure change, not a dev-only convenience. Gate this render behind a development-only or explicit debug flag before shipping.
// src/pages/LibraryPage.tsx
<Box mt="xl">
<AuthCaptureDebugPanel compact title="Debug: latest upstream capture" />
</Box>| } | ||
|
|
||
| let sessionEstablished = false; | ||
| if (capture.status === 'succeeded' && capture.bridgeSession) { |
There was a problem hiding this comment.
[🟠 High] [🔵 Bug]
sendCaptureStatus() now rewrites the session cookie for any successful capture returned by authCaptureManager.getStatus(captureId), but getStatus(undefined) falls back to the persisted latestArtifact when there is no active capture. /auth/logout only clears the cookie; it never invalidates that stored capture. As a result, after logout a user can hit the new status endpoint again (for example via LoginPage’s manual “Refresh capture status” path) and the bridge will mint a fresh local session from stale capture evidence.
// server/app.ts
const capture = authCaptureManager.getStatus(captureId);
...
if (capture.status === 'succeeded' && capture.bridgeSession) {
...
writeSession(res, nextSession);
}This breaks logout semantics and can restore a session even when the upstream logout call failed or was skipped. Fix by ensuring id-less status reads do not establish sessions from latestArtifact, or by explicitly invalidating persisted capture sessions on logout before this branch can run again.
| const buildServerSuffix = `${path.sep}build${path.sep}server${path.sep}server`; | ||
| const projectRoot = configDir.endsWith(buildServerSuffix) | ||
| ? path.resolve(configDir, '..', '..', '..') | ||
| : path.resolve(configDir, '..'); |
There was a problem hiding this comment.
[🟡 Medium] [🔵 Bug]
projectRoot only recognizes the standalone server bundle layout, but tsconfig.electron.json also emits server/**/*.ts into build/electron/server, and @electron/main.ts imports that copy in production. That means the newly added .runtime defaults resolve under <repo>/build/electron/.runtime/... instead of <project>/.runtime/..., so the browser profile and raw capture artifacts follow the build output rather than the app root documented in @README.md and @.env.example.
// server/config.ts
const buildServerSuffix = `${path.sep}build${path.sep}server${path.sep}server`;
const projectRoot = configDir.endsWith(buildServerSuffix)
? path.resolve(configDir, '..', '..', '..')
: path.resolve(configDir, '..');| const buildServerSuffix = `${path.sep}build${path.sep}server${path.sep}server`; | |
| const projectRoot = configDir.endsWith(buildServerSuffix) | |
| ? path.resolve(configDir, '..', '..', '..') | |
| : path.resolve(configDir, '..'); | |
| const buildServerSuffixes = [ | |
| `${path.sep}build${path.sep}server${path.sep}server`, | |
| `${path.sep}build${path.sep}electron${path.sep}server`, | |
| ]; | |
| const projectRoot = buildServerSuffixes.some((suffix) => configDir.endsWith(suffix)) | |
| ? path.resolve(configDir, '..', '..', '..') | |
| : path.resolve(configDir, '..'); |
| } | ||
|
|
||
| try { | ||
| const latest = await getLoginCaptureStatus(); |
There was a problem hiding this comment.
[🟡 Medium] [🔵 Bug]
extensionPairingCode is only populated from the initial /auth/login/start response, but the re-attach path only restores LoginCaptureSessionStatus, whose contract does not include that code. After a reload, a manual Refresh capture status, or a 409 “already running” response, the page can recover the capture ID/status but cannot re-display the pairing code that the extension popup requires, so the user cannot complete that in-progress extension session without cancelling and starting over. Fix by either returning the pairing code for active extension captures in the status response or persisting/restoring it client-side by capture ID.
// src/pages/LoginPage.tsx
const latest = await getLoginCaptureStatus();
setCapture(latest);
if (!TERMINAL_STATUSES.has(latest.status)) {
void pollStatus(latest.id);
}| setCapture(initialStatus); | ||
| void pollStatus(started.id); | ||
| if (method === 'extension') { | ||
| window.open(started.loginUrl, '_blank', 'noopener,noreferrer'); |
There was a problem hiding this comment.
[🟡 Medium] [🔵 Bug]
The extension flow is explicitly Chrome-specific, but this code launches started.loginUrl with window.open(...), and the Electron host handles all window opens by calling shell.openExternal(url) in @electron/main.ts. That means users whose system default browser is not Chrome will be sent to Firefox/Safari/etc., where the OpenStroid Chrome extension is unavailable and the capture cannot complete. The same contract mismatch is duplicated by the Open Boosteroid in Chrome button later in this file. Fix by removing the automatic open here or routing through a desktop-specific mechanism that actually targets Chrome instead of the generic default browser.
// src/pages/LoginPage.tsx
if (method === 'extension') {
window.open(started.loginUrl, '_blank', 'noopener,noreferrer');
}| } | ||
|
|
||
| export function normalizeError(error: unknown): { status: number; message: string; details?: unknown } { | ||
| if (error instanceof Error) { |
There was a problem hiding this comment.
[🟡 Medium] [🔵 Bug]
normalizeError now handles any Error with a numeric status before it reaches the Axios-specific branch. In this repo axios@^1.15.0 is used, and Axios documents AxiosError as carrying a status, so upstream HTTP failures will hit this early return and lose the richer payload parsing at lines 42-57. That changes bridge responses from payload-derived messages/details to the generic Axios summary ("Request failed with status code ...") for routes such as /auth/session, /me, and /library/installed. Exclude Axios errors from this new branch, or move the axios.isAxiosError check above it.
// server/lib/upstream.ts
if (error instanceof Error) {
const typedError = error as Error & { status?: number; details?: unknown };
if (typeof typedError.status === 'number') {| if (error instanceof Error) { | |
| if (error instanceof Error && !axios.isAxiosError(error)) { |
| extensionPairingCode?: string; | ||
| } | ||
|
|
||
| export interface LoginCaptureSessionStatus { |
There was a problem hiding this comment.
[🟡 Medium] [🔵 Bug]
LoginCaptureSessionStatus cannot represent the one extension-only value the UI needs to resume an in-flight extension capture after a refresh/reopen. The added contract is:
// src/types/index.ts
export interface LoginCaptureSessionStatus {
user: User | null;
captureMethod: LoginCaptureMethod;
sessionEstablished: boolean;
}Unlike LoginCaptureStartResponse, this status shape has no extensionPairingCode. That omission is observable in the current flow: @src/pages/LoginPage.tsx only hydrates extensionPairingCode from startLoginCapture(), but handleRefresh() and any page reload recover state solely from getLoginCaptureStatus(). The extension handshake in @extension/openstroid-capture/background.js cannot continue without that pairing code because /auth/extension/active is keyed only by pairingCode. Result: an extension capture can be resumed by ID/status, but not actually completed after a refresh because the user no longer sees the code they must paste into the extension. Add extensionPairingCode?: string to the session-status contract and have /auth/login/status populate it for captureMethod === 'extension'.
| "noUnusedParameters": true, | ||
| "noFallthroughCasesInSwitch": true | ||
| }, | ||
| "include": ["electron/**/*.ts", "server/**/*.ts"] |
There was a problem hiding this comment.
[🟠 High] [🔵 Bug]
Including the server sources in the Electron emit changes where server/config.js runs from, but the config code only recognizes the standalone bridge layout under build/server/server. With this config, production Electron emits server/config.js to build/electron/server, so import.meta.dirname no longer matches the existing heuristic and projectRoot resolves to build/electron instead of the repo root. That makes serverConfig.distDir point at build/electron/dist, while Vite still outputs the renderer to the top-level dist/; npm run start then launches build/electron/electron/main.js, which loads the bridge URL but the bridge has no built UI to serve. Fix by updating server/config.ts to handle the Electron output layout as a built location, or by emitting/reusing the server build in a path that preserves the current runtime assumption.
// tsconfig.electron.json
"outDir": "./build/electron",
"types": ["node"],
...
"include": ["electron/**/*.ts", "server/**/*.ts"]
This PR replaces direct credential login with a manual browser capture flow. The backend now launches a real Playwright browser window for the user to log in on Boosteroid, captures the resulting authenticated state, and persists raw evidence. The frontend polls for status and establishes a first-party session upon success.
AuthCaptureManagerinserver/lib/authCapture.tsto manage Playwright browser lifecycle, capture cookies/payloads, validate tokens, save artifacts to.runtime/auth-captures/, and prevent concurrent captures.POST /auth/loginwithPOST /auth/login/start,GET /auth/login/status[/:id],POST /auth/login/cancel, andGET /auth/debug/captureroutes.POST /auth/login/statusto write the first-party encrypted session cookie when capture succeeds, preserving existing/auth/sessionand/library/installedbehavior.LoginPagecredential/Turnstile form and replaced it with capture start/cancel/status polling UI.AuthCaptureDebugPanelcomponent and embedded it inLoginPageandLibraryPageto inspect raw upstream artifacts via the gated debug endpoint.AuthContextto userefreshSessioninstead of credential-basedlogin, and alignedsrc/typeswith new capture artifacts.README.md.