diff --git a/.agents/skills/lightfast-desktop-signin/SKILL.md b/.agents/skills/lightfast-desktop-signin/SKILL.md new file mode 100644 index 000000000..fb8ac2b8f --- /dev/null +++ b/.agents/skills/lightfast-desktop-signin/SKILL.md @@ -0,0 +1,115 @@ +--- +name: lightfast-desktop-signin +description: | + Sign the Lightfast desktop app in from an agent harness without opening Dia + or the system default browser. Drives a single agent-browser session over + a deterministic stdout JSON event grammar. Triggers when the user wants the + desktop app authed for tRPC/E2E/renderer testing, or asks to "sign the + desktop app in for me". Dev-only: refuses against pk_live_ Clerk keys or + non-localhost LIGHTFAST_API_URL. +--- + +# Lightfast Desktop Sign-In Skill + +End-to-end agent runbook for getting the Electron desktop app signed in via +a custom URL scheme + PKCE flow. Replaces the older "log-grep loopback URL, +attach CDP to renderer" choreography with a single line of stdout JSON in, +single agent-browser session, structured completion event out. + +## When to use + +You need the desktop app to hold a real Clerk JWT — to drive tRPC procedures +that require `authedProcedure`, run E2E flows against signed-in renderer +surfaces, or smoke-test the auth mesh end-to-end. If you only need a JWT for +HTTP calls (not the desktop), use `lightfast-clerk` instead — it's faster. + +## Preconditions + +- Local dev mesh up on `:3024` (`pnpm dev:full` or `pnpm dev:app`). +- Clerk publishable key is `pk_test_*` (refuse `pk_live_*`). +- `LIGHTFAST_API_URL` either unset or pointing at `http://localhost:*` (refuse + any non-localhost host). +- `agent-browser` installed and reachable on PATH. +- The desktop app must already be running before you trigger the redirect. + Cold-launching via OS dispatch is unreliable in dev (unpackaged Electron + registers `lightfast-dev://` against `com.github.electron`, not Lightfast's + bundle id, so LaunchServices relaunches bare Electron without our entrypoint). + Packaged builds are fine; agents run unpackaged dev builds. + +## Required environment + +| Var | Value | Why | +| --- | --- | --- | +| `LIGHTFAST_DESKTOP_AGENT_MODE` | `1` | Skips `shell.openExternal` (Dia never opens) and emits structured stdout JSON instead. Without this, the flow opens the user's default browser and the agent has no way to read the URL. | +| `AGENT_BROWSER_HEADED` | `true` | **Mandatory.** Headless Chrome for Testing silently drops `lightfast-dev://` navigations — no prompt, no error, no fallback browser hand-off. The desktop's `app.on('open-url')` never fires and the agent times out with no diagnostic signal. Validated 2026-04-25 spike. | +| `LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS` | `30000` (recommended) | Default is 5 minutes for human users; agents want fast CI feedback. | + +## Stdout event grammar + +Desktop emits one JSON object per line on stdout (only when AGENT_MODE=1): + +| Event | When | Payload | +| --- | --- | --- | +| `auth_already_signed_in` | App start, token already persisted | `{}` | +| `auth_signin_url` | App start, token absent — sign-in begun | `{ url: string }` | +| `auth_signed_in` | Exchange succeeded, token persisted | `{}` | +| `auth_signin_failed` | Timeout / exchange 4xx / state mismatch / persist failed | `{ reason: string }` | + +Every `auth_signin_url` is followed by exactly one terminal event +(`auth_signed_in` OR `auth_signin_failed`) per in-flight sign-in. + +## The flow + +```sh +# 1. Start desktop in agent mode. Auto-triggers sign-in on app-ready when +# no token is persisted; idempotent if already signed in. +LIGHTFAST_DESKTOP_AGENT_MODE=1 \ +LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS=30000 \ + pnpm --filter @lightfast/desktop dev > /tmp/desktop.log 2>&1 & + +# 2. Read the first lifecycle event (auth_already_signed_in OR auth_signin_url). +EVENT=$(timeout 30 sh -c "tail -F /tmp/desktop.log | jq -rcM --unbuffered 'select(.event)' | head -1") +case "$(echo "$EVENT" | jq -r .event)" in + auth_already_signed_in) echo "Already signed in"; exit 0 ;; + auth_signin_url) SIGNIN_URL=$(echo "$EVENT" | jq -r .url) ;; + *) echo "Unexpected event: $EVENT"; exit 1 ;; +esac + +# 3. Headed agent-browser navigates to the URL. Clerk completes, browser +# dispatches lightfast-dev://auth/callback?code=…&state=…, OS routes to +# the running desktop, exchange runs, token persists. +AGENT_BROWSER_HEADED=true agent-browser open "$SIGNIN_URL" + +# 4. Block on completion event. +RESULT=$(timeout 30 sh -c "tail -F /tmp/desktop.log | jq -rcM --unbuffered 'select(.event==\"auth_signed_in\" or .event==\"auth_signin_failed\")' | head -1") +echo "$RESULT" | jq -e '.event=="auth_signed_in"' > /dev/null +``` + +No CDP attach to the renderer, no log-grep — just JSON parse off stdout. Verify +with `pgrep -l Dia` before/after that no Dia process was spawned. + +## Failure modes + +| Symptom | Most likely cause | +| --- | --- | +| `auth_signin_failed{reason:"timeout"}` | **Forgot `AGENT_BROWSER_HEADED=true`.** Headless Chromium dropped the `lightfast-dev://` navigation silently. This is the #1 cause. | +| `auth_signin_failed{reason:"exchange_failed"}` | API unreachable, or the code expired (30s TTL). Check `pnpm dev:full` is running and Upstash Redis env is configured. | +| `auth_signin_failed{reason:"persist_failed"}` | Electron `safeStorage` unavailable on this host (rare; usually macOS Keychain access denied). | +| `auth_signin_failed{reason:"handler_error"}` | Custom-scheme URL parsing or unexpected callback shape. Check the desktop log; surface to engineering. | +| No event at all within 30s | Desktop didn't start in agent mode, or `pnpm dev:full` mesh is down on `:3024`. Check the bootstrap line in stdout. | + +## Hygiene + +- `agent-browser close --all` between runs if you need a *fresh* sign-in. + Otherwise the daemon profile retains Clerk session cookies and the next run + will short-circuit through Clerk silently — fine if that's what you want. +- Sign-out: dispatch the existing IPC `auth:sign-out` from the renderer, or + delete `~/Library/Application Support/Lightfast Dev/auth.bin` (macOS). + +## Refusal conditions + +Refuse to run when: +- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` starts with `pk_live_`. +- `LIGHTFAST_API_URL` is set to anything other than a localhost URL. + +These are the same guardrails as `lightfast-clerk` — this skill is dev-only. diff --git a/api/app/src/__tests__/resolve-clerk-session.test.ts b/api/app/src/__tests__/resolve-clerk-session.test.ts index b130c44a1..6aeda534c 100644 --- a/api/app/src/__tests__/resolve-clerk-session.test.ts +++ b/api/app/src/__tests__/resolve-clerk-session.test.ts @@ -89,6 +89,19 @@ describe("resolveClerkSession", () => { expect(authMock).toHaveBeenCalledWith({ treatPendingAsSignedOut: false }); }); + it("returns null when the Bearer JWT is invalid and no cookie session exists", async () => { + verifyTokenMock.mockRejectedValueOnce(new Error("jwt expired")); + authMock.mockResolvedValueOnce({ userId: null, orgId: null }); + + const session = await resolveClerkSession( + new Headers({ authorization: "Bearer expired.jwt" }) + ); + + expect(session).toBeNull(); + expect(verifyTokenMock).toHaveBeenCalledTimes(1); + expect(authMock).toHaveBeenCalledWith({ treatPendingAsSignedOut: false }); + }); + it("returns null when neither Bearer nor cookie produce a session", async () => { authMock.mockResolvedValueOnce({ userId: null, orgId: null }); diff --git a/microfrontends.json b/apps/app/microfrontends.json similarity index 100% rename from microfrontends.json rename to apps/app/microfrontends.json diff --git a/apps/app/package.json b/apps/app/package.json index e87a9035d..56065601a 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -87,15 +87,17 @@ "@repo/vitest-config": "workspace:*", "@tailwindcss/postcss": "catalog:tailwind4", "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@types/lodash.merge": "^4.6.9", "@types/node": "catalog:", "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", + "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "catalog:", "@vitest/expect": "catalog:", "babel-plugin-react-compiler": "catalog:", "dotenv-cli": "catalog:", - "happy-dom": "^20.9.0", + "happy-dom": "catalog:", "import-in-the-middle": "catalog:", "postcss": "catalog:tailwind4", "require-in-the-middle": "catalog:", diff --git a/apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.test.tsx b/apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.test.tsx new file mode 100644 index 000000000..71315cda9 --- /dev/null +++ b/apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.test.tsx @@ -0,0 +1,513 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const captureExceptionMock = vi.fn(); +const captureMessageMock = vi.fn(); + +vi.mock("@vendor/observability/sentry-nextjs", () => ({ + captureException: (...args: unknown[]) => captureExceptionMock(...args), + captureMessage: (...args: unknown[]) => captureMessageMock(...args), +})); + +const useAuthMock = vi.fn(); +vi.mock("@vendor/clerk/client", () => ({ + useAuth: () => useAuthMock(), +})); + +const useSearchParamsMock = vi.fn(() => new URLSearchParams()); +vi.mock("next/navigation", () => ({ + useSearchParams: () => useSearchParamsMock(), +})); + +// Import under test AFTER mocks +const { ClientAuthBridge } = await import("./client-auth-bridge"); + +function mockSignedInWithToken(token: string | null) { + useAuthMock.mockReturnValue({ + isLoaded: true, + isSignedIn: true, + getToken: vi.fn(async () => token), + }); +} + +function mockSignedOut() { + useAuthMock.mockReturnValue({ + isLoaded: true, + isSignedIn: false, + getToken: vi.fn(async () => null), + }); +} + +function mockNotLoaded() { + useAuthMock.mockReturnValue({ + isLoaded: false, + isSignedIn: false, + getToken: vi.fn(async () => null), + }); +} + +describe("ClientAuthBridge — POST mode", () => { + let fetchSpy: ReturnType; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof globalThis.fetch; + captureExceptionMock.mockClear(); + captureMessageMock.mockClear(); + useAuthMock.mockClear(); + useSearchParamsMock.mockClear(); + useSearchParamsMock.mockReturnValue( + new URLSearchParams("state=S1&callback=http://127.0.0.1:9999/callback") + ); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + cleanup(); + }); + + it("POSTs token + state as JSON body with credentials omit, then renders success panel on 204", async () => { + mockSignedInWithToken("real-jwt"); + fetchSpy.mockResolvedValue(new Response(null, { status: 204 })); + + render( + ({ + url: "http://127.0.0.1:9999/callback", + state: "S1", + })} + mode="post" + subtitle="You'll be redirected" + title="Authenticating…" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Signed in to Lightfast")).toBeTruthy(); + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("http://127.0.0.1:9999/callback"); + expect(init.method).toBe("POST"); + expect(init.credentials).toBe("omit"); + expect(init.headers).toEqual({ "Content-Type": "application/json" }); + expect(JSON.parse(init.body as string)).toEqual({ + token: "real-jwt", + state: "S1", + }); + }); + + it("fires exactly one POST under React StrictMode double-invoke (didStart latch)", async () => { + mockSignedInWithToken("real-jwt"); + fetchSpy.mockResolvedValue(new Response(null, { status: 204 })); + + render( + + ({ + url: "http://127.0.0.1:9999/callback", + state: "S1", + })} + mode="post" + subtitle="sub" + title="title" + /> + + ); + + await waitFor(() => { + expect(screen.getByText("Signed in to Lightfast")).toBeTruthy(); + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("renders Authentication Failed and captures warning when buildPostCallback returns null", async () => { + mockSignedInWithToken("real-jwt"); + + render( + null} + mode="post" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Authentication Failed")).toBeTruthy(); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(captureMessageMock).toHaveBeenCalledWith( + expect.stringContaining("buildPostCallback returned null"), + expect.objectContaining({ + level: "warning", + tags: { scope: "auth-bridge.invalid_callback" }, + }) + ); + }); + + it("renders error and captures exception when fetch throws a network error", async () => { + mockSignedInWithToken("real-jwt"); + fetchSpy.mockRejectedValue(new TypeError("Failed to fetch")); + + render( + ({ + url: "http://127.0.0.1:9999/callback", + state: "S1", + })} + mode="post" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Authentication Failed")).toBeTruthy(); + }); + expect(captureExceptionMock).toHaveBeenCalledWith( + expect.any(TypeError), + expect.objectContaining({ + tags: { scope: "auth-bridge.fetch_network_error" }, + }) + ); + }); + + it("renders error and captures warning on non-2xx response", async () => { + mockSignedInWithToken("real-jwt"); + fetchSpy.mockResolvedValue(new Response("no", { status: 400 })); + + render( + ({ + url: "http://127.0.0.1:9999/callback", + state: "S1", + })} + mode="post" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Authentication Failed")).toBeTruthy(); + }); + expect(captureMessageMock).toHaveBeenCalledWith( + expect.stringContaining("non-ok"), + expect.objectContaining({ + level: "warning", + tags: expect.objectContaining({ + scope: "auth-bridge.fetch_non_ok", + status: "400", + }), + }) + ); + }); + + it("renders error when getToken returns null", async () => { + mockSignedInWithToken(null); + + render( + ({ + url: "http://127.0.0.1:9999/callback", + state: "S1", + })} + mode="post" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Authentication Failed")).toBeTruthy(); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("renders error deterministically when Clerk reports signed-out", async () => { + mockSignedOut(); + + render( + ({ + url: "http://127.0.0.1:9999/callback", + state: "S1", + })} + mode="post" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Authentication Failed")).toBeTruthy(); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("stays in loading state while Clerk is not yet loaded", () => { + mockNotLoaded(); + + render( + ({ + url: "http://127.0.0.1:9999/callback", + state: "S1", + })} + mode="post" + subtitle="Loading…" + title="Authenticating…" + /> + ); + + // Neither success nor error panel should appear. + expect(screen.queryByText("Signed in to Lightfast")).toBeNull(); + expect(screen.queryByText("Authentication Failed")).toBeNull(); + expect(screen.getByText("Authenticating…")).toBeTruthy(); + }); +}); + +describe("ClientAuthBridge — code-redirect mode (desktop PKCE)", () => { + let fetchSpy: ReturnType; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof globalThis.fetch; + captureExceptionMock.mockClear(); + captureMessageMock.mockClear(); + useAuthMock.mockClear(); + useSearchParamsMock.mockClear(); + useSearchParamsMock.mockReturnValue(new URLSearchParams()); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + cleanup(); + }); + + it("POSTs to /api/desktop/auth/code with PKCE body + Bearer auth, then redirects to redirectUri?code=…&state=…", async () => { + mockSignedInWithToken("real-jwt"); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ code: "issued-code" }), { status: 200 }) + ); + const locationSpy = vi.spyOn( + window.location, + "href", + "set" + ) as unknown as ReturnType; + + render( + ({ + state: "S1", + codeChallenge: "CHAL", + redirectUri: "lightfast-dev://auth/callback", + })} + mode="code-redirect" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalled(); + }); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/desktop/auth/code"); + expect(init.method).toBe("POST"); + expect(init.credentials).toBe("omit"); + expect(init.headers).toEqual({ + "Content-Type": "application/json", + Authorization: "Bearer real-jwt", + }); + expect(JSON.parse(init.body as string)).toEqual({ + state: "S1", + code_challenge: "CHAL", + code_challenge_method: "S256", + redirect_uri: "lightfast-dev://auth/callback", + }); + + await waitFor(() => { + expect(locationSpy).toHaveBeenCalledWith( + "lightfast-dev://auth/callback?code=issued-code&state=S1" + ); + }); + locationSpy.mockRestore(); + }); + + it("renders Authentication Failed and captures warning when buildExchangeRequest returns null", async () => { + mockSignedInWithToken("real-jwt"); + + render( + null} + mode="code-redirect" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Authentication Failed")).toBeTruthy(); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(captureMessageMock).toHaveBeenCalledWith( + expect.stringContaining("buildExchangeRequest returned null"), + expect.objectContaining({ + level: "warning", + tags: { scope: "auth-bridge.invalid_callback" }, + }) + ); + }); + + it("renders error and captures warning when /api/desktop/auth/code returns 4xx", async () => { + mockSignedInWithToken("real-jwt"); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }) + ); + + render( + ({ + state: "S1", + codeChallenge: "CHAL", + redirectUri: "lightfast-dev://auth/callback", + })} + mode="code-redirect" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Authentication Failed")).toBeTruthy(); + }); + expect(captureMessageMock).toHaveBeenCalledWith( + expect.stringContaining("code endpoint non-ok"), + expect.objectContaining({ + level: "warning", + tags: expect.objectContaining({ + scope: "auth-bridge.code_non_ok", + status: "401", + }), + }) + ); + }); + + it("renders error when fetch throws a network error during exchange POST", async () => { + mockSignedInWithToken("real-jwt"); + fetchSpy.mockRejectedValue(new TypeError("Failed to fetch")); + + render( + ({ + state: "S1", + codeChallenge: "CHAL", + redirectUri: "lightfast-dev://auth/callback", + })} + mode="code-redirect" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Authentication Failed")).toBeTruthy(); + }); + expect(captureExceptionMock).toHaveBeenCalledWith( + expect.any(TypeError), + expect.objectContaining({ + tags: { scope: "auth-bridge.code_network_error" }, + }) + ); + }); + + it("renders error when response body is missing the code field", async () => { + mockSignedInWithToken("real-jwt"); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }) + ); + + render( + ({ + state: "S1", + codeChallenge: "CHAL", + redirectUri: "lightfast-dev://auth/callback", + })} + mode="code-redirect" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Authentication Failed")).toBeTruthy(); + }); + }); +}); + +describe("ClientAuthBridge — redirect mode (CLI parity)", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + captureExceptionMock.mockClear(); + captureMessageMock.mockClear(); + useAuthMock.mockClear(); + useSearchParamsMock.mockReturnValue(new URLSearchParams()); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + cleanup(); + }); + + it("sets window.location.href to the builder result and never fetches", async () => { + mockSignedInWithToken("jwt-123"); + const builtUrl = "http://localhost:55555/callback?token=jwt-123&state=S"; + const locationSpy = vi.spyOn( + window.location, + "href", + "set" + ) as unknown as ReturnType; + + render( + + `http://localhost:55555/callback?token=${token}&state=S` + } + mode="redirect" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(locationSpy).toHaveBeenCalledWith(builtUrl); + }); + locationSpy.mockRestore(); + }); + + it("renders Authentication Failed when buildRedirectUrl returns null", async () => { + mockSignedInWithToken("jwt-123"); + + render( + null} + mode="redirect" + subtitle="sub" + title="title" + /> + ); + + await waitFor(() => { + expect(screen.getByText("Authentication Failed")).toBeTruthy(); + }); + }); +}); diff --git a/apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.tsx b/apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.tsx index 2d8511bb3..edc070bd0 100644 --- a/apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.tsx +++ b/apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.tsx @@ -1,47 +1,188 @@ "use client"; -import { useSession } from "@vendor/clerk/client"; +import { useAuth } from "@vendor/clerk/client"; +import { + captureException, + captureMessage, +} from "@vendor/observability/sentry-nextjs"; import { useSearchParams } from "next/navigation"; import { type ReactNode, Suspense, useEffect, useRef, useState } from "react"; -export interface ClientAuthBridgeProps { - buildRedirectUrl: (args: { - token: string; - searchParams: URLSearchParams; - }) => string | null; +interface ClientAuthBridgeBaseProps { fallback?: ReactNode; jwtTemplate?: string; subtitle: string; title: string; } +interface PostCallbackProps { + buildPostCallback: (args: { + searchParams: URLSearchParams; + }) => { url: string; state: string } | null; + mode: "post"; +} + +interface RedirectProps { + buildRedirectUrl: (args: { + token: string; + searchParams: URLSearchParams; + }) => string | null; + mode: "redirect"; +} + +interface CodeRedirectProps { + buildExchangeRequest: (args: { + searchParams: URLSearchParams; + }) => { state: string; codeChallenge: string; redirectUri: string } | null; + mode: "code-redirect"; +} + +export type ClientAuthBridgeProps = ClientAuthBridgeBaseProps & + (PostCallbackProps | RedirectProps | CodeRedirectProps); + +type BridgeStatus = "loading" | "redirecting" | "success" | "error"; + +const CODE_ENDPOINT = "/api/desktop/auth/code"; +const WINDOW_CLOSE_DELAY_MS = 250; + function BridgeContent(props: ClientAuthBridgeProps) { - const { isLoaded, session } = useSession(); + const { getToken, isLoaded, isSignedIn } = useAuth(); const searchParams = useSearchParams(); - const [status, setStatus] = useState<"loading" | "redirecting" | "error">( - "loading" - ); - const hasStartedRef = useRef(false); + const [status, setStatus] = useState("loading"); + const didStart = useRef(false); + // biome-ignore lint/correctness/useExhaustiveDependencies: handshake is one-shot, latched by didStart.current — re-firing the effect would double-POST the token. useEffect(() => { - if (!isLoaded || hasStartedRef.current) { + if (!isLoaded || didStart.current) { return; } - if (!session) { + if (!isSignedIn) { + didStart.current = true; setStatus("error"); return; } - hasStartedRef.current = true; - + didStart.current = true; void (async () => { try { - const token = await session.getToken( + const token = await getToken( props.jwtTemplate ? { template: props.jwtTemplate } : undefined ); if (!token) { setStatus("error"); return; } + if (props.mode === "post") { + const built = props.buildPostCallback({ searchParams }); + if (!built) { + captureMessage("auth-bridge: buildPostCallback returned null", { + level: "warning", + tags: { scope: "auth-bridge.invalid_callback" }, + }); + setStatus("error"); + return; + } + let response: Response; + try { + response = await fetch(built.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, state: built.state }), + credentials: "omit", + }); + } catch (error) { + captureException(error, { + tags: { scope: "auth-bridge.fetch_network_error" }, + }); + setStatus("error"); + return; + } + if (!response.ok) { + captureMessage("auth-bridge: loopback POST non-ok", { + level: "warning", + tags: { + scope: "auth-bridge.fetch_non_ok", + status: String(response.status), + }, + }); + setStatus("error"); + return; + } + setStatus("success"); + return; + } + if (props.mode === "code-redirect") { + const built = props.buildExchangeRequest({ searchParams }); + if (!built) { + captureMessage("auth-bridge: buildExchangeRequest returned null", { + level: "warning", + tags: { scope: "auth-bridge.invalid_callback" }, + }); + setStatus("error"); + return; + } + let response: Response; + try { + response = await fetch(CODE_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + state: built.state, + code_challenge: built.codeChallenge, + code_challenge_method: "S256", + redirect_uri: built.redirectUri, + }), + credentials: "omit", + }); + } catch (error) { + captureException(error, { + tags: { scope: "auth-bridge.code_network_error" }, + }); + setStatus("error"); + return; + } + if (!response.ok) { + captureMessage("auth-bridge: code endpoint non-ok", { + level: "warning", + tags: { + scope: "auth-bridge.code_non_ok", + status: String(response.status), + }, + }); + setStatus("error"); + return; + } + let parsed: { code?: unknown }; + try { + parsed = (await response.json()) as { code?: unknown }; + } catch (error) { + captureException(error, { + tags: { scope: "auth-bridge.code_parse_error" }, + }); + setStatus("error"); + return; + } + if (typeof parsed.code !== "string" || parsed.code.length === 0) { + setStatus("error"); + return; + } + const finalUrl = `${built.redirectUri}?code=${encodeURIComponent(parsed.code)}&state=${encodeURIComponent(built.state)}`; + setStatus("redirecting"); + window.location.href = finalUrl; + // Best-effort: let the navigation flush, then close this tab. + // Browsers only allow window.close() on script-opened windows and + // even then may silently no-op. Close failures are not surfaced. + setTimeout(() => { + try { + window.close(); + } catch { + // ignore — best-effort + } + }, WINDOW_CLOSE_DELAY_MS); + return; + } const url = props.buildRedirectUrl({ token, searchParams }); if (!url) { setStatus("error"); @@ -49,11 +190,14 @@ function BridgeContent(props: ClientAuthBridgeProps) { } setStatus("redirecting"); window.location.href = url; - } catch { + } catch (error) { + captureException(error, { + tags: { scope: "auth-bridge.unexpected_error" }, + }); setStatus("error"); } })(); - }, [isLoaded, session, props, searchParams]); + }, [isLoaded, isSignedIn]); if (status === "error") { return ( @@ -68,6 +212,19 @@ function BridgeContent(props: ClientAuthBridgeProps) { ); } + if (status === "success") { + return ( +
+
+

Signed in to Lightfast

+

+ You can close this tab and return to Lightfast. +

+
+
+ ); + } + return (
diff --git a/apps/app/src/app/(app)/(user)/(pending-not-allowed)/cli/auth/_components/cli-auth-client.tsx b/apps/app/src/app/(app)/(user)/(pending-not-allowed)/cli/auth/_components/cli-auth-client.tsx index 9ba8a688d..6b3f6e339 100644 --- a/apps/app/src/app/(app)/(user)/(pending-not-allowed)/cli/auth/_components/cli-auth-client.tsx +++ b/apps/app/src/app/(app)/(user)/(pending-not-allowed)/cli/auth/_components/cli-auth-client.tsx @@ -2,6 +2,9 @@ import { ClientAuthBridge } from "../../../_components/client-auth-bridge"; +// CLI retains the GET ?token= redirect handoff. Desktop moved to POST in +// Phase 2 of the PR #614 follow-up; the CLI side can migrate later when its +// loopback server adds POST support. export function CLIAuthClient() { return ( diff --git a/apps/app/src/app/(app)/(user)/(pending-not-allowed)/desktop/auth/_components/desktop-auth-client.tsx b/apps/app/src/app/(app)/(user)/(pending-not-allowed)/desktop/auth/_components/desktop-auth-client.tsx index 266ab53e6..f1fe1559f 100644 --- a/apps/app/src/app/(app)/(user)/(pending-not-allowed)/desktop/auth/_components/desktop-auth-client.tsx +++ b/apps/app/src/app/(app)/(user)/(pending-not-allowed)/desktop/auth/_components/desktop-auth-client.tsx @@ -2,44 +2,30 @@ import { ClientAuthBridge } from "../../../_components/client-auth-bridge"; -const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost"]); - -function validateLoopbackCallback(raw: string | null): URL | null { - if (!raw) { - return null; - } - try { - const parsed = new URL(raw); - if (parsed.protocol !== "http:") { - return null; - } - if (!LOOPBACK_HOSTS.has(parsed.hostname)) { - return null; - } - if (parsed.pathname !== "/callback") { - return null; - } - return parsed; - } catch { - return null; - } -} +const ALLOWED_REDIRECT_URIS = new Set([ + "lightfast://auth/callback", + "lightfast-dev://auth/callback", +]); export function DesktopAuthClient() { return ( { + buildExchangeRequest={({ searchParams }) => { const state = searchParams.get("state"); - const callback = validateLoopbackCallback(searchParams.get("callback")); - if (!(state && callback)) { + const codeChallenge = searchParams.get("code_challenge"); + const method = searchParams.get("code_challenge_method"); + const redirectUri = searchParams.get("redirect_uri"); + if (!(state && codeChallenge) || method !== "S256" || !redirectUri) { + return null; + } + if (!ALLOWED_REDIRECT_URIS.has(redirectUri)) { return null; } - callback.searchParams.set("token", token); - callback.searchParams.set("state", state); - return callback.toString(); + return { state, codeChallenge, redirectUri }; }} jwtTemplate="lightfast-desktop" - subtitle="You'll be redirected back to the Lightfast desktop app shortly." + mode="code-redirect" + subtitle="Returning you to the Lightfast desktop app…" title="Authenticating…" /> ); diff --git a/apps/app/src/app/api/cli/lib/verify-jwt.ts b/apps/app/src/app/api/cli/lib/verify-jwt.ts index ee10b8b52..88f143dd7 100644 --- a/apps/app/src/app/api/cli/lib/verify-jwt.ts +++ b/apps/app/src/app/api/cli/lib/verify-jwt.ts @@ -4,18 +4,18 @@ import { env } from "~/env"; export async function verifyCliJwt( req: Request -): Promise<{ userId: string } | null> { +): Promise<{ userId: string; jwt: string } | null> { const authHeader = req.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) { return null; } - const token = authHeader.replace("Bearer ", ""); + const jwt = authHeader.replace("Bearer ", ""); try { - const payload = await verifyToken(token, { + const payload = await verifyToken(jwt, { secretKey: env.CLERK_SECRET_KEY, }); - return { userId: payload.sub }; + return { userId: payload.sub, jwt }; } catch { return null; } diff --git a/apps/app/src/app/api/desktop/auth/code/route.test.ts b/apps/app/src/app/api/desktop/auth/code/route.test.ts new file mode 100644 index 000000000..d2e10b294 --- /dev/null +++ b/apps/app/src/app/api/desktop/auth/code/route.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const verifyCliJwtMock = + vi.fn<(req: Request) => Promise<{ userId: string; jwt: string } | null>>(); +vi.mock("../../../cli/lib/verify-jwt", () => ({ + verifyCliJwt: (req: Request) => verifyCliJwtMock(req), +})); + +const issueCodeMock = vi.fn<(record: unknown) => Promise>(); +vi.mock("../lib/code-store", () => ({ + issueCode: (record: unknown) => issueCodeMock(record), +})); + +const { POST } = await import("./route"); + +const VALID_BODY = { + state: "a".repeat(32), + code_challenge: "b".repeat(43), + code_challenge_method: "S256", + redirect_uri: "lightfast-dev://auth/callback", +}; + +function makeReq(body: unknown, init?: RequestInit): Request { + return new Request("http://localhost/api/desktop/auth/code", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer fake-jwt", + ...(init?.headers as Record | undefined), + }, + body: typeof body === "string" ? body : JSON.stringify(body), + ...init, + }); +} + +describe("POST /api/desktop/auth/code", () => { + beforeEach(() => { + verifyCliJwtMock.mockReset(); + issueCodeMock.mockReset(); + issueCodeMock.mockResolvedValue("issued-code"); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when verifyCliJwt returns null", async () => { + verifyCliJwtMock.mockResolvedValue(null); + + const res = await POST(makeReq(VALID_BODY)); + + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "unauthorized" }); + expect(issueCodeMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when body fails schema (missing fields)", async () => { + verifyCliJwtMock.mockResolvedValue({ userId: "user_123", jwt: "fake-jwt" }); + + const res = await POST(makeReq({ state: "x" })); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "bad_request" }); + expect(issueCodeMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when redirect_uri is not in the allowlist", async () => { + verifyCliJwtMock.mockResolvedValue({ userId: "user_123", jwt: "fake-jwt" }); + + const res = await POST( + makeReq({ ...VALID_BODY, redirect_uri: "https://evil.com/callback" }) + ); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "bad_request" }); + expect(issueCodeMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when code_challenge_method is not S256", async () => { + verifyCliJwtMock.mockResolvedValue({ userId: "user_123", jwt: "fake-jwt" }); + + const res = await POST( + makeReq({ ...VALID_BODY, code_challenge_method: "plain" }) + ); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "bad_request" }); + }); + + it("returns 400 when body is not valid JSON", async () => { + verifyCliJwtMock.mockResolvedValue({ userId: "user_123", jwt: "fake-jwt" }); + + const res = await POST(makeReq("not json")); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "bad_request" }); + }); + + it("issues a code and returns it on happy path with lightfast:// redirect", async () => { + verifyCliJwtMock.mockResolvedValue({ userId: "user_123", jwt: "fake-jwt" }); + + const res = await POST( + makeReq({ ...VALID_BODY, redirect_uri: "lightfast://auth/callback" }) + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ code: "issued-code" }); + expect(issueCodeMock).toHaveBeenCalledTimes(1); + expect(issueCodeMock).toHaveBeenCalledWith({ + userId: "user_123", + jwt: "fake-jwt", + state: VALID_BODY.state, + codeChallenge: VALID_BODY.code_challenge, + redirectUri: "lightfast://auth/callback", + }); + }); + + it("stores exactly the jwt that verifyCliJwt authenticated (single-parser invariant)", async () => { + // Whatever token verifyCliJwt verified must be the one that lands in + // issueCode — the route no longer parses the Authorization header itself, + // so there is no second normalizer that can disagree with the verifier. + verifyCliJwtMock.mockResolvedValue({ + userId: "user_456", + jwt: "verified-token", + }); + + await POST( + new Request("http://localhost/api/desktop/auth/code", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer some-other-string", + }, + body: JSON.stringify(VALID_BODY), + }) + ); + + expect(issueCodeMock).toHaveBeenCalledWith( + expect.objectContaining({ jwt: "verified-token" }) + ); + }); +}); diff --git a/apps/app/src/app/api/desktop/auth/code/route.ts b/apps/app/src/app/api/desktop/auth/code/route.ts new file mode 100644 index 000000000..b7337f378 --- /dev/null +++ b/apps/app/src/app/api/desktop/auth/code/route.ts @@ -0,0 +1,38 @@ +// POST /api/desktop/auth/code +// Auth: Clerk JWT (lightfast-desktop template) in Authorization header. +import { z } from "zod"; +import { verifyCliJwt } from "../../../cli/lib/verify-jwt"; +import { issueCode } from "../lib/code-store"; + +const ALLOWED_REDIRECT_URIS = new Set([ + "lightfast://auth/callback", + "lightfast-dev://auth/callback", +]); + +const bodySchema = z.object({ + state: z.string().min(16).max(256), + code_challenge: z.string().min(43).max(128), + code_challenge_method: z.literal("S256"), + redirect_uri: z.string().refine((u) => ALLOWED_REDIRECT_URIS.has(u)), +}); + +export async function POST(req: Request) { + const session = await verifyCliJwt(req); + if (!session) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return Response.json({ error: "bad_request" }, { status: 400 }); + } + + const code = await issueCode({ + userId: session.userId, + jwt: session.jwt, + state: parsed.data.state, + codeChallenge: parsed.data.code_challenge, + redirectUri: parsed.data.redirect_uri, + }); + return Response.json({ code }); +} diff --git a/apps/app/src/app/api/desktop/auth/exchange/route.test.ts b/apps/app/src/app/api/desktop/auth/exchange/route.test.ts new file mode 100644 index 000000000..ee5ce39ca --- /dev/null +++ b/apps/app/src/app/api/desktop/auth/exchange/route.test.ts @@ -0,0 +1,95 @@ +import { createHash } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CodeRecord } from "../lib/code-store"; + +const consumeCodeMock = vi.fn<(code: string) => Promise>(); +vi.mock("../lib/code-store", () => ({ + consumeCode: (code: string) => consumeCodeMock(code), +})); + +const { POST } = await import("./route"); + +const VERIFIER = "v".repeat(64); +const CHALLENGE = createHash("sha256").update(VERIFIER).digest("base64url"); +const CODE = "c".repeat(43); + +function makeReq(body: unknown): Request { + return new Request("http://localhost/api/desktop/auth/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +const goodRecord: CodeRecord = { + userId: "user_123", + jwt: "real-jwt", + state: "s".repeat(32), + codeChallenge: CHALLENGE, + redirectUri: "lightfast-dev://auth/callback", +}; + +describe("POST /api/desktop/auth/exchange", () => { + beforeEach(() => { + consumeCodeMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 bad_request on schema failure", async () => { + const res = await POST(makeReq({ code: "short" })); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "bad_request" }); + expect(consumeCodeMock).not.toHaveBeenCalled(); + }); + + it("returns 400 bad_request when body is not valid JSON", async () => { + const res = await POST(makeReq("not json")); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "bad_request" }); + }); + + it("returns 400 invalid_code when consumeCode returns null (expired/missing)", async () => { + consumeCodeMock.mockResolvedValue(null); + + const res = await POST(makeReq({ code: CODE, code_verifier: VERIFIER })); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "invalid_code" }); + expect(consumeCodeMock).toHaveBeenCalledWith(CODE); + }); + + it("returns 400 invalid_verifier when SHA256(verifier) != stored challenge", async () => { + consumeCodeMock.mockResolvedValue(goodRecord); + + const tampered = "x".repeat(64); + const res = await POST(makeReq({ code: CODE, code_verifier: tampered })); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "invalid_verifier" }); + }); + + it("returns 200 + token on happy path", async () => { + consumeCodeMock.mockResolvedValue(goodRecord); + + const res = await POST(makeReq({ code: CODE, code_verifier: VERIFIER })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ token: "real-jwt" }); + }); + + it("consumes the code exactly once even when called twice with the same code", async () => { + consumeCodeMock + .mockResolvedValueOnce(goodRecord) + .mockResolvedValueOnce(null); + + const first = await POST(makeReq({ code: CODE, code_verifier: VERIFIER })); + const second = await POST(makeReq({ code: CODE, code_verifier: VERIFIER })); + + expect(first.status).toBe(200); + expect(second.status).toBe(400); + expect(await second.json()).toEqual({ error: "invalid_code" }); + }); +}); diff --git a/apps/app/src/app/api/desktop/auth/exchange/route.ts b/apps/app/src/app/api/desktop/auth/exchange/route.ts new file mode 100644 index 000000000..d215e7540 --- /dev/null +++ b/apps/app/src/app/api/desktop/auth/exchange/route.ts @@ -0,0 +1,32 @@ +// POST /api/desktop/auth/exchange +// Auth: none — the code itself proves possession of the in-flight sign-in. +// Verifier check (PKCE S256) binds the exchange to the same client that +// issued the code via /api/desktop/auth/code. +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { consumeCode } from "../lib/code-store"; + +const bodySchema = z.object({ + code: z.string().min(32).max(128), + code_verifier: z.string().min(43).max(128), +}); + +export async function POST(req: Request) { + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return Response.json({ error: "bad_request" }, { status: 400 }); + } + + const record = await consumeCode(parsed.data.code); + if (!record) { + return Response.json({ error: "invalid_code" }, { status: 400 }); + } + + const expected = createHash("sha256") + .update(parsed.data.code_verifier) + .digest("base64url"); + if (expected !== record.codeChallenge) { + return Response.json({ error: "invalid_verifier" }, { status: 400 }); + } + return Response.json({ token: record.jwt }); +} diff --git a/apps/app/src/app/api/desktop/auth/lib/code-store.ts b/apps/app/src/app/api/desktop/auth/lib/code-store.ts new file mode 100644 index 000000000..a67d26adf --- /dev/null +++ b/apps/app/src/app/api/desktop/auth/lib/code-store.ts @@ -0,0 +1,28 @@ +// Short-lived (~30s TTL) one-shot store for in-flight desktop OAuth-style codes. +// Holds a Clerk JWT briefly while the desktop app exchanges code+verifier for it. +// Upstash provides at-rest encryption + TLS in transit; the entry is consumed +// atomically via GETDEL on first read. +import { randomBytes } from "node:crypto"; +import { redis } from "@vendor/upstash"; + +const PREFIX = "desktop_auth_code:"; +const TTL_SECONDS = 30; + +export interface CodeRecord { + codeChallenge: string; + jwt: string; + redirectUri: string; + state: string; + userId: string; +} + +export async function issueCode(record: CodeRecord): Promise { + const code = randomBytes(32).toString("base64url"); + await redis.set(`${PREFIX}${code}`, record, { ex: TTL_SECONDS }); + return code; +} + +export async function consumeCode(code: string): Promise { + const result = await redis.getdel(`${PREFIX}${code}`); + return result ?? null; +} diff --git a/apps/app/src/instrumentation.ts b/apps/app/src/instrumentation.ts index d8f3594fe..9843add76 100644 --- a/apps/app/src/instrumentation.ts +++ b/apps/app/src/instrumentation.ts @@ -1,13 +1,13 @@ +import { TRPCError } from "@trpc/server"; +import { getHTTPStatusCodeFromError } from "@trpc/server/http"; +import { NonRetriableError } from "@vendor/inngest"; import { captureConsoleIntegration, captureRequestError, extraErrorDataIntegration, init, spotlightIntegration, -} from "@sentry/nextjs"; -import { TRPCError } from "@trpc/server"; -import { getHTTPStatusCodeFromError } from "@trpc/server/http"; -import { NonRetriableError } from "@vendor/inngest"; +} from "@vendor/observability/sentry-nextjs"; import { env } from "~/env"; diff --git a/apps/app/src/proxy.ts b/apps/app/src/proxy.ts index fdbde568a..656e637a3 100644 --- a/apps/app/src/proxy.ts +++ b/apps/app/src/proxy.ts @@ -41,6 +41,7 @@ const isPublicRoute = createRouteMatcher([ const isApiRoute = createRouteMatcher([ "/v1/(.*)", "/api/cli/(.*)", + "/api/desktop/(.*)", "/api/inngest(.*)", "/api/trpc/(.*)", ]); diff --git a/apps/app/vitest.config.ts b/apps/app/vitest.config.ts index 9c5fabcfb..126bdab4f 100644 --- a/apps/app/vitest.config.ts +++ b/apps/app/vitest.config.ts @@ -1,10 +1,12 @@ import { resolve } from "node:path"; import sharedConfig from "@repo/vitest-config"; +import react from "@vitejs/plugin-react"; import { defineConfig, mergeConfig } from "vitest/config"; export default mergeConfig( sharedConfig, defineConfig({ + plugins: [react()], esbuild: { jsx: "automatic", }, diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index d443266db..3c4a6a106 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -12,6 +12,7 @@ import { PublisherGithub } from "@electron-forge/publisher-github"; import type { ForgeConfig } from "@electron-forge/shared-types"; const BUNDLE_ID = "ai.lightfast.lightfast"; +const URL_SCHEME = "lightfast"; const osxSign = process.env.APPLE_SIGNING_IDENTITY && process.env.APPLE_TEAM_ID @@ -129,7 +130,14 @@ const config: ForgeConfig = { "Used for voice notes and audio capture inside the app.", NSAudioCaptureUsageDescription: "Used for capturing system audio during sessions.", + CFBundleURLTypes: [ + { + CFBundleURLName: BUNDLE_ID, + CFBundleURLSchemes: [URL_SCHEME], + }, + ], }, + protocols: [{ name: "Lightfast", schemes: [URL_SCHEME] }], }, rebuildConfig: {}, makers: [ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 43efae8c7..4000626be 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,6 +12,7 @@ "publish": "electron-forge publish", "sourcemaps:upload": "node scripts/upload-sourcemaps.mjs", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", + "test": "vitest run", "with-env": "dotenv -e ./.vercel/.env.development.local --" }, "devDependencies": { @@ -25,20 +26,21 @@ "@electron-forge/publisher-github": "^7.11.1", "@electron-forge/shared-types": "^7.11.1", "@electron/fuses": "^1.8.0", - "@electron/notarize": "^3.1.1", - "@electron/osx-sign": "^1.3.3", "@repo/typescript-config": "workspace:*", + "@repo/vitest-config": "workspace:*", "@sentry/cli": "^2.39.1", "@t3-oss/env-core": "catalog:", "@types/electron-squirrel-startup": "^1.0.2", "@types/node": "catalog:", "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "catalog:", "dotenv-cli": "catalog:", - "electron": "^39.8.5", + "electron": "^41.3.0", + "happy-dom": "catalog:", "typescript": "catalog:", - "vite": "^5.4.11" + "vite": "^8.0.10", + "vitest": "catalog:" }, "dependencies": { "@radix-ui/react-dropdown-menu": "catalog:", @@ -48,6 +50,7 @@ "@tanstack/react-query": "catalog:", "@trpc/client": "catalog:", "@trpc/tanstack-react-query": "catalog:", + "@vendor/observability": "workspace:*", "copy-anything": "^4.0.5", "electron-context-menu": "^4.1.1", "electron-squirrel-startup": "^1.0.1", diff --git a/apps/desktop/src/main/__tests__/auth-flow.test.ts b/apps/desktop/src/main/__tests__/auth-flow.test.ts new file mode 100644 index 000000000..2d66ae93c --- /dev/null +++ b/apps/desktop/src/main/__tests__/auth-flow.test.ts @@ -0,0 +1,683 @@ +import { createHash } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const shellOpenExternalMock = vi.fn<(url: string) => Promise>(() => + Promise.resolve() +); +const setTokenMock = vi.fn<(token: string) => boolean>(() => true); +const getTokenMock = vi.fn<() => string | null>(() => null); +const sentryCaptureExceptionMock = vi.fn<(...args: unknown[]) => void>(); +const sentryCaptureMessageMock = vi.fn<(...args: unknown[]) => void>(); + +let protocolListeners: Array<(url: string) => void> = []; +let isPackagedFlag = false; +let testAppOrigin = "http://localhost:3024"; + +vi.mock("electron", () => ({ + shell: { + openExternal: (url: string) => shellOpenExternalMock(url), + }, + app: { + get isPackaged() { + return isPackagedFlag; + }, + }, +})); + +vi.mock("@vendor/observability/sentry-electron-main", () => ({ + captureException: (error: unknown, options?: unknown) => + sentryCaptureExceptionMock(error, options), + captureMessage: (message: string, options?: unknown) => + sentryCaptureMessageMock(message, options), +})); + +vi.mock("../auth-store", () => ({ + setToken: (token: string) => setTokenMock(token), + getToken: () => getTokenMock(), +})); + +vi.mock("../protocol", () => ({ + getProtocolScheme: () => (isPackagedFlag ? "lightfast" : "lightfast-dev"), + onProtocolUrl: (listener: (url: string) => void) => { + protocolListeners.push(listener); + return () => { + protocolListeners = protocolListeners.filter((l) => l !== listener); + }; + }, +})); + +vi.mock("../app-url", () => ({ + createAppUrl: (path: string) => new URL(path, testAppOrigin), + openAppOrigin: () => Promise.resolve(), +})); + +async function loadAuthFlow(env?: Record) { + vi.resetModules(); + protocolListeners = []; + // Snapshot only the keys we intend to mutate so restore can properly + // remove keys that didn't previously exist (Object.assign won't delete). + const touchedKeys = env ? Object.keys(env) : []; + const prev: Record = {}; + for (const k of touchedKeys) { + prev[k] = process.env[k]; + } + const restore = () => { + for (const k of touchedKeys) { + const original = prev[k]; + if (original === undefined) { + delete process.env[k]; + } else { + process.env[k] = original; + } + } + }; + if (env) { + for (const [k, v] of Object.entries(env)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + } + // If `await import` throws, the env mutation above must be reverted + // before the rejection propagates — otherwise an import-time failure + // contaminates every subsequent test in the suite. + let mod: typeof import("../auth-flow"); + try { + mod = await import("../auth-flow"); + } catch (error) { + restore(); + throw error; + } + return { mod, restore }; +} + +interface CapturedSignin { + codeChallenge: string; + redirectUri: string; + state: string; + url: URL; +} + +async function captureSigninUrl( + fromOpenExternal: boolean, + emittedEvents: AuthLine[] +): Promise { + for (let i = 0; i < 200; i++) { + if (fromOpenExternal && shellOpenExternalMock.mock.calls.length > 0) { + break; + } + if ( + !fromOpenExternal && + emittedEvents.some((e) => e.event === "auth_signin_url") + ) { + break; + } + await new Promise((r) => setTimeout(r, 10)); + } + const raw = fromOpenExternal + ? (shellOpenExternalMock.mock.calls.at(-1)?.[0] as string | undefined) + : ( + emittedEvents.find((e) => e.event === "auth_signin_url") as + | { event: "auth_signin_url"; url: string } + | undefined + )?.url; + if (!raw) { + throw new Error("signin URL not observed"); + } + const url = new URL(raw); + const state = url.searchParams.get("state") ?? ""; + const codeChallenge = url.searchParams.get("code_challenge") ?? ""; + const redirectUri = url.searchParams.get("redirect_uri") ?? ""; + return { url, state, codeChallenge, redirectUri }; +} + +type AuthLine = + | { event: "auth_already_signed_in" } + | { event: "auth_signin_url"; url: string } + | { event: "auth_signed_in" } + | { event: "auth_signin_failed"; reason: string }; + +function spyStdout(): { events: AuthLine[]; restore: () => void } { + const events: AuthLine[] = []; + const original = process.stdout.write.bind(process.stdout); + const spy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: unknown) => { + if (typeof chunk === "string") { + for (const line of chunk.split("\n")) { + if (!line) { + continue; + } + try { + const parsed = JSON.parse(line); + if (parsed && typeof parsed === "object" && "event" in parsed) { + events.push(parsed as AuthLine); + } + } catch { + // not JSON — fall through to original + } + } + } + return true; + }); + return { + events, + restore: () => { + spy.mockRestore(); + process.stdout.write = original; + }, + }; +} + +beforeEach(() => { + shellOpenExternalMock.mockClear(); + setTokenMock.mockClear(); + setTokenMock.mockImplementation(() => true); + getTokenMock.mockClear(); + getTokenMock.mockImplementation(() => null); + sentryCaptureExceptionMock.mockClear(); + sentryCaptureMessageMock.mockClear(); + isPackagedFlag = false; + testAppOrigin = "http://localhost:3024"; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("auth-flow PKCE sign-in", () => { + it("composes signin URL with state, S256 code_challenge, and lightfast-dev redirect_uri (unpackaged)", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", + }); + try { + const signIn = mod.beginSignIn(); + const captured = await captureSigninUrl(true, []); + expect(captured.url.origin).toBe("http://localhost:3024"); + expect(captured.url.pathname).toBe("/desktop/auth"); + expect(captured.state.length).toBeGreaterThanOrEqual(32); + expect(captured.codeChallenge.length).toBeGreaterThanOrEqual(43); + expect(captured.redirectUri).toBe("lightfast-dev://auth/callback"); + expect(captured.url.searchParams.get("code_challenge_method")).toBe( + "S256" + ); + + const result = await signIn; + expect(result).toBeNull(); + } finally { + restore(); + } + }); + + it("composes redirect_uri with the lightfast scheme when packaged", async () => { + isPackagedFlag = true; + testAppOrigin = "https://lightfast.ai"; + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "production", + LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", + }); + try { + const signIn = mod.beginSignIn(); + const captured = await captureSigninUrl(true, []); + expect(captured.redirectUri).toBe("lightfast://auth/callback"); + expect(captured.url.origin).toBe("https://lightfast.ai"); + + await signIn; + } finally { + restore(); + } + }); + + it("happy path: protocol callback → exchange → setToken → resolves with the token", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ token: "real-jwt" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) as unknown as Response + ); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + }); + try { + const signIn = mod.beginSignIn(); + const captured = await captureSigninUrl(true, []); + const cb = protocolListeners[0]; + if (!cb) { + throw new Error("no protocol listener"); + } + + cb( + `lightfast-dev://auth/callback?code=${"a".repeat(43)}&state=${captured.state}` + ); + + const result = await signIn; + expect(result).toBe("real-jwt"); + expect(setTokenMock).toHaveBeenCalledWith("real-jwt"); + // Verify exchange POST was made with correct body shape. + const [exchUrl, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(exchUrl).toBe("http://localhost:3024/api/desktop/auth/exchange"); + const body = JSON.parse(init.body as string); + expect(body.code).toBe("a".repeat(43)); + // verifier must match the captured challenge under SHA256. + const expectedChallenge = createHash("sha256") + .update(body.code_verifier) + .digest("base64url"); + expect(expectedChallenge).toBe(captured.codeChallenge); + } finally { + restore(); + fetchSpy.mockRestore(); + } + }); + + it("ignores callbacks with foreign state (no event, flow stays pending until timeout)", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + }); + try { + vi.useFakeTimers(); + const signIn = mod.beginSignIn(); + // Wait for openExternal microtasks + for (let i = 0; i < 5; i++) { + await vi.advanceTimersByTimeAsync(10); + if (shellOpenExternalMock.mock.calls.length > 0) { + break; + } + } + const cb = protocolListeners[0]; + if (!cb) { + throw new Error("no protocol listener"); + } + + cb( + `lightfast-dev://auth/callback?code=${"a".repeat(43)}&state=${"WRONG".repeat(8)}` + ); + + // Allow microtasks to flush, then advance past timeout. + await vi.advanceTimersByTimeAsync(50); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(setTokenMock).not.toHaveBeenCalled(); + expect(sentryCaptureMessageMock).toHaveBeenCalledWith( + expect.stringContaining("state mismatch"), + expect.objectContaining({ + tags: { scope: "auth-flow.state_mismatch" }, + }) + ); + + await vi.advanceTimersByTimeAsync(5 * 60_000 + 1000); + const result = await signIn; + expect(result).toBeNull(); + } finally { + restore(); + fetchSpy.mockRestore(); + } + }); + + it("exchange 4xx returns null and emits auth_signin_failed{reason:exchange_failed} in agent mode", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: "invalid_code" }), { + status: 400, + }) as unknown as Response + ); + const { events, restore: restoreStdout } = spyStdout(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AGENT_MODE: "1", + }); + try { + const signIn = mod.beginSignIn(); + const captured = await captureSigninUrl(false, events); + const cb = protocolListeners[0]; + if (!cb) { + throw new Error("no protocol listener"); + } + cb( + `lightfast-dev://auth/callback?code=${"b".repeat(43)}&state=${captured.state}` + ); + + const result = await signIn; + expect(result).toBeNull(); + expect( + events.find((e) => e.event === "auth_signin_failed") + ).toMatchObject({ reason: "exchange_failed" }); + } finally { + restoreStdout(); + restore(); + fetchSpy.mockRestore(); + } + }); + + it("persist failure emits auth_signin_failed{reason:persist_failed} in agent mode", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ token: "jwt" }), { + status: 200, + }) as unknown as Response + ); + setTokenMock.mockImplementationOnce(() => false); + const { events, restore: restoreStdout } = spyStdout(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AGENT_MODE: "1", + }); + try { + const signIn = mod.beginSignIn(); + const captured = await captureSigninUrl(false, events); + const cb = protocolListeners[0]; + if (!cb) { + throw new Error("no protocol listener"); + } + cb( + `lightfast-dev://auth/callback?code=${"c".repeat(43)}&state=${captured.state}` + ); + + const result = await signIn; + expect(result).toBeNull(); + expect( + events.find((e) => e.event === "auth_signin_failed") + ).toMatchObject({ reason: "persist_failed" }); + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { scope: "auth-flow.persist_failed" }, + }) + ); + } finally { + restoreStdout(); + restore(); + fetchSpy.mockRestore(); + } + }); + + it("handler exception emits auth_signin_failed{reason:handler_error}", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new TypeError("network down")); + const { events, restore: restoreStdout } = spyStdout(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AGENT_MODE: "1", + }); + try { + const signIn = mod.beginSignIn(); + const captured = await captureSigninUrl(false, events); + const cb = protocolListeners[0]; + if (!cb) { + throw new Error("no protocol listener"); + } + cb( + `lightfast-dev://auth/callback?code=${"d".repeat(43)}&state=${captured.state}` + ); + + // fetch rejected → exchangeCode returns null → exchange_failed (NOT + // handler_error, because the catch is upstream of the rejection). + const result = await signIn; + expect(result).toBeNull(); + // Either exchange_failed (rejected fetch caught inside exchangeCode) is + // the expected event here. + expect( + events.find((e) => e.event === "auth_signin_failed") + ).toMatchObject({ reason: "exchange_failed" }); + } finally { + restoreStdout(); + restore(); + fetchSpy.mockRestore(); + } + }); + + it("timeout emits auth_signin_failed{reason:timeout}; configurable via LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS", async () => { + vi.useFakeTimers(); + const { events, restore: restoreStdout } = spyStdout(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AGENT_MODE: "1", + LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", + }); + try { + const signIn = mod.beginSignIn(); + // Allow microtasks for the URL emission, then advance past 100ms. + await vi.advanceTimersByTimeAsync(50); + await vi.advanceTimersByTimeAsync(150); + const result = await signIn; + expect(result).toBeNull(); + expect( + events.find((e) => e.event === "auth_signin_failed") + ).toMatchObject({ reason: "timeout" }); + expect(sentryCaptureMessageMock).toHaveBeenCalledWith( + expect.stringContaining("timeout"), + expect.objectContaining({ tags: { scope: "auth-flow.timeout" } }) + ); + } finally { + restoreStdout(); + restore(); + } + }, 5000); + + it("duplicate callbacks during exchange in-flight do not trigger a second exchangeCode", async () => { + const fetchGate: { resolve: (value: Response) => void } = { + resolve: () => { + // overwritten below + }, + }; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + () => + new Promise((r) => { + fetchGate.resolve = r; + }) + ); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + }); + try { + const signIn = mod.beginSignIn(); + const captured = await captureSigninUrl(true, []); + const cb = protocolListeners[0]; + if (!cb) { + throw new Error("no protocol listener"); + } + // Fire two valid callbacks back-to-back. The first enters exchange + // and awaits the paused fetch; the second must short-circuit on + // callbackInFlight before issuing a second fetch. + const url = `lightfast-dev://auth/callback?code=${"a".repeat(43)}&state=${captured.state}`; + cb(url); + cb(url); + + // Yield once to let the first cb's microtasks run before resolving. + await Promise.resolve(); + await Promise.resolve(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Now resolve the in-flight exchange so signIn settles. + fetchGate.resolve( + new Response(JSON.stringify({ token: "real-jwt" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) as unknown as Response + ); + const result = await signIn; + expect(result).toBe("real-jwt"); + expect(setTokenMock).toHaveBeenCalledTimes(1); + // Confirm no late second fetch sneaks in after settle. + expect(fetchSpy).toHaveBeenCalledTimes(1); + } finally { + restore(); + fetchSpy.mockRestore(); + } + }); + + it("inflight singleton: concurrent beginSignIn calls share a single promise", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", + }); + try { + const a = mod.beginSignIn(); + const b = mod.beginSignIn(); + expect(a).toBe(b); + + // shell.openExternal is invoked synchronously from inside the + // Promise executor in non-agent mode, so we expect it called by now. + expect(shellOpenExternalMock).toHaveBeenCalledTimes(1); + + const [r1, r2] = await Promise.all([a, b]); + // Both callers see the same (timeout → null) result. + expect(r1).toBe(r2); + } finally { + restore(); + } + }); +}); + +describe("auth-flow LIGHTFAST_DESKTOP_AGENT_MODE", () => { + it("agent mode: shell.openExternal NOT called; stdout receives exactly one auth_signin_url line", async () => { + const { events, restore: restoreStdout } = spyStdout(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AGENT_MODE: "1", + LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", + }); + try { + const signIn = mod.beginSignIn(); + + // The URL emission happens synchronously inside the Promise executor + // before the first await, so events should already contain it. + const urlEvents = events.filter((e) => e.event === "auth_signin_url"); + expect(urlEvents).toHaveLength(1); + expect(shellOpenExternalMock).not.toHaveBeenCalled(); + const ev = urlEvents[0]; + if (!ev || ev.event !== "auth_signin_url") { + throw new Error("expected auth_signin_url event"); + } + const signinUrl = new URL(ev.url); + expect(signinUrl.searchParams.get("redirect_uri")).toBe( + "lightfast-dev://auth/callback" + ); + + await signIn; + } finally { + restoreStdout(); + restore(); + } + }); + + it("non-agent mode: shell.openExternal IS called; stdout has no auth_signin_url JSON line", async () => { + const { events, restore: restoreStdout } = spyStdout(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", + }); + try { + const signIn = mod.beginSignIn(); + expect(shellOpenExternalMock).toHaveBeenCalledTimes(1); + expect(events.find((e) => e.event === "auth_signin_url")).toBeUndefined(); + await signIn; + } finally { + restoreStdout(); + restore(); + } + }); +}); + +describe("auth-flow maybeAutoBeginSignIn", () => { + it("outside AGENT_MODE: no-op (no events, beginSignIn not invoked)", async () => { + const { events, restore: restoreStdout } = spyStdout(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + }); + try { + mod.maybeAutoBeginSignIn(); + expect(events).toHaveLength(0); + expect(shellOpenExternalMock).not.toHaveBeenCalled(); + expect(getTokenMock).not.toHaveBeenCalled(); + } finally { + restoreStdout(); + restore(); + } + }); + + it("AGENT_MODE + token present: emits auth_already_signed_in exactly once and does NOT begin sign-in", async () => { + getTokenMock.mockReturnValue("existing-jwt"); + const { events, restore: restoreStdout } = spyStdout(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AGENT_MODE: "1", + }); + try { + mod.maybeAutoBeginSignIn(); + expect( + events.filter((e) => e.event === "auth_already_signed_in") + ).toHaveLength(1); + expect(events.find((e) => e.event === "auth_signin_url")).toBeUndefined(); + expect(shellOpenExternalMock).not.toHaveBeenCalled(); + } finally { + restoreStdout(); + restore(); + } + }); + + it("AGENT_MODE + no token: calls beginSignIn (auth_signin_url emitted)", async () => { + getTokenMock.mockReturnValue(null); + const { events, restore: restoreStdout } = spyStdout(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AGENT_MODE: "1", + LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", + }); + try { + mod.maybeAutoBeginSignIn(); + + expect(events.find((e) => e.event === "auth_signin_url")).toBeDefined(); + expect(shellOpenExternalMock).not.toHaveBeenCalled(); + + // Wait for the (auto) sign-in promise to time out so we don't leak + // pending timers into subsequent tests. + await new Promise((r) => setTimeout(r, 150)); + } finally { + restoreStdout(); + restore(); + } + }); +}); + +describe("auth-flow event grammar", () => { + it("every auth_signin_url is followed by exactly one terminal event per in-flight sign-in", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ token: "jwt" }), { + status: 200, + }) as unknown as Response + ); + const { events, restore: restoreStdout } = spyStdout(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_DESKTOP_AGENT_MODE: "1", + }); + try { + const signIn = mod.beginSignIn(); + const captured = await captureSigninUrl(false, events); + const cb = protocolListeners[0]; + if (!cb) { + throw new Error("no protocol listener"); + } + cb( + `lightfast-dev://auth/callback?code=${"e".repeat(43)}&state=${captured.state}` + ); + await signIn; + + const urlCount = events.filter( + (e) => e.event === "auth_signin_url" + ).length; + const terminalCount = events.filter( + (e) => e.event === "auth_signed_in" || e.event === "auth_signin_failed" + ).length; + expect(urlCount).toBe(1); + expect(terminalCount).toBe(1); + expect(events.find((e) => e.event === "auth_signed_in")).toBeDefined(); + } finally { + restoreStdout(); + restore(); + fetchSpy.mockRestore(); + } + }); +}); diff --git a/apps/desktop/src/main/__tests__/auth-focus-gate.test.ts b/apps/desktop/src/main/__tests__/auth-focus-gate.test.ts new file mode 100644 index 000000000..add3e2025 --- /dev/null +++ b/apps/desktop/src/main/__tests__/auth-focus-gate.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createAuthFocusGate, type FocusableWindow } from "../auth-focus-gate"; + +type WindowSpy = FocusableWindow & { + show: ReturnType; + focus: ReturnType; +}; + +function makeWindowSpy(): WindowSpy { + return { show: vi.fn(), focus: vi.fn() } as WindowSpy; +} + +describe("createAuthFocusGate", () => { + let win: WindowSpy; + let windows: FocusableWindow[]; + + beforeEach(() => { + win = makeWindowSpy(); + windows = [win]; + }); + + it("focuses on signed-out → signed-in transition", () => { + const gate = createAuthFocusGate({ + initiallySignedIn: false, + getWindows: () => windows, + }); + gate({ isSignedIn: true }); + expect(win.show).toHaveBeenCalledTimes(1); + expect(win.focus).toHaveBeenCalledTimes(1); + }); + + it("does not focus on signed-in → signed-in (token refresh)", () => { + const gate = createAuthFocusGate({ + initiallySignedIn: true, + getWindows: () => windows, + }); + gate({ isSignedIn: true }); + expect(win.show).not.toHaveBeenCalled(); + expect(win.focus).not.toHaveBeenCalled(); + }); + + it("does not focus on signed-in → signed-out (sign-out)", () => { + const gate = createAuthFocusGate({ + initiallySignedIn: true, + getWindows: () => windows, + }); + gate({ isSignedIn: false }); + expect(win.show).not.toHaveBeenCalled(); + expect(win.focus).not.toHaveBeenCalled(); + }); + + it("focuses on re-sign-in after sign-out", () => { + const gate = createAuthFocusGate({ + initiallySignedIn: false, + getWindows: () => windows, + }); + gate({ isSignedIn: true }); + gate({ isSignedIn: false }); + gate({ isSignedIn: true }); + expect(win.show).toHaveBeenCalledTimes(2); + expect(win.focus).toHaveBeenCalledTimes(2); + }); + + it("focuses every window in the list, not just the first", () => { + const win2 = makeWindowSpy(); + windows = [win, win2]; + const gate = createAuthFocusGate({ + initiallySignedIn: false, + getWindows: () => windows, + }); + gate({ isSignedIn: true }); + expect(win.show).toHaveBeenCalledTimes(1); + expect(win2.show).toHaveBeenCalledTimes(1); + expect(win.focus).toHaveBeenCalledTimes(1); + expect(win2.focus).toHaveBeenCalledTimes(1); + }); + + it("calls getWindows at fire-time, not at gate-construction time", () => { + const getWindows = vi.fn(() => windows); + const gate = createAuthFocusGate({ + initiallySignedIn: false, + getWindows, + }); + expect(getWindows).not.toHaveBeenCalled(); + gate({ isSignedIn: true }); + expect(getWindows).toHaveBeenCalledTimes(1); + }); + + it("does not call getWindows for non-focusing transitions", () => { + const getWindows = vi.fn(() => windows); + const gate = createAuthFocusGate({ + initiallySignedIn: true, + getWindows, + }); + gate({ isSignedIn: false }); + gate({ isSignedIn: false }); + expect(getWindows).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/__tests__/protocol.test.ts b/apps/desktop/src/main/__tests__/protocol.test.ts new file mode 100644 index 000000000..b8ddc12be --- /dev/null +++ b/apps/desktop/src/main/__tests__/protocol.test.ts @@ -0,0 +1,395 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type AppEvent = "open-url" | "second-instance" | (string & {}); +type AppEventHandler = (...args: unknown[]) => void; + +const setAsDefaultProtocolClientMock = vi.fn(); +const eventHandlers = new Map(); +let isPackagedFlag = false; +let whenReadyResolved = true; + +vi.mock("electron", () => ({ + app: { + get isPackaged() { + return isPackagedFlag; + }, + setAsDefaultProtocolClient: (...args: unknown[]) => + setAsDefaultProtocolClientMock(...args), + on: (event: AppEvent, handler: AppEventHandler) => { + eventHandlers.set(event, handler); + }, + whenReady: () => + whenReadyResolved + ? Promise.resolve() + : new Promise(() => { + // never resolves + }), + }, +})); + +async function loadProtocol(opts?: { + isPackaged?: boolean; + argv?: string[]; + platform?: NodeJS.Platform; + whenReady?: boolean; + defaultApp?: boolean; +}) { + vi.resetModules(); + eventHandlers.clear(); + setAsDefaultProtocolClientMock.mockClear(); + isPackagedFlag = opts?.isPackaged ?? false; + + const prevArgv = process.argv; + const prevPlatform = process.platform; + const prevDefaultApp = (process as { defaultApp?: boolean }).defaultApp; + if (opts?.argv) { + process.argv = opts.argv; + } + if (opts?.platform) { + Object.defineProperty(process, "platform", { + value: opts.platform, + configurable: true, + }); + } + if (opts?.defaultApp !== undefined) { + Object.defineProperty(process, "defaultApp", { + value: opts.defaultApp, + configurable: true, + }); + } + whenReadyResolved = opts?.whenReady ?? true; + + const mod = await import("../protocol"); + return { + mod, + restore: () => { + process.argv = prevArgv; + Object.defineProperty(process, "platform", { + value: prevPlatform, + configurable: true, + }); + if (opts?.defaultApp !== undefined) { + if (prevDefaultApp === undefined) { + // biome-ignore lint/performance/noDelete: test cleanup must remove the property entirely; assigning `undefined` would leave a defined property where there was none. + delete (process as { defaultApp?: boolean }).defaultApp; + } else { + Object.defineProperty(process, "defaultApp", { + value: prevDefaultApp, + configurable: true, + }); + } + } + }, + }; +} + +function makeWindow(overrides?: { destroyed?: boolean; minimized?: boolean }): { + win: { + isDestroyed: () => boolean; + isMinimized: () => boolean; + show: ReturnType; + focus: ReturnType; + restore: ReturnType; + }; +} { + return { + win: { + isDestroyed: () => Boolean(overrides?.destroyed), + isMinimized: () => Boolean(overrides?.minimized), + show: vi.fn(), + focus: vi.fn(), + restore: vi.fn(), + }, + }; +} + +describe("protocol", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + describe("getProtocolScheme", () => { + it("returns 'lightfast-dev' when not packaged", async () => { + const { mod, restore } = await loadProtocol({ isPackaged: false }); + try { + expect(mod.getProtocolScheme()).toBe("lightfast-dev"); + } finally { + restore(); + } + }); + + it("returns 'lightfast' when packaged", async () => { + const { mod, restore } = await loadProtocol({ isPackaged: true }); + try { + expect(mod.getProtocolScheme()).toBe("lightfast"); + } finally { + restore(); + } + }); + }); + + describe("registerProtocolHandler", () => { + beforeEach(() => { + setAsDefaultProtocolClientMock.mockClear(); + }); + + it("registers the dev scheme as the default protocol client when unpackaged", async () => { + const { mod, restore } = await loadProtocol({ isPackaged: false }); + try { + mod.registerProtocolHandler(() => []); + expect(setAsDefaultProtocolClientMock).toHaveBeenCalledWith( + "lightfast-dev" + ); + } finally { + restore(); + } + }); + + it("attaches the open-url listener synchronously, before app.whenReady() resolves", async () => { + // Use a never-resolving whenReady so any whenReady-deferred work cannot + // run; assert the listener still exists immediately after the call. + const { mod, restore } = await loadProtocol({ + isPackaged: false, + whenReady: false, + }); + try { + expect(eventHandlers.has("open-url")).toBe(false); + mod.registerProtocolHandler(() => []); + expect(eventHandlers.has("open-url")).toBe(true); + } finally { + restore(); + } + }); + + it("registers Windows three-arg form when running unpackaged on win32 dev", async () => { + const { mod, restore } = await loadProtocol({ + isPackaged: false, + platform: "win32", + defaultApp: true, + argv: ["C:/electron.exe", "C:/lightfast/main.js"], + }); + try { + mod.registerProtocolHandler(() => []); + expect(setAsDefaultProtocolClientMock).toHaveBeenCalledTimes(1); + const call = setAsDefaultProtocolClientMock.mock.calls[0] ?? []; + expect(call[0]).toBe("lightfast-dev"); + expect(typeof call[1]).toBe("string"); + expect(Array.isArray(call[2])).toBe(true); + const argvPath = (call[2] as unknown[])[0]; + expect(typeof argvPath).toBe("string"); + // resolve(argv[1]) must end with the script path. + expect(String(argvPath)).toContain("main.js"); + } finally { + restore(); + } + }); + + it("registers single-arg form on packaged win32 (process.defaultApp is false)", async () => { + const { mod, restore } = await loadProtocol({ + isPackaged: true, + platform: "win32", + defaultApp: false, + argv: ["C:/Program Files/Lightfast/lightfast.exe"], + }); + try { + mod.registerProtocolHandler(() => []); + expect(setAsDefaultProtocolClientMock).toHaveBeenCalledWith( + "lightfast" + ); + expect(setAsDefaultProtocolClientMock.mock.calls[0]?.length).toBe(1); + } finally { + restore(); + } + }); + + it("dispatches matching open-url events to all listeners", async () => { + const { mod, restore } = await loadProtocol({ isPackaged: false }); + try { + mod.registerProtocolHandler(() => []); + const a = vi.fn(); + const b = vi.fn(); + mod.onProtocolUrl(a); + mod.onProtocolUrl(b); + + const handler = eventHandlers.get("open-url"); + if (!handler) { + throw new Error("open-url handler not registered"); + } + const event = { preventDefault: vi.fn() }; + handler(event, "lightfast-dev://auth/callback?code=abc&state=xyz"); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(a).toHaveBeenCalledWith( + "lightfast-dev://auth/callback?code=abc&state=xyz" + ); + expect(b).toHaveBeenCalledWith( + "lightfast-dev://auth/callback?code=abc&state=xyz" + ); + } finally { + restore(); + } + }); + + it("ignores foreign-scheme URLs", async () => { + const { mod, restore } = await loadProtocol({ isPackaged: false }); + try { + mod.registerProtocolHandler(() => []); + const listener = vi.fn(); + mod.onProtocolUrl(listener); + + const handler = eventHandlers.get("open-url"); + if (!handler) { + throw new Error("open-url handler not registered"); + } + handler({ preventDefault: vi.fn() }, "lightfast://auth/callback"); + + expect(listener).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("returning the unsubscribe function detaches the listener", async () => { + const { mod, restore } = await loadProtocol({ isPackaged: false }); + try { + mod.registerProtocolHandler(() => []); + const listener = vi.fn(); + const unsubscribe = mod.onProtocolUrl(listener); + unsubscribe(); + + const handler = eventHandlers.get("open-url"); + handler?.({ preventDefault: vi.fn() }, "lightfast-dev://auth/callback"); + expect(listener).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("extracts URL from argv on second-instance and dispatches", async () => { + const { mod, restore } = await loadProtocol({ isPackaged: false }); + try { + mod.registerProtocolHandler(() => []); + const listener = vi.fn(); + mod.onProtocolUrl(listener); + + const handler = eventHandlers.get("second-instance"); + if (!handler) { + throw new Error("second-instance handler not registered"); + } + handler({}, [ + "/path/to/electron", + "--some-flag", + "lightfast-dev://auth/callback?code=z", + ]); + + expect(listener).toHaveBeenCalledWith( + "lightfast-dev://auth/callback?code=z" + ); + } finally { + restore(); + } + }); + + it("ignores second-instance argv that has no matching scheme", async () => { + const { mod, restore } = await loadProtocol({ isPackaged: false }); + try { + mod.registerProtocolHandler(() => []); + const listener = vi.fn(); + mod.onProtocolUrl(listener); + + const handler = eventHandlers.get("second-instance"); + handler?.({}, ["/path/to/electron", "--some-flag"]); + + expect(listener).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("surfaces a non-destroyed window on dispatch (show + focus)", async () => { + const { mod, restore } = await loadProtocol({ isPackaged: false }); + try { + const { win } = makeWindow({ minimized: false }); + const destroyed = makeWindow({ destroyed: true }).win; + mod.registerProtocolHandler(() => [destroyed, win] as never); + mod.onProtocolUrl(vi.fn()); + + const handler = eventHandlers.get("open-url"); + handler?.({ preventDefault: vi.fn() }, "lightfast-dev://auth/callback"); + + expect(win.show).toHaveBeenCalled(); + expect(win.focus).toHaveBeenCalled(); + expect(win.restore).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("restores a minimized window on dispatch", async () => { + const { mod, restore } = await loadProtocol({ isPackaged: false }); + try { + const { win } = makeWindow({ minimized: true }); + mod.registerProtocolHandler(() => [win] as never); + + const handler = eventHandlers.get("open-url"); + handler?.({ preventDefault: vi.fn() }, "lightfast-dev://auth/callback"); + + expect(win.restore).toHaveBeenCalled(); + expect(win.show).toHaveBeenCalled(); + expect(win.focus).toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("on Windows/Linux, dispatches a matching URL found in process.argv at first launch", async () => { + const { mod, restore } = await loadProtocol({ + isPackaged: false, + platform: "win32", + argv: [ + "C:/lightfast/electron.exe", + "lightfast-dev://auth/callback?code=launch", + ], + }); + try { + const listener = vi.fn(); + // Register handler then subscribe — module schedules dispatch via app.whenReady(). + mod.registerProtocolHandler(() => []); + mod.onProtocolUrl(listener); + + // Flush microtasks (whenReady is resolved). + await Promise.resolve(); + await Promise.resolve(); + + expect(listener).toHaveBeenCalledWith( + "lightfast-dev://auth/callback?code=launch" + ); + } finally { + restore(); + } + }); + + it("on macOS, never reads process.argv for a first-launch URL (relies on open-url)", async () => { + const { mod, restore } = await loadProtocol({ + isPackaged: false, + platform: "darwin", + argv: [ + "/Applications/Lightfast Dev.app", + "lightfast-dev://auth/callback?code=should-be-ignored", + ], + }); + try { + const listener = vi.fn(); + mod.registerProtocolHandler(() => []); + mod.onProtocolUrl(listener); + + await Promise.resolve(); + await Promise.resolve(); + + expect(listener).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + }); +}); diff --git a/apps/desktop/src/main/auth-flow.ts b/apps/desktop/src/main/auth-flow.ts index 42f027b07..6dec2d8c1 100644 --- a/apps/desktop/src/main/auth-flow.ts +++ b/apps/desktop/src/main/auth-flow.ts @@ -1,135 +1,274 @@ -import { randomBytes } from "node:crypto"; -import { createServer, type Server } from "node:http"; +import { createHash, randomBytes } from "node:crypto"; +import { + captureException, + captureMessage, +} from "@vendor/observability/sentry-electron-main"; import { shell } from "electron"; +import { z } from "zod"; import { createAppUrl } from "./app-url"; -import { setToken } from "./auth-store"; - -const SIGNIN_TIMEOUT_MS = 5 * 60_000; -const LOOPBACK_HOST = "127.0.0.1"; -const CALLBACK_PATH = "/callback"; - -function responsePage(message: string): string { - return ` - - - - Lightfast - - - - -
-

${message}

-

You can close this tab and return to Lightfast.

-
- -`; +import { getToken, setToken } from "./auth-store"; +import { getProtocolScheme, onProtocolUrl } from "./protocol"; + +const DEFAULT_SIGNIN_TIMEOUT_MS = 5 * 60_000; + +function getSigninTimeoutMs(): number { + const raw = process.env.LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS; + if (!raw) { + return DEFAULT_SIGNIN_TIMEOUT_MS; + } + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_SIGNIN_TIMEOUT_MS; } -async function startLoopbackServer(): Promise<{ - server: Server; - port: number; -}> { - const server = createServer(); - await new Promise((resolve, reject) => { - const onError = (error: Error) => { - server.off("listening", onListening); - reject(error); - }; - const onListening = () => { - server.off("error", onError); - resolve(); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(0, LOOPBACK_HOST); - }); - const address = server.address(); - if (!address || typeof address !== "object") { - server.close(); - throw new Error("loopback server failed to bind"); +function isAgentMode(): boolean { + return process.env.LIGHTFAST_DESKTOP_AGENT_MODE === "1"; +} + +type AuthEvent = + | { event: "auth_already_signed_in" } + | { event: "auth_signin_url"; url: string } + | { event: "auth_signed_in" } + | { event: "auth_signin_failed"; reason: string }; + +function emitAgentEvent(payload: AuthEvent): void { + if (!isAgentMode()) { + return; } - return { server, port: address.port }; + process.stdout.write(`${JSON.stringify(payload)}\n`); } -export async function beginSignIn(): Promise { - const state = randomBytes(32).toString("hex"); +const callbackSchema = z.object({ + code: z.string().min(32).max(128), + state: z.string().min(16).max(256), +}); + +const exchangeResponseSchema = z.object({ token: z.string().min(1) }); + +let inflight: Promise | null = null; +let pendingSigninUrl: string | null = null; +const urlListeners = new Set<(url: string | null) => void>(); - let bound: { server: Server; port: number }; +export function getPendingSigninUrl(): string | null { + return pendingSigninUrl; +} + +export function onPendingSigninUrl( + listener: (url: string | null) => void +): () => void { + urlListeners.add(listener); + return () => urlListeners.delete(listener); +} + +function setPendingSigninUrl(url: string | null): void { + pendingSigninUrl = url; + for (const listener of urlListeners) { + listener(url); + } +} + +export function beginSignIn(): Promise { + if (inflight) { + return inflight; + } + inflight = (async () => { + try { + return await runSignIn(); + } finally { + inflight = null; + setPendingSigninUrl(null); + } + })(); + return inflight; +} + +// Auto-trigger sign-in for agent mode on app-ready. Idempotent — when a +// token is already persisted, emits auth_already_signed_in instead of +// re-running the flow. No-op outside AGENT_MODE. +export function maybeAutoBeginSignIn(): void { + if (!isAgentMode()) { + return; + } + if (getToken()) { + emitAgentEvent({ event: "auth_already_signed_in" }); + return; + } + void beginSignIn(); +} + +function matchesAuthCallback(rawUrl: string, scheme: string): boolean { + if (!rawUrl.startsWith(`${scheme}://`)) { + return false; + } + // Node's URL parser handles custom schemes inconsistently across platforms + // (host="auth"+pathname="/callback" vs. host=""+pathname="//auth/callback"). + // Normalize by concatenating and stripping leading slashes. + let url: URL; try { - bound = await startLoopbackServer(); - } catch (error) { - console.error("[auth-flow] loopback bind failed", error); - return null; + url = new URL(rawUrl); + } catch { + return false; } - const { server, port } = bound; - const callbackUrl = `http://${LOOPBACK_HOST}:${port}${CALLBACK_PATH}`; + const path = `${url.host}${url.pathname}`.replace(/^\/+/, ""); + return path === "auth/callback"; +} + +async function runSignIn(): Promise { + const state = randomBytes(32).toString("base64url"); + const codeVerifier = randomBytes(32).toString("base64url"); + const codeChallenge = createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + const scheme = getProtocolScheme(); + const redirectUri = `${scheme}://auth/callback`; + + const signinUrl = createAppUrl("/desktop/auth"); + signinUrl.searchParams.set("state", state); + signinUrl.searchParams.set("code_challenge", codeChallenge); + signinUrl.searchParams.set("code_challenge_method", "S256"); + signinUrl.searchParams.set("redirect_uri", redirectUri); return new Promise((resolve) => { let settled = false; + // Two `open-url` events can race past `settled === false` while the + // first exchange is awaiting fetch. Both would call exchangeCode with + // the same single-use code; the second hits a 410 and emits Sentry + // noise. `callbackInFlight` short-circuits the duplicate before the + // network call. (settle() already unsubscribes synchronously, so the + // race is bounded to the await window of the first exchange.) + let callbackInFlight = false; const settle = (token: string | null) => { if (settled) { return; } settled = true; clearTimeout(timer); - server.close(); - if (token) { - setToken(token); - } + unsubscribe(); resolve(token); }; - const timer = setTimeout(() => settle(null), SIGNIN_TIMEOUT_MS); + const timer = setTimeout(() => { + captureMessage("auth-flow: sign-in timeout", { + level: "warning", + tags: { scope: "auth-flow.timeout" }, + }); + emitAgentEvent({ event: "auth_signin_failed", reason: "timeout" }); + settle(null); + }, getSigninTimeoutMs()); - server.on("request", (req, res) => { + const unsubscribe = onProtocolUrl(async (rawUrl) => { try { - const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}:${port}`); - if (url.pathname !== CALLBACK_PATH) { - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not Found"); + if ( + settled || + callbackInFlight || + !matchesAuthCallback(rawUrl, scheme) + ) { + return; + } + const url = new URL(rawUrl); + const parsed = callbackSchema.safeParse({ + code: url.searchParams.get("code"), + state: url.searchParams.get("state"), + }); + if (!parsed.success) { + return; + } + if (parsed.data.state !== state) { + captureMessage("auth-flow: state mismatch", { + level: "warning", + tags: { scope: "auth-flow.state_mismatch" }, + }); + return; + } + callbackInFlight = true; + const token = await exchangeCode(parsed.data.code, codeVerifier); + if (settled) { + return; + } + if (!token) { + emitAgentEvent({ + event: "auth_signin_failed", + reason: "exchange_failed", + }); + settle(null); + return; + } + const persisted = setToken(token); + if (!persisted) { + captureException(new Error("auth-flow: persist failed"), { + tags: { scope: "auth-flow.persist_failed" }, + }); + emitAgentEvent({ + event: "auth_signin_failed", + reason: "persist_failed", + }); + settle(null); return; } - const token = url.searchParams.get("token"); - const returned = url.searchParams.get("state"); - const ok = !!token && returned === state; - res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html" }); - res.end(responsePage(ok ? "Signed in to Lightfast" : "Sign-in failed")); - settle(ok ? token : null); + emitAgentEvent({ event: "auth_signed_in" }); + settle(token); } catch (error) { - console.error("[auth-flow] loopback handler error", error); - res.writeHead(500, { "Content-Type": "text/plain" }); - res.end("Internal Server Error"); + console.error("[auth-flow] callback handler error", error); + captureException(error, { + tags: { scope: "auth-flow.handler_error" }, + }); + emitAgentEvent({ + event: "auth_signin_failed", + reason: "handler_error", + }); settle(null); } }); - server.on("error", (error) => { - console.error("[auth-flow] loopback server error", error); - settle(null); - }); + setPendingSigninUrl(signinUrl.toString()); - const signInUrl = createAppUrl("/desktop/auth"); - signInUrl.searchParams.set("state", state); - signInUrl.searchParams.set("callback", callbackUrl); + if (isAgentMode()) { + // Agent harnesses (e.g. Claude Code via agent-browser) parse a single + // structured line off stdout instead of the system-default browser. Pair + // with AGENT_BROWSER_HEADED=true on the agent side — headless Chromium + // silently drops custom-scheme navigations (validated 2026-04-25 spike). + emitAgentEvent({ event: "auth_signin_url", url: signinUrl.toString() }); + return; + } - console.log( - `[auth-flow] signin url=${signInUrl.toString()} callback=${callbackUrl}` - ); - - shell.openExternal(signInUrl.toString()).catch((error) => { + shell.openExternal(signinUrl.toString()).catch((error) => { console.error("[auth-flow] shell.openExternal failed", error); + captureException(error, { + tags: { scope: "auth-flow.open_external" }, + }); settle(null); }); }); } + +async function exchangeCode( + code: string, + codeVerifier: string +): Promise { + try { + const response = await fetch( + createAppUrl("/api/desktop/auth/exchange").toString(), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, code_verifier: codeVerifier }), + } + ); + if (!response.ok) { + captureMessage("auth-flow: exchange non-ok", { + level: "warning", + tags: { + scope: "auth-flow.exchange_non_ok", + status: String(response.status), + }, + }); + return null; + } + const json = exchangeResponseSchema.safeParse(await response.json()); + return json.success ? json.data.token : null; + } catch (error) { + captureException(error, { + tags: { scope: "auth-flow.exchange_network" }, + }); + return null; + } +} diff --git a/apps/desktop/src/main/auth-focus-gate.ts b/apps/desktop/src/main/auth-focus-gate.ts new file mode 100644 index 000000000..a541be695 --- /dev/null +++ b/apps/desktop/src/main/auth-focus-gate.ts @@ -0,0 +1,32 @@ +export interface FocusableWindow { + focus: () => void; + show: () => void; +} + +export interface AuthFocusGateOptions { + getWindows: () => FocusableWindow[]; + initiallySignedIn: boolean; +} + +/** + * Tracks the signed-in transition so that only a false → true flip yanks + * focus. Token refreshes (true → true) and sign-outs (true → false) are + * ignored. Seeding `initiallySignedIn` from the current auth snapshot + * prevents a false positive when the boot-time `emit(true)` arrives before + * the subscriber. + */ +export function createAuthFocusGate( + options: AuthFocusGateOptions +): (snapshot: { isSignedIn: boolean }) => void { + let prev = options.initiallySignedIn; + return (snapshot) => { + const next = Boolean(snapshot.isSignedIn); + if (!prev && next) { + for (const win of options.getWindows()) { + win.show(); + win.focus(); + } + } + prev = next; + }; +} diff --git a/apps/desktop/src/main/auth-store.ts b/apps/desktop/src/main/auth-store.ts index 25af26cf1..e7bc77c38 100644 --- a/apps/desktop/src/main/auth-store.ts +++ b/apps/desktop/src/main/auth-store.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { captureException } from "@vendor/observability/sentry-electron-main"; import { app, safeStorage } from "electron"; import { z } from "zod"; @@ -20,6 +21,17 @@ function storePath(): string { return join(app.getPath("userData"), "auth.bin"); } +function purgePersisted(filePath: string, scope: string): boolean { + try { + rmSync(filePath, { force: true }); + return true; + } catch (err) { + console.warn("[auth-store] purge failed", err); + captureException(err, { tags: { scope } }); + return false; + } +} + function load(): string | null { if (memory) { return memory; @@ -37,40 +49,48 @@ function load(): string | null { const parsed = persistedSchema.safeParse(JSON.parse(plain)); if (!parsed.success) { console.error("[auth-store] invalid persisted payload", parsed.error); + captureException(parsed.error, { + tags: { scope: "auth-store.load.schema" }, + }); + purgePersisted(path, "auth-store.load.schema.purge"); return null; } memory = parsed.data.token; return memory; } catch (err) { - console.error("[auth-store] failed to load", err); + console.error("[auth-store] failed to load; purging", err); + captureException(err, { tags: { scope: "auth-store.load" } }); + purgePersisted(path, "auth-store.load.purge"); return null; } } -function persist(token: string): void { - memory = token; +function persist(token: string): boolean { if (!safeStorage.isEncryptionAvailable()) { console.error( "[auth-store] safeStorage unavailable; refusing to write plaintext" ); - return; + return false; } try { const payload: Persisted = { token, savedAt: Date.now() }; const buf = safeStorage.encryptString(JSON.stringify(payload)); writeFileSync(storePath(), buf); + memory = token; + return true; } catch (err) { console.error("[auth-store] failed to persist", err); + captureException(err, { tags: { scope: "auth-store.persist" } }); + return false; } } -function clearPersisted(): void { - memory = null; - try { - rmSync(storePath(), { force: true }); - } catch (err) { - console.error("[auth-store] failed to remove", err); +function clearPersisted(): boolean { + const ok = purgePersisted(storePath(), "auth-store.clear"); + if (ok) { + memory = null; } + return ok; } function emit(): void { @@ -94,14 +114,20 @@ export function getToken(): string | null { return memory; } -export function setToken(token: string): void { - persist(token); - emit(); +export function setToken(token: string): boolean { + const ok = persist(token); + if (ok) { + emit(); + } + return ok; } -export function signOut(): void { - clearPersisted(); - emit(); +export function signOut(): boolean { + const ok = clearPersisted(); + if (ok) { + emit(); + } + return ok; } export function onAuthChanged( diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 76b1a2a9d..31d23a6da 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -15,7 +15,13 @@ import { type SystemThemeVariant, } from "../shared/ipc"; import { openAppOrigin } from "./app-url"; -import { beginSignIn } from "./auth-flow"; +import { + beginSignIn, + getPendingSigninUrl, + maybeAutoBeginSignIn, + onPendingSigninUrl, +} from "./auth-flow"; +import { createAuthFocusGate } from "./auth-focus-gate"; import { getAuthSnapshot, getToken as getAuthToken, @@ -24,6 +30,7 @@ import { } from "./auth-store"; import { getBuildInfo } from "./build-info"; import { buildApplicationMenu } from "./menu"; +import { registerProtocolHandler } from "./protocol"; import { getRuntimeConfig } from "./runtime-config"; import { initSentry } from "./sentry"; import { @@ -220,9 +227,8 @@ function registerIpcHandlers(): void { }); ipcMain.handle(IpcChannels.authGetToken, () => getAuthToken()); ipcMain.handle(IpcChannels.authSignIn, () => beginSignIn()); - ipcMain.handle(IpcChannels.authSignOut, () => { - signOutAuth(); - }); + ipcMain.handle(IpcChannels.authSignOut, () => signOutAuth()); + ipcMain.handle(IpcChannels.authPendingSigninUrl, () => getPendingSigninUrl()); } function broadcastThemeUpdates(): void { @@ -340,6 +346,11 @@ function broadcastSettings(snapshot: SettingsSnapshot): void { initSentry(); +// Register the custom-scheme handler synchronously, before app.whenReady(). +// macOS delivers cold-start `open-url` events between app launch and ready; +// attaching the listener inside whenReady().then(...) loses those URLs. +registerProtocolHandler(() => BrowserWindow.getAllWindows()); + contextMenu({ showInspectElement: !app.isPackaged, showSaveImageAs: true, @@ -378,11 +389,25 @@ app.whenReady().then(() => { }); void openPrimaryWindow(); + const focusGate = createAuthFocusGate({ + initiallySignedIn: Boolean(getAuthSnapshot().isSignedIn), + getWindows: () => BrowserWindow.getAllWindows(), + }); onAuthChanged((snapshot) => { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(IpcChannels.authChanged, snapshot); } + focusGate(snapshot); }); + onPendingSigninUrl((url) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IpcChannels.authPendingSigninUrlChanged, url); + } + }); + + // Agent-mode auto-trigger. No-op outside agent mode. Idempotent — emits + // auth_already_signed_in if a token is already persisted. + maybeAutoBeginSignIn(); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/apps/desktop/src/main/protocol.ts b/apps/desktop/src/main/protocol.ts new file mode 100644 index 000000000..4ddf0a96b --- /dev/null +++ b/apps/desktop/src/main/protocol.ts @@ -0,0 +1,78 @@ +import path from "node:path"; +import { app, type BrowserWindow } from "electron"; + +export type ProtocolUrlListener = (url: string) => void; + +const listeners = new Set(); + +export function getProtocolScheme(): "lightfast" | "lightfast-dev" { + return app.isPackaged ? "lightfast" : "lightfast-dev"; +} + +export function onProtocolUrl(listener: ProtocolUrlListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function registerProtocolHandler( + getWindows: () => BrowserWindow[] +): void { + const scheme = getProtocolScheme(); + // Windows-dev: when launched as `electron .` (process.defaultApp === true) + // the OS records the Electron binary as the protocol target, not the script, + // so subsequent invocations open Electron with no app to run. Pass execPath + // + the resolved script path so the registered command-line is reproducible. + if ( + process.platform === "win32" && + (process as { defaultApp?: boolean }).defaultApp && + process.argv.length >= 2 + ) { + app.setAsDefaultProtocolClient(scheme, process.execPath, [ + path.resolve(process.argv[1] ?? ""), + ]); + } else { + app.setAsDefaultProtocolClient(scheme); + } + + const dispatch = (rawUrl: string) => { + if (!rawUrl.startsWith(`${scheme}://`)) { + return; + } + for (const listener of listeners) { + listener(rawUrl); + } + const wins = getWindows(); + const win = wins.find((w) => !w.isDestroyed()); + if (win) { + if (win.isMinimized()) { + win.restore(); + } + win.show(); + win.focus(); + } + }; + + // macOS: open-url fires both on first launch (handler delivered before + // app.whenReady() resolves) and on subsequent dispatches while running. + app.on("open-url", (event, url) => { + event.preventDefault(); + dispatch(url); + }); + + // Windows/Linux: a second invocation arrives via single-instance argv. + app.on("second-instance", (_event, argv) => { + const url = argv.find((a) => a.startsWith(`${scheme}://`)); + if (url) { + dispatch(url); + } + }); + + // First launch on Windows/Linux: URL is in process.argv. Defer one tick so + // listeners registered after registerProtocolHandler() still observe it. + if (process.platform !== "darwin") { + const url = process.argv.find((a) => a.startsWith(`${scheme}://`)); + if (url) { + void app.whenReady().then(() => dispatch(url)); + } + } +} diff --git a/apps/desktop/src/main/sentry.ts b/apps/desktop/src/main/sentry.ts index fafaf19b2..c41d3af25 100644 --- a/apps/desktop/src/main/sentry.ts +++ b/apps/desktop/src/main/sentry.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; -import * as Sentry from "@sentry/electron/main"; -import { rewriteFramesIntegration } from "@sentry/electron/main"; +import { + init, + rewriteFramesIntegration, +} from "@vendor/observability/sentry-electron-main"; import { app } from "electron"; import { mainEnv } from "../env/main"; import { getBuildInfo } from "./build-info"; @@ -40,7 +42,7 @@ export function initSentry(): void { return; } const build = getBuildInfo(); - Sentry.init({ + init({ dsn: options.dsn, release: options.release, environment: options.environment, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index af2aa8b6b..a1ba4faf3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -41,6 +41,15 @@ const bridge: LightfastBridge = { ipcRenderer.on(IpcChannels.authChanged, handler); return () => ipcRenderer.off(IpcChannels.authChanged, handler); }, + pendingSigninUrl: () => + ipcRenderer.invoke(IpcChannels.authPendingSigninUrl), + onPendingSigninUrlChanged: (listener) => { + const handler = (_event: IpcRendererEvent, url: string | null) => + listener(url); + ipcRenderer.on(IpcChannels.authPendingSigninUrlChanged, handler); + return () => + ipcRenderer.off(IpcChannels.authPendingSigninUrlChanged, handler); + }, }, buildInfo, platform: process.platform, diff --git a/apps/desktop/src/renderer/src/react/app-shell.tsx b/apps/desktop/src/renderer/src/react/app-shell.tsx index ea98d0a71..b4cac8f49 100644 --- a/apps/desktop/src/renderer/src/react/app-shell.tsx +++ b/apps/desktop/src/renderer/src/react/app-shell.tsx @@ -1,8 +1,13 @@ import { useQueryClient } from "@tanstack/react-query"; +import { captureException } from "@vendor/observability/sentry-browser"; import { useEffect, useState } from "react"; +import { Toaster, toast } from "sonner"; import type { AuthSnapshot } from "../../../shared/ipc"; +import { AccountCard } from "./account-card"; import { SignedOutShell } from "./signed-out-shell"; +let signoutFailureReported = false; + export function AppShell() { const [auth, setAuth] = useState( () => window.lightfastBridge.auth.snapshot @@ -22,7 +27,14 @@ export function AppShell() { } const code = (err as { data?: { code?: string } }).data?.code; if (code === "UNAUTHORIZED") { - void window.lightfastBridge.auth.signOut(); + void window.lightfastBridge.auth.signOut().then((ok) => { + if (!(ok || signoutFailureReported)) { + signoutFailureReported = true; + captureException(new Error("auto-sign-out failed"), { + tags: { scope: "app-shell.auto-sign-out" }, + }); + } + }); } }); return unsub; @@ -30,12 +42,40 @@ export function AppShell() { if (!auth.isSignedIn) { return ( - void window.lightfastBridge.openApp()} - onSignIn={() => void window.lightfastBridge.auth.signIn()} - /> + <> + + void window.lightfastBridge.openApp()} + onSignIn={() => { + void window.lightfastBridge.auth.signIn().then((token) => { + if (token) { + signoutFailureReported = false; + return; + } + toast.error("Sign-in didn't complete — please try again"); + }); + }} + /> + ); } - return null; + return ( +
+ + + +
+ ); } diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 943c18c16..96abf1c98 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -22,6 +22,8 @@ export const IpcChannels = { authSignIn: channel("auth-sign-in"), authSignOut: channel("auth-sign-out"), authChanged: channel("auth-changed"), + authPendingSigninUrl: channel("auth-pending-signin-url"), + authPendingSigninUrlChanged: channel("auth-pending-signin-url-changed"), runtimeConfigSync: channel("runtime-config-sync"), } as const; @@ -100,8 +102,12 @@ export interface LightfastBridge { snapshot: AuthSnapshot; getToken: () => Promise; signIn: () => Promise; - signOut: () => Promise; + signOut: () => Promise; onChanged: (listener: (snapshot: AuthSnapshot) => void) => () => void; + pendingSigninUrl: () => Promise; + onPendingSigninUrlChanged: ( + listener: (url: string | null) => void + ) => () => void; }; buildInfo: BuildInfoSnapshot; getSystemThemeVariant: () => Promise; diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 000000000..208aa459e --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,13 @@ +import sharedConfig from "@repo/vitest-config"; +import { defineConfig, mergeConfig } from "vitest/config"; + +export default mergeConfig( + sharedConfig, + defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.{test,spec}.ts"], + }, + }) +); diff --git a/apps/platform/src/instrumentation.ts b/apps/platform/src/instrumentation.ts index d8f3594fe..9843add76 100644 --- a/apps/platform/src/instrumentation.ts +++ b/apps/platform/src/instrumentation.ts @@ -1,13 +1,13 @@ +import { TRPCError } from "@trpc/server"; +import { getHTTPStatusCodeFromError } from "@trpc/server/http"; +import { NonRetriableError } from "@vendor/inngest"; import { captureConsoleIntegration, captureRequestError, extraErrorDataIntegration, init, spotlightIntegration, -} from "@sentry/nextjs"; -import { TRPCError } from "@trpc/server"; -import { getHTTPStatusCodeFromError } from "@trpc/server/http"; -import { NonRetriableError } from "@vendor/inngest"; +} from "@vendor/observability/sentry-nextjs"; import { env } from "~/env"; diff --git a/apps/www/src/instrumentation.ts b/apps/www/src/instrumentation.ts index fe487e0db..b1e72d178 100644 --- a/apps/www/src/instrumentation.ts +++ b/apps/www/src/instrumentation.ts @@ -4,7 +4,7 @@ import { extraErrorDataIntegration, init, spotlightIntegration, -} from "@sentry/nextjs"; +} from "@vendor/observability/sentry-nextjs"; import { env } from "~/env"; diff --git a/package.json b/package.json index 2d717d598..d0633cc48 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "license": "MIT", "private": true, "engines": { - "node": ">=22.0.0", + "node": ">=22.12.0", "pnpm": "10.32.1" }, "packageManager": "pnpm@10.32.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7564937a3..c420d0977 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,9 +51,15 @@ catalogs: '@radix-ui/react-dropdown-menu': specifier: ^2.1.15 version: 2.1.16 + '@sentry/browser': + specifier: ^10.49.0 + version: 10.49.0 '@sentry/core': specifier: ^10.49.0 version: 10.49.0 + '@sentry/electron': + specifier: ^7.11.0 + version: 7.11.0 '@sentry/nextjs': specifier: ^10.49.0 version: 10.49.0 @@ -84,6 +90,9 @@ catalogs: '@vercel/related-projects': specifier: ^1.1.0 version: 1.1.0 + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1 '@vitest/coverage-v8': specifier: ^4.1.4 version: 4.1.4 @@ -111,6 +120,9 @@ catalogs: geist: specifier: ^1.7.0 version: 1.7.0 + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 hono: specifier: ^4.12.14 version: 4.12.14 @@ -314,7 +326,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) api/platform: dependencies: @@ -414,7 +426,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/app: dependencies: @@ -513,7 +525,7 @@ importers: version: link:../../packages/ui '@sentry/nextjs': specifier: 'catalog:' - version: 10.49.0(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4) + version: 10.49.0(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.3)) '@t3-oss/env-nextjs': specifier: ^0.12.0 version: 0.12.0(typescript@5.9.3)(zod@4.3.6) @@ -558,13 +570,13 @@ importers: version: link:../../vendor/upstash '@vercel/microfrontends': specifier: ^2.3.2 - version: 2.3.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 2.3.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vercel/related-projects': specifier: 'catalog:' version: 1.1.0 '@vercel/toolbar': specifier: ^0.2.2 - version: 0.2.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 0.2.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) ai: specifier: 'catalog:' version: 5.0.52(zod@4.3.6) @@ -582,10 +594,10 @@ importers: version: 1.8.0(react@19.2.5) next: specifier: catalog:next16 - version: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) nuqs: specifier: ^2.8.9 - version: 2.8.9(@tanstack/react-router@1.133.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 2.8.9(@tanstack/react-router@1.133.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(next@16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) postgres: specifier: 'catalog:' version: 3.4.9 @@ -623,6 +635,9 @@ importers: '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.9.1 + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@types/lodash.merge': specifier: ^4.6.9 version: 4.6.9 @@ -635,6 +650,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.4(vitest@4.1.4) @@ -648,7 +666,7 @@ importers: specifier: 'catalog:' version: 11.0.0 happy-dom: - specifier: ^20.9.0 + specifier: 'catalog:' version: 20.9.0(bufferutil@4.1.0) import-in-the-middle: specifier: 'catalog:' @@ -667,7 +685,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/desktop: dependencies: @@ -692,6 +710,9 @@ importers: '@trpc/tanstack-react-query': specifier: 'catalog:' version: 11.16.0(@tanstack/react-query@5.99.1(react@19.2.5))(@trpc/client@11.16.0(@trpc/server@11.16.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.16.0(typescript@5.9.3))(react@19.2.5)(typescript@5.9.3) + '@vendor/observability': + specifier: workspace:* + version: link:../../vendor/observability copy-anything: specifier: ^4.0.5 version: 4.0.5 @@ -725,7 +746,7 @@ importers: devDependencies: '@electron-forge/cli': specifier: ^7.11.1 - version: 7.11.1(encoding@0.1.13) + version: 7.11.1(encoding@0.1.13)(esbuild@0.27.3) '@electron-forge/maker-dmg': specifier: ^7.11.1 version: 7.11.1 @@ -753,15 +774,12 @@ importers: '@electron/fuses': specifier: ^1.8.0 version: 1.8.0 - '@electron/notarize': - specifier: ^3.1.1 - version: 3.1.1 - '@electron/osx-sign': - specifier: ^1.3.3 - version: 1.3.3 '@repo/typescript-config': specifier: workspace:* version: link:../../internal/typescript + '@repo/vitest-config': + specifier: workspace:* + version: link:../../internal/vitest-config '@sentry/cli': specifier: ^2.39.1 version: 2.58.5(encoding@0.1.13) @@ -781,20 +799,26 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@5.4.21(@types/node@24.9.1)(lightningcss@1.32.0)(terser@5.46.1)) + specifier: 'catalog:' + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) dotenv-cli: specifier: 'catalog:' version: 11.0.0 electron: - specifier: ^39.8.5 - version: 39.8.5 + specifier: ^41.3.0 + version: 41.5.0 + happy-dom: + specifier: 'catalog:' + version: 20.9.0(bufferutil@4.1.0) typescript: specifier: 'catalog:' version: 5.9.3 vite: - specifier: ^5.4.11 - version: 5.4.21(@types/node@24.9.1)(lightningcss@1.32.0)(terser@5.46.1) + specifier: ^8.0.10 + version: 8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitest: + specifier: 'catalog:' + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/platform: dependencies: @@ -815,7 +839,7 @@ importers: version: link:../../packages/app-providers '@sentry/nextjs': specifier: 'catalog:' - version: 10.49.0(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4) + version: 10.49.0(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.3)) '@t3-oss/env-nextjs': specifier: ^0.12.0 version: 0.12.0(typescript@5.9.3)(zod@4.3.6) @@ -845,13 +869,13 @@ importers: version: 1.1.0 '@vercel/toolbar': specifier: ^0.2.2 - version: 0.2.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 0.2.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) lodash.merge: specifier: ^4.6.2 version: 4.6.2 next: specifier: catalog:next16 - version: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: specifier: ^19.2.5 version: 19.2.5 @@ -921,7 +945,7 @@ importers: version: 2.1.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) '@sentry/nextjs': specifier: 'catalog:' - version: 10.49.0(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4) + version: 10.49.0(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.3)) '@t3-oss/env-nextjs': specifier: 'catalog:' version: 0.13.11(typescript@5.9.3)(zod@4.3.6) @@ -951,13 +975,13 @@ importers: version: link:../../vendor/seo '@vercel/microfrontends': specifier: ^2.3.2 - version: 2.3.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 2.3.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vercel/related-projects': specifier: 'catalog:' version: 1.1.0 '@vercel/toolbar': specifier: ^0.2.2 - version: 0.2.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 0.2.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) feed: specifier: ^5.2.0 version: 5.2.0 @@ -969,7 +993,7 @@ importers: version: 16.6.10(@mdx-js/mdx@3.1.1)(@mixedbread/sdk@0.46.0)(@tanstack/react-router@1.133.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.8.0(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6) fumadocs-mdx: specifier: 14.2.9 - version: 14.2.9(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.10(@mdx-js/mdx@3.1.1)(@mixedbread/sdk@0.46.0)(@tanstack/react-router@1.133.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.8.0(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 14.2.9(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.10(@mdx-js/mdx@3.1.1)(@mixedbread/sdk@0.46.0)(@tanstack/react-router@1.133.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.8.0(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) fumadocs-openapi: specifier: 10.3.16 version: 10.3.16(1dd075e463b6caba1edbdc74cad6b40c) @@ -996,7 +1020,7 @@ importers: version: 1.8.0(react@19.2.5) next: specifier: catalog:next16 - version: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: specifier: ^19.2.5 version: 19.2.5 @@ -1075,7 +1099,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) core/cli: dependencies: @@ -1133,7 +1157,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.4 - version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) core/mcp: dependencies: @@ -1215,7 +1239,7 @@ importers: dependencies: vitest: specifier: 'catalog:' - version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@25.3.3)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@25.3.3)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@25.3.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages/app-ai: dependencies: @@ -1346,7 +1370,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages/app-octokit-github: dependencies: @@ -1436,7 +1460,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages/app-remotion: dependencies: @@ -1583,7 +1607,7 @@ importers: version: 11.16.0(@tanstack/react-query@5.99.1(react@19.2.5))(@trpc/client@11.16.0(@trpc/server@11.16.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.16.0(typescript@5.9.3))(react@19.2.5)(typescript@5.9.3) next: specifier: catalog:next16 - version: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: specifier: ^19.2.5 version: 19.2.5 @@ -1683,7 +1707,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages/og: dependencies: @@ -1723,7 +1747,7 @@ importers: version: 11.16.0(@tanstack/react-query@5.99.1(react@19.2.5))(@trpc/client@11.16.0(@trpc/server@11.16.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.16.0(typescript@5.9.3))(react@19.2.5)(typescript@5.9.3) next: specifier: catalog:next16 - version: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: specifier: ^19.2.5 version: 19.2.5 @@ -1896,7 +1920,7 @@ importers: version: 5.1.9 next: specifier: catalog:next16 - version: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -2033,7 +2057,7 @@ importers: version: 2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) next: specifier: catalog:next16 - version: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) posthog-js: specifier: ^1.369.3 version: 1.369.3 @@ -2082,7 +2106,7 @@ importers: version: 0.13.11(typescript@5.9.3)(zod@4.3.6) next: specifier: catalog:next16 - version: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: specifier: ^19.2.5 version: 19.2.5 @@ -2251,7 +2275,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vendor/mcp: dependencies: @@ -2295,7 +2319,7 @@ importers: version: link:../../internal/typescript next: specifier: catalog:next16 - version: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) typescript: specifier: 'catalog:' version: 5.9.3 @@ -2308,9 +2332,18 @@ importers: '@orpc/client': specifier: ^1.13.14 version: 1.13.14(@opentelemetry/api@1.9.1) + '@sentry/browser': + specifier: 'catalog:' + version: 10.49.0 '@sentry/core': specifier: 'catalog:' version: 10.49.0 + '@sentry/electron': + specifier: 'catalog:' + version: 7.11.0(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1)) + '@sentry/nextjs': + specifier: 'catalog:' + version: 10.49.0(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4) '@t3-oss/env-nextjs': specifier: 'catalog:' version: 0.13.11(typescript@5.9.3)(zod@4.3.6) @@ -2450,7 +2483,7 @@ importers: version: 19.2.14 next: specifier: catalog:next16 - version: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) typescript: specifier: 'catalog:' version: 5.9.3 @@ -2805,10 +2838,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -2835,18 +2864,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -3267,10 +3284,6 @@ packages: resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} engines: {node: '>= 10.0.0'} - '@electron/notarize@3.1.1': - resolution: {integrity: sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==} - engines: {node: '>= 22.12.0'} - '@electron/osx-sign@1.3.3': resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} engines: {node: '>=12.0.0'} @@ -3295,15 +3308,24 @@ packages: engines: {node: '>=14.14'} hasBin: true + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -3312,12 +3334,6 @@ packages: resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} deprecated: 'Merged into tsx: https://tsx.is' - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.25.0': resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} engines: {node: '>=18'} @@ -3348,12 +3364,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.25.0': resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} engines: {node: '>=18'} @@ -3384,12 +3394,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.25.0': resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} engines: {node: '>=18'} @@ -3420,12 +3424,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.25.0': resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} engines: {node: '>=18'} @@ -3456,12 +3454,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.25.0': resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} engines: {node: '>=18'} @@ -3492,12 +3484,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.25.0': resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} engines: {node: '>=18'} @@ -3528,12 +3514,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.25.0': resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} engines: {node: '>=18'} @@ -3564,12 +3544,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.25.0': resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} engines: {node: '>=18'} @@ -3600,12 +3574,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.25.0': resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} engines: {node: '>=18'} @@ -3636,12 +3604,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.25.0': resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} engines: {node: '>=18'} @@ -3672,12 +3634,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.25.0': resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} engines: {node: '>=18'} @@ -3708,12 +3664,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.25.0': resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} engines: {node: '>=18'} @@ -3744,12 +3694,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.25.0': resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} engines: {node: '>=18'} @@ -3780,12 +3724,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.25.0': resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} engines: {node: '>=18'} @@ -3816,12 +3754,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.25.0': resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} engines: {node: '>=18'} @@ -3852,12 +3784,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.25.0': resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} engines: {node: '>=18'} @@ -3888,12 +3814,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.25.0': resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} engines: {node: '>=18'} @@ -3948,12 +3868,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.25.0': resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} engines: {node: '>=18'} @@ -4008,12 +3922,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.25.0': resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} engines: {node: '>=18'} @@ -4062,12 +3970,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.25.0': resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} engines: {node: '>=18'} @@ -4098,12 +4000,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.25.0': resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} engines: {node: '>=18'} @@ -4134,12 +4030,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.25.0': resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} engines: {node: '>=18'} @@ -4170,12 +4060,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.25.0': resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} engines: {node: '>=18'} @@ -4854,6 +4738,12 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} @@ -6053,6 +5943,9 @@ packages: '@oxc-project/types@0.121.0': resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} cpu: [arm] @@ -7177,30 +7070,60 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-rc.1': resolution: {integrity: sha512-YzJdn08kSOXnj85ghHauH2iHpOJ6eSmstdRTLyaziDcUxe9SyQJgGyx/5jDIhDvtOcNvMm2Ju7m19+S/Rm1jFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.1': resolution: {integrity: sha512-cIvAbqM+ZVV6lBSKSBtlNqH5iCiW933t1q8j0H66B3sjbe8AxIRetVqfGgcHcJtMzBIkIALlL9fcDrElWLJQcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-rc.1': resolution: {integrity: sha512-rVt+B1B/qmKwCl1XD02wKfgh3vQPXRXdB/TicV2w6g7RVAM1+cZcpigwhLarqiVCxDObFZ7UgXCxPC7tpDoRog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.1': resolution: {integrity: sha512-69YKwJJBOFprQa1GktPgbuBOfnn+EGxu8sBJ1TjPER+zhSpYeaU4N07uqmyBiksOLGXsMegymuecLobfz03h8Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.1': resolution: {integrity: sha512-9JDhHUf3WcLfnViFWm+TyorqUtnSAHaCzlSNmMOq824prVuuzDOK91K0Hl8DUcEb9M5x2O+d2/jmBMsetRIn3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7208,6 +7131,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.1': resolution: {integrity: sha512-UvApLEGholmxw/HIwmUnLq3CwdydbhaHHllvWiCTNbyGom7wTwOtz5OAQbAKZYyiEOeIXZNPkM7nA4Dtng7CLw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7215,6 +7145,27 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.1': resolution: {integrity: sha512-uVctNgZHiGnJx5Fij7wHLhgw4uyZBVi6mykeWKOqE7bVy9Hcxn0fM/IuqdMwk6hXlaf9fFShDTFz2+YejP+x0A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7222,6 +7173,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.1': resolution: {integrity: sha512-T6Eg0xWwcxd/MzBcuv4Z37YVbUbJxy5cMNnbIt/Yr99wFwli30O4BPlY8hKeGyn6lWNtU0QioBS46lVzDN38bg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7229,35 +7187,68 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': resolution: {integrity: sha512-PuGZVS2xNJyLADeh2F04b+Cz4NwvpglbtWACgrDOa5YDTEHKwmiTDjoD5eZ9/ptXtcpeFrMqD2H4Zn33KAh1Eg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.1': resolution: {integrity: sha512-2mOxY562ihHlz9lEXuaGEIDCZ1vI+zyFdtsoa3M62xsEunDXQE+DVPO4S4x5MPK9tKulG/aFcA/IH5eVN257Cw==} engines: {node: '>=14.0.0'} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.1': resolution: {integrity: sha512-oQVOP5cfAWZwRD0Q3nGn/cA9FW3KhMMuQ0NIndALAe6obqjLhqYVYDiGGRGrxvnjJsVbpLwR14gIUYnpIcHR1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.1': resolution: {integrity: sha512-Ydsxxx++FNOuov3wCBPaYjZrEvKOOGq3k+BF4BPridhg2pENfitSRD2TEuQ8i33bp5VptuNdC9IzxRKU031z5A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] '@rolldown/pluginutils@1.0.0-rc.1': resolution: {integrity: sha512-UTBjtTxVOhodhzFVp/ayITaTETRHPUPYZPXQe0WU0wOgxghMojXxYjOiPOauKIYNWJAWS2fd7gJgGQK8GU8vDA==} + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + '@rollup/plugin-commonjs@28.0.1': resolution: {integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -8338,10 +8329,29 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + '@testing-library/jest-dom@6.9.1': resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^19.2.14 + '@types/react-dom': ^19.2.3 + react: ^19.2.5 + react-dom: ^19.2.5 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tinyhttp/accepts@1.3.0': resolution: {integrity: sha512-YaJ4EMgVUI6JHzWO14lr6vn/BLJEoFN4Sqd20l0/oBcLLENkP8gnPtX1jB7OhIu0AE40VCweAqvSP+0/pgzB1g==} engines: {node: '>=12.4.0'} @@ -8483,21 +8493,12 @@ packages: '@types/appdmg@0.5.5': resolution: {integrity: sha512-G+n6DgZTZFOteITE30LnWj+HRVIGr7wMlAiLWOO02uJFWVEitaPU9JVXm9wJokkgshBawb2O1OykdcsmkkZfgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/aws-lambda@8.10.156': resolution: {integrity: sha512-LElQP+QliVWykC7OF8dNr04z++HJCMO2lF7k9HuKoSDARqhcjHq8MzbrRwujCSDeBHIlvaimbuY/tVZL36KXFQ==} - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/bunyan@1.8.11': resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} @@ -9016,11 +9017,18 @@ packages: vite: optional: true - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true '@vitest/coverage-v8@4.1.4': resolution: {integrity: sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==} @@ -9253,6 +9261,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -9287,6 +9299,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -10206,6 +10221,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} @@ -10394,8 +10412,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@39.8.5: - resolution: {integrity: sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==} + electron@41.5.0: + resolution: {integrity: sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg==} engines: {node: '>= 12.20.55'} hasBin: true @@ -10533,11 +10551,6 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.25.0: resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} engines: {node: '>=18'} @@ -12136,6 +12149,10 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + macos-alias@0.2.12: resolution: {integrity: sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw==} os: [darwin] @@ -13155,10 +13172,6 @@ packages: resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.11: resolution: {integrity: sha512-5dDj8+lmvA8XB78SmzGI8NlQoksv7IfutGWeVZxiixHbO+p4LDPT3wuG/D9sM/wrjZZ9I+Siy/e117vbFPxSZg==} engines: {node: ^10 || ^12 || >=14} @@ -13213,6 +13226,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@7.0.1: resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} engines: {node: '>=10'} @@ -13354,6 +13371,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -13372,10 +13392,6 @@ packages: react-promise-suspense@0.3.4: resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==} - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -13674,6 +13690,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -14639,62 +14660,34 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true - less: + '@vitejs/devtools': optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - vite@7.1.10: - resolution: {integrity: sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': + esbuild: optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -15144,7 +15137,7 @@ snapshots: '@arcjet/protocol': 1.4.0 '@arcjet/transport': 1.4.0 arcjet: 1.4.0 - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@arcjet/protocol@1.4.0': dependencies: @@ -15681,8 +15674,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -15702,16 +15693,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.4)': - dependencies: - '@babel/core': 7.28.4 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/runtime@7.29.2': {} '@babel/template@7.27.2': @@ -15975,7 +15956,7 @@ snapshots: '@clerk/backend': 3.2.14(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@clerk/react': 6.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@clerk/shared': 4.8.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) server-only: 0.0.1 @@ -16083,9 +16064,9 @@ snapshots: '@electric-sql/pglite@0.3.15': optional: true - '@electron-forge/cli@7.11.1(encoding@0.1.13)': + '@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.27.3)': dependencies: - '@electron-forge/core': 7.11.1(encoding@0.1.13) + '@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.27.3) '@electron-forge/core-utils': 7.11.1 '@electron-forge/shared-types': 7.11.1 '@electron/get': 3.1.0 @@ -16123,7 +16104,7 @@ snapshots: - bluebird - supports-color - '@electron-forge/core@7.11.1(encoding@0.1.13)': + '@electron-forge/core@7.11.1(encoding@0.1.13)(esbuild@0.27.3)': dependencies: '@electron-forge/core-utils': 7.11.1 '@electron-forge/maker-base': 7.11.1 @@ -16134,7 +16115,7 @@ snapshots: '@electron-forge/template-vite': 7.11.1 '@electron-forge/template-vite-typescript': 7.11.1 '@electron-forge/template-webpack': 7.11.1 - '@electron-forge/template-webpack-typescript': 7.11.1 + '@electron-forge/template-webpack-typescript': 7.11.1(esbuild@0.27.3) '@electron-forge/tracer': 7.11.1 '@electron/get': 3.1.0 '@electron/packager': 18.4.4 @@ -16312,13 +16293,13 @@ snapshots: - bluebird - supports-color - '@electron-forge/template-webpack-typescript@7.11.1': + '@electron-forge/template-webpack-typescript@7.11.1(esbuild@0.27.3)': dependencies: '@electron-forge/shared-types': 7.11.1 '@electron-forge/template-base': 7.11.1 fs-extra: 10.1.0 typescript: 5.4.5 - webpack: 5.105.4 + webpack: 5.105.4(esbuild@0.27.3) transitivePeerDependencies: - '@swc/core' - bluebird @@ -16404,13 +16385,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/notarize@3.1.1': - dependencies: - debug: 4.4.3 - promise-retry: 2.0.1 - transitivePeerDependencies: - - supports-color - '@electron/osx-sign@1.3.3': dependencies: compare-version: 0.1.2 @@ -16490,12 +16464,23 @@ snapshots: transitivePeerDependencies: - supports-color + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -16506,6 +16491,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -16516,9 +16506,6 @@ snapshots: '@esbuild-kit/core-utils': 3.3.2 get-tsconfig: 4.13.7 - '@esbuild/aix-ppc64@0.21.5': - optional: true - '@esbuild/aix-ppc64@0.25.0': optional: true @@ -16534,9 +16521,6 @@ snapshots: '@esbuild/android-arm64@0.18.20': optional: true - '@esbuild/android-arm64@0.21.5': - optional: true - '@esbuild/android-arm64@0.25.0': optional: true @@ -16552,9 +16536,6 @@ snapshots: '@esbuild/android-arm@0.18.20': optional: true - '@esbuild/android-arm@0.21.5': - optional: true - '@esbuild/android-arm@0.25.0': optional: true @@ -16570,9 +16551,6 @@ snapshots: '@esbuild/android-x64@0.18.20': optional: true - '@esbuild/android-x64@0.21.5': - optional: true - '@esbuild/android-x64@0.25.0': optional: true @@ -16588,9 +16566,6 @@ snapshots: '@esbuild/darwin-arm64@0.18.20': optional: true - '@esbuild/darwin-arm64@0.21.5': - optional: true - '@esbuild/darwin-arm64@0.25.0': optional: true @@ -16606,9 +16581,6 @@ snapshots: '@esbuild/darwin-x64@0.18.20': optional: true - '@esbuild/darwin-x64@0.21.5': - optional: true - '@esbuild/darwin-x64@0.25.0': optional: true @@ -16624,9 +16596,6 @@ snapshots: '@esbuild/freebsd-arm64@0.18.20': optional: true - '@esbuild/freebsd-arm64@0.21.5': - optional: true - '@esbuild/freebsd-arm64@0.25.0': optional: true @@ -16642,9 +16611,6 @@ snapshots: '@esbuild/freebsd-x64@0.18.20': optional: true - '@esbuild/freebsd-x64@0.21.5': - optional: true - '@esbuild/freebsd-x64@0.25.0': optional: true @@ -16660,9 +16626,6 @@ snapshots: '@esbuild/linux-arm64@0.18.20': optional: true - '@esbuild/linux-arm64@0.21.5': - optional: true - '@esbuild/linux-arm64@0.25.0': optional: true @@ -16678,9 +16641,6 @@ snapshots: '@esbuild/linux-arm@0.18.20': optional: true - '@esbuild/linux-arm@0.21.5': - optional: true - '@esbuild/linux-arm@0.25.0': optional: true @@ -16696,9 +16656,6 @@ snapshots: '@esbuild/linux-ia32@0.18.20': optional: true - '@esbuild/linux-ia32@0.21.5': - optional: true - '@esbuild/linux-ia32@0.25.0': optional: true @@ -16714,9 +16671,6 @@ snapshots: '@esbuild/linux-loong64@0.18.20': optional: true - '@esbuild/linux-loong64@0.21.5': - optional: true - '@esbuild/linux-loong64@0.25.0': optional: true @@ -16732,9 +16686,6 @@ snapshots: '@esbuild/linux-mips64el@0.18.20': optional: true - '@esbuild/linux-mips64el@0.21.5': - optional: true - '@esbuild/linux-mips64el@0.25.0': optional: true @@ -16750,9 +16701,6 @@ snapshots: '@esbuild/linux-ppc64@0.18.20': optional: true - '@esbuild/linux-ppc64@0.21.5': - optional: true - '@esbuild/linux-ppc64@0.25.0': optional: true @@ -16768,9 +16716,6 @@ snapshots: '@esbuild/linux-riscv64@0.18.20': optional: true - '@esbuild/linux-riscv64@0.21.5': - optional: true - '@esbuild/linux-riscv64@0.25.0': optional: true @@ -16786,9 +16731,6 @@ snapshots: '@esbuild/linux-s390x@0.18.20': optional: true - '@esbuild/linux-s390x@0.21.5': - optional: true - '@esbuild/linux-s390x@0.25.0': optional: true @@ -16804,9 +16746,6 @@ snapshots: '@esbuild/linux-x64@0.18.20': optional: true - '@esbuild/linux-x64@0.21.5': - optional: true - '@esbuild/linux-x64@0.25.0': optional: true @@ -16834,9 +16773,6 @@ snapshots: '@esbuild/netbsd-x64@0.18.20': optional: true - '@esbuild/netbsd-x64@0.21.5': - optional: true - '@esbuild/netbsd-x64@0.25.0': optional: true @@ -16864,9 +16800,6 @@ snapshots: '@esbuild/openbsd-x64@0.18.20': optional: true - '@esbuild/openbsd-x64@0.21.5': - optional: true - '@esbuild/openbsd-x64@0.25.0': optional: true @@ -16891,9 +16824,6 @@ snapshots: '@esbuild/sunos-x64@0.18.20': optional: true - '@esbuild/sunos-x64@0.21.5': - optional: true - '@esbuild/sunos-x64@0.25.0': optional: true @@ -16909,9 +16839,6 @@ snapshots: '@esbuild/win32-arm64@0.18.20': optional: true - '@esbuild/win32-arm64@0.21.5': - optional: true - '@esbuild/win32-arm64@0.25.0': optional: true @@ -16927,9 +16854,6 @@ snapshots: '@esbuild/win32-ia32@0.18.20': optional: true - '@esbuild/win32-ia32@0.21.5': - optional: true - '@esbuild/win32-ia32@0.25.0': optional: true @@ -16945,9 +16869,6 @@ snapshots: '@esbuild/win32-x64@0.18.20': optional: true - '@esbuild/win32-x64@0.21.5': - optional: true - '@esbuild/win32-x64@0.25.0': optional: true @@ -17539,7 +17460,7 @@ snapshots: '@logtail/next@0.3.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': dependencies: - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 use-deep-compare: 1.3.0(react@19.2.5) whatwg-fetch: 3.6.20 @@ -17707,6 +17628,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + '@neon-rs/load@0.0.4': optional: true @@ -17769,7 +17697,7 @@ snapshots: '@nosecone/next@1.4.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) nosecone: 1.4.0 '@npmcli/fs@2.1.2': @@ -19237,6 +19165,8 @@ snapshots: '@oxc-project/types@0.121.0': {} + '@oxc-project/types@0.127.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -20358,54 +20288,105 @@ snapshots: '@rescale/nemo@2.1.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) path-to-regexp: 6.3.0 '@rolldown/binding-android-arm64@1.0.0-rc.1': optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.1': optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.1': optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.1': optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.1': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.1': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.1': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.1': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.1': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.1': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.1': optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.1': optional: true - '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true '@rolldown/pluginutils@1.0.0-rc.1': {} + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + '@rollup/plugin-commonjs@28.0.1(rollup@4.59.0)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.59.0) @@ -20788,6 +20769,31 @@ snapshots: - '@opentelemetry/exporter-trace-otlp-http' - supports-color + '@sentry/nextjs@10.49.0(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.3))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + '@rollup/plugin-commonjs': 28.0.1(rollup@4.59.0) + '@sentry-internal/browser-utils': 10.49.0 + '@sentry/bundler-plugin-core': 5.2.0(encoding@0.1.13) + '@sentry/core': 10.49.0 + '@sentry/node': 10.49.0(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) + '@sentry/react': 10.49.0(react@19.2.5) + '@sentry/vercel-edge': 10.49.0 + '@sentry/webpack-plugin': 5.2.0(encoding@0.1.13)(webpack@5.105.4(esbuild@0.27.3)) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + rollup: 4.59.0 + stacktrace-parser: 0.1.11 + transitivePeerDependencies: + - '@opentelemetry/core' + - '@opentelemetry/exporter-trace-otlp-http' + - '@opentelemetry/sdk-trace-base' + - encoding + - react + - supports-color + - webpack + '@sentry/nextjs@10.49.0(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4)': dependencies: '@opentelemetry/api': 1.9.1 @@ -20801,7 +20807,7 @@ snapshots: '@sentry/react': 10.49.0(react@19.2.5) '@sentry/vercel-edge': 10.49.0 '@sentry/webpack-plugin': 5.2.0(encoding@0.1.13)(webpack@5.105.4) - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) rollup: 4.59.0 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -20949,6 +20955,14 @@ snapshots: '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) '@sentry/core': 10.49.0 + '@sentry/webpack-plugin@5.2.0(encoding@0.1.13)(webpack@5.105.4(esbuild@0.27.3))': + dependencies: + '@sentry/bundler-plugin-core': 5.2.0(encoding@0.1.13) + webpack: 5.105.4(esbuild@0.27.3) + transitivePeerDependencies: + - encoding + - supports-color + '@sentry/webpack-plugin@5.2.0(encoding@0.1.13)(webpack@5.105.4)': dependencies: '@sentry/bundler-plugin-core': 5.2.0(encoding@0.1.13) @@ -21498,6 +21512,17 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + '@testing-library/jest-dom@6.9.1': dependencies: '@adobe/css-tools': 4.4.4 @@ -21507,6 +21532,16 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@tinyhttp/accepts@1.3.0': dependencies: es-mime-types: 0.0.16 @@ -21651,28 +21686,9 @@ snapshots: '@types/node': 25.3.3 optional: true - '@types/aws-lambda@8.10.156': {} - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 + '@types/aria-query@5.0.4': {} - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 + '@types/aws-lambda@8.10.156': {} '@types/bunyan@1.8.11': dependencies: @@ -21973,7 +21989,7 @@ snapshots: '@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': optionalDependencies: - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 '@vercel/backends@0.1.1(encoding@0.1.13)(rollup@4.60.2)(typescript@5.9.3)': @@ -22065,7 +22081,7 @@ snapshots: jose: 5.2.1 js-xxhash: 4.0.0 optionalDependencies: - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' @@ -22150,7 +22166,7 @@ snapshots: - rollup - supports-color - '@vercel/microfrontends@2.0.1(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vercel/microfrontends@2.0.1(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@next/env': 15.5.4 '@types/md5': 2.3.6 @@ -22167,14 +22183,14 @@ snapshots: optionalDependencies: '@vercel/analytics': 2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) '@vercel/speed-insights': 2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - vite: 7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - debug - '@vercel/microfrontends@2.3.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vercel/microfrontends@2.3.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@next/env': 16.0.10 '@types/md5': 2.3.6 @@ -22191,10 +22207,10 @@ snapshots: optionalDependencies: '@vercel/analytics': 2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) '@vercel/speed-insights': 2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - vite: 7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - debug @@ -22333,7 +22349,7 @@ snapshots: '@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': optionalDependencies: - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 '@vercel/static-build@2.9.19': @@ -22349,10 +22365,10 @@ snapshots: json-schema-to-ts: 1.6.4 ts-morph: 12.0.0 - '@vercel/toolbar@0.2.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vercel/toolbar@0.2.2(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@tinyhttp/app': 1.3.0 - '@vercel/microfrontends': 2.0.1(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vercel/microfrontends': 2.0.1(@vercel/analytics@2.0.1(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@vercel/speed-insights@2.0.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) chokidar: 3.6.0 execa: 5.1.1 fast-glob: 3.3.3 @@ -22361,9 +22377,9 @@ snapshots: jsonc-parser: 3.3.1 strip-ansi: 6.0.1 optionalDependencies: - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 - vite: 7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@sveltejs/kit' - '@vercel/analytics' @@ -22371,17 +22387,12 @@ snapshots: - debug - react-dom - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.9.1)(lightningcss@1.32.0)(terser@5.46.1))': + '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@babel/core': 7.28.4 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 5.4.21(@types/node@24.9.1)(lightningcss@1.32.0)(terser@5.46.1) - transitivePeerDependencies: - - supports-color + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + optionalDependencies: + babel-plugin-react-compiler: 1.0.0 '@vitest/coverage-v8@4.1.4(vitest@4.1.4)': dependencies: @@ -22395,7 +22406,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.4': dependencies: @@ -22406,21 +22417,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.4(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.4(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@4.1.4(vite@7.1.10(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.4(vite@8.0.10(@types/node@25.3.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.10(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.3.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@4.1.4': dependencies: @@ -22449,7 +22460,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) optional: true '@vitest/utils@4.1.4': @@ -22666,6 +22677,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -22712,6 +22725,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-union@2.1.0: {} @@ -23613,6 +23630,8 @@ snapshots: dependencies: path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} + dom-accessibility-api@0.6.3: {} dom-helpers@5.2.1: @@ -23758,10 +23777,10 @@ snapshots: - supports-color optional: true - electron@39.8.5: + electron@41.5.0: dependencies: '@electron/get': 2.0.3 - '@types/node': 22.19.17 + '@types/node': 24.9.1 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -23908,32 +23927,6 @@ snapshots: '@esbuild/win32-ia32': 0.18.20 '@esbuild/win32-x64': 0.18.20 - esbuild@0.21.5: - optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 - esbuild@0.25.0: optionalDependencies: '@esbuild/aix-ppc64': 0.25.0 @@ -24393,7 +24386,7 @@ snapshots: jose: 5.10.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) @@ -24566,14 +24559,14 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.14 lucide-react: 1.8.0(react@19.2.5) - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) zod: 4.3.6 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.2.9(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.10(@mdx-js/mdx@3.1.1)(@mixedbread/sdk@0.46.0)(@tanstack/react-router@1.133.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.8.0(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + fumadocs-mdx@14.2.9(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.6.10(@mdx-js/mdx@3.1.1)(@mixedbread/sdk@0.46.0)(@tanstack/react-router@1.133.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.8.0(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 @@ -24597,9 +24590,9 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.13 '@types/react': 19.2.14 - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: 19.2.5 - vite: 7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -24665,7 +24658,7 @@ snapshots: unist-util-visit: 5.1.0 optionalDependencies: '@types/react': 19.2.14 - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@types/react-dom' @@ -24699,7 +24692,7 @@ snapshots: geist@1.7.0(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)): dependencies: - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) generate-function@2.3.1: dependencies: @@ -25302,7 +25295,7 @@ snapshots: express: 5.2.1 h3: 1.15.4 hono: 4.12.14 - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) typescript: 5.9.3 transitivePeerDependencies: - '@opentelemetry/core' @@ -25816,6 +25809,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + macos-alias@0.2.12: dependencies: nan: 2.26.2 @@ -26563,7 +26558,7 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@next/env': 16.2.4 '@swc/helpers': 0.5.15 @@ -26572,7 +26567,7 @@ snapshots: postcss: 8.4.31 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - styled-jsx: 5.1.6(react@19.2.5) + styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.5) optionalDependencies: '@next/swc-darwin-arm64': 16.2.4 '@next/swc-darwin-x64': 16.2.4 @@ -26664,13 +26659,13 @@ snapshots: npm-to-yarn@3.0.1: {} - nuqs@2.8.9(@tanstack/react-router@1.133.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(next@16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5): + nuqs@2.8.9(@tanstack/react-router@1.133.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(next@16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.5 optionalDependencies: '@tanstack/react-router': 1.133.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - next: 16.2.4(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + next: 16.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) nypm@0.6.5: dependencies: @@ -27142,12 +27137,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.10: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.11: dependencies: nanoid: 3.3.11 @@ -27198,6 +27187,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@7.0.1: dependencies: parse-ms: 2.1.0 @@ -27349,6 +27344,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): @@ -27378,8 +27375,6 @@ snapshots: dependencies: fast-deep-equal: 2.0.1 - react-refresh@0.17.0: {} - react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): @@ -27776,6 +27771,27 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.1 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.1 + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -27837,6 +27853,7 @@ snapshots: '@rollup/rollup-win32-x64-gnu': 4.60.2 '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 + optional: true rou3@0.7.12: {} @@ -28343,10 +28360,12 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.1.6(react@19.2.5): + styled-jsx@5.1.6(@babel/core@7.28.4)(react@19.2.5): dependencies: client-only: 0.0.1 react: 19.2.5 + optionalDependencies: + '@babel/core': 7.28.4 stylis@4.3.6: {} @@ -28446,6 +28465,16 @@ snapshots: optionalDependencies: esbuild: 0.25.0 + terser-webpack-plugin@5.4.0(esbuild@0.27.3)(webpack@5.105.4(esbuild@0.27.3)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.46.1 + webpack: 5.105.4(esbuild@0.27.3) + optionalDependencies: + esbuild: 0.27.3 + terser-webpack-plugin@5.4.0(webpack@5.105.4): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -28908,55 +28937,42 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@5.4.21(@types/node@24.9.1)(lightningcss@1.32.0)(terser@5.46.1): + vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: - esbuild: 0.21.5 - postcss: 8.5.10 - rollup: 4.59.0 - optionalDependencies: - '@types/node': 24.9.1 - fsevents: 2.3.3 lightningcss: 1.32.0 - terser: 5.46.1 - - vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.11 - rollup: 4.60.2 + rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 24.9.1 + esbuild: 0.27.3 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.32.0 terser: 5.46.1 tsx: 4.21.0 yaml: 2.8.3 - vite@7.1.10(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vite@8.0.10(@types/node@25.3.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) + lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.11 - rollup: 4.60.2 + rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.3.3 + esbuild: 0.27.3 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.32.0 terser: 5.46.1 tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@24.9.1)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.4(vite@8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -28973,7 +28989,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.1.10(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@24.9.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@edge-runtime/vm': 3.2.0 @@ -28986,10 +29002,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@25.3.3)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@7.1.10(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.4(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@25.3.3)(@vitest/coverage-v8@4.1.4)(@vitest/ui@4.1.4)(happy-dom@20.9.0(bufferutil@4.1.0))(jsdom@27.0.1(bufferutil@4.1.0))(vite@8.0.10(@types/node@25.3.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.1.10(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 4.1.4(vite@8.0.10(@types/node@25.3.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -29006,7 +29022,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.1.10(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite: 8.0.10(@types/node@25.3.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@edge-runtime/vm': 3.2.0 @@ -29152,6 +29168,38 @@ snapshots: - esbuild - uglify-js + webpack@5.105.4(esbuild@0.27.3): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.20.1 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.2 + terser-webpack-plugin: 5.4.0(esbuild@0.27.3)(webpack@5.105.4(esbuild@0.27.3)) + watchpack: 2.5.1 + webpack-sources: 3.3.4 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index de67e0ecb..daab91741 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,7 +23,9 @@ catalog: '@noble/ed25519': ^3.1.0 '@noble/hashes': ^2.2.0 '@radix-ui/react-dropdown-menu': ^2.1.15 + '@sentry/browser': ^10.49.0 '@sentry/core': ^10.49.0 + '@sentry/electron': ^7.11.0 '@sentry/nextjs': ^10.49.0 '@t3-oss/env-core': ^0.13.11 '@t3-oss/env-nextjs': ^0.13.11 @@ -34,6 +36,7 @@ catalog: '@types/node': ^24.9.1 '@upstash/redis': ^1.37.0 '@vercel/related-projects': ^1.1.0 + '@vitejs/plugin-react': ^6.0.1 '@vitest/coverage-v8': ^4.1.4 '@vitest/expect': ^4.1.4 ai: 5.0.52 @@ -43,6 +46,7 @@ catalog: drizzle-orm: ^0.45.2 drizzle-zod: ^0.8.3 geist: ^1.7.0 + happy-dom: ^20.9.0 hono: ^4.12.14 import-in-the-middle: ^3.0.1 inngest: ^3.54.2 diff --git a/thoughts/shared/plans/2026-04-24-coderabbit-pr614-fixes.md b/thoughts/shared/plans/2026-04-24-coderabbit-pr614-fixes.md new file mode 100644 index 000000000..948b6dbb5 --- /dev/null +++ b/thoughts/shared/plans/2026-04-24-coderabbit-pr614-fixes.md @@ -0,0 +1,969 @@ +# CodeRabbit PR #614 Follow-Up Fixes — Desktop Clerk Loopback + +## Overview + +Resolve the 5 remaining actionable CodeRabbit findings on `feat/desktop-clerk-loopback` (PR #614) before the desktop client ships to production: one critical sign-out correctness bug, two major robustness/security gaps, one UX state-machine warning, and one missing auth-boundary test. Four of the nine original findings were already fixed by late commits on this branch and are explicitly out of scope here. + +## Current State Analysis + +- PR #614 added the desktop OS-browser loopback sign-in flow and the tRPC `Authorization: Bearer` path. +- CodeRabbit's review left 9 inline comments on the PR. Cross-checking each against the current HEAD of `feat/desktop-clerk-loopback`: + - **Already fixed** (verified `pnpm biome check` is clean on all 6 flagged files): trpc.ts early-return braces; `client-auth-bridge.tsx` import + interface ordering; `cli-auth-client.tsx` JSX attr ordering + block statements + numeric separator; `desktop-auth-client.tsx` block statements. + - **Still applicable**: findings #9 (critical), #7 / #8 (major), #4 (warning), #1 (minor) — enumerated below. +- The existing vitest suite at `api/app/src/__tests__/resolve-clerk-session.test.ts` covers 5 cases; all pass. Desktop main-process code has no vitest config, so correctness fixes there rely on manual verification + runtime invariants. + +### Key Discoveries + +- `apps/desktop/src/main/auth-store.ts:67-74` — `clearPersisted()` nulls memory **before** `rmSync`. If `rmSync` throws (permission error / locked file), next `load()` call restores the stale token from the still-present `auth.bin`. Sign-out silently reverts. +- `apps/desktop/src/main/auth-store.ts:50-65, 97-100` — `persist()` swallows encryption/write errors. `setToken()` is `void` and `beginSignIn()` in `auth-flow.ts:95-97` treats any non-null callback token as successful sign-in regardless of whether the token actually reached disk. +- `apps/desktop/src/main/auth-flow.ts:88-99` — `settle(token)` currently calls `setToken(token)` *internally* before resolving. That coupling is what makes the sign-in path ignore persistence failures; any Phase 1 fix must decouple them (settle owns resolution, caller owns persistence). +- `apps/desktop/src/main/auth-flow.ts:103-123` — loopback server reads JWT from `url.searchParams.get("token")`. The web bridge (`apps/app/.../desktop-auth-client.tsx`) reaches the loopback via `window.location.href = url`, so the full JWT lands in the default browser's history, referrer chain, and any URL-logging extensions. +- `apps/app/src/.../_components/client-auth-bridge.tsx:25-49` — the `useEffect` returns early whenever `!(isLoaded && isSignedIn)` is true. Once Clerk finishes loading as signed-out (expired session, sign-out race), the bridge is pinned in the "loading" state indefinitely. The dep array also uses the `props` object, which changes identity on every render. +- `api/app/src/__tests__/resolve-clerk-session.test.ts` — covers Bearer-valid, Bearer-invalid-with-cookie-fallback, cookie-only, and no-auth cases, but **not** the desktop-common unhappy path: expired/invalid Bearer with no cookie session. +- No CORS on the loopback HTTP server today; Phase 2's POST exchange will introduce it narrowly (origin-pinned to the Lightfast API origin). + +## Desired End State + +- Desktop sign-out is atomic: if `rmSync(auth.bin)` fails, the in-memory token is not cleared and listeners are not notified of a false sign-out. +- Desktop sign-in surfaces storage failures: if `safeStorage.encryptString` or `writeFileSync` fails, `beginSignIn()` resolves as a sign-in failure (not success with an unpersisted token). +- Desktop JWT never appears in the browser URL bar, history, or referrer. The loopback callback is a `POST http://127.0.0.1:/callback` with the JWT in the request body. +- `ClientAuthBridge` deterministically resolves to `"error"` once Clerk is loaded and reports signed-out, instead of staying in `"loading"`. +- `resolveClerkSession` test suite covers the expired-Bearer-without-cookie case. +- All of this is verifiable via: existing `pnpm --filter @api/app vitest run`, `pnpm biome check` on the touched files, and the PR's manual test plan (sign in → token appears in `~/Library/Application Support/Lightfast/auth.bin`; sign out → file deleted; relaunch → remains signed in / signed out respectively). + +## What We're NOT Doing + +- **CLI auth flow (`cli-auth-client.tsx`)** — has the same JWT-in-URL pattern as desktop. Out of scope here; CodeRabbit didn't flag it on this PR and the CLI ship is behind desktop. Tracked as a follow-up: "port CLI to POST loopback after desktop lands in prod." +- **PKCE code-exchange flow** — considered for #7, rejected as overkill for loopback. POST body transport kills the leak with no new server endpoint. +- **Introducing vitest to `apps/desktop`** — correctness of auth-store changes will be verified manually against the actual Electron binary. Setting up vitest for the main process is a separate workstream. +- **Changing the JWT template / refresh behavior** — session lifetime stays at 24h as documented in the PR body. +- **Any changes to `api/platform`** — all tRPC + auth work in this plan is scoped to `api/app`. +- **Cookie-based web auth paths** — untouched; the bridge changes only affect the desktop / CLI handoff route. + +## Implementation Approach + +Four phases ordered by blast radius (most critical correctness first, then security, then UX, then coverage). Each phase is independently committable and leaves the branch in a shippable state. Phase 2 is the only phase that touches both desktop main process and web bridge code — everything else is scoped to one surface. + +--- + +## Phase 1: auth-store correctness — sign-out atomicity + persist-failure propagation + +### Overview + +Fix the critical sign-out bug (#9) and the silent-persist-failure bug (#8) together because both live in the same file and #8's caller-side change (`beginSignIn`) is trivial. + +### Changes Required + +#### 1. `apps/desktop/src/main/auth-store.ts` + +**Change `persist()` and `setToken()` to return a boolean success signal; reorder `clearPersisted()` to delete on disk before clearing memory.** + +```ts +function persist(token: string): boolean { + if (!safeStorage.isEncryptionAvailable()) { + console.error( + "[auth-store] safeStorage unavailable; refusing to write plaintext" + ); + return false; + } + try { + const payload: Persisted = { token, savedAt: Date.now() }; + const buf = safeStorage.encryptString(JSON.stringify(payload)); + writeFileSync(storePath(), buf); + memory = token; + return true; + } catch (err) { + console.error("[auth-store] failed to persist", err); + Sentry.captureException(err, { tags: { scope: "auth-store.persist" } }); + return false; + } +} + +function clearPersisted(): boolean { + try { + rmSync(storePath(), { force: true }); + memory = null; + return true; + } catch (err) { + console.error("[auth-store] failed to remove", err); + Sentry.captureException(err, { tags: { scope: "auth-store.clear" } }); + return false; + } +} + +// load() auto-purges malformed / undecryptable files so a rotated macOS +// keychain or a corrupted auth.bin doesn't leave the user unable to sign in. +function load(): string | null { + if (memory) return memory; + const path = storePath(); + if (!existsSync(path)) return null; + if (!safeStorage.isEncryptionAvailable()) return null; + try { + const buf = readFileSync(path); + const plain = safeStorage.decryptString(buf); + const parsed = persistedSchema.safeParse(JSON.parse(plain)); + if (!parsed.success) { + console.error("[auth-store] invalid persisted payload", parsed.error); + Sentry.captureException(parsed.error, { + tags: { scope: "auth-store.load.schema" }, + }); + rmSync(path, { force: true }); + return null; + } + memory = parsed.data.token; + return memory; + } catch (err) { + console.error("[auth-store] failed to load; purging", err); + Sentry.captureException(err, { tags: { scope: "auth-store.load" } }); + rmSync(path, { force: true }); + return null; + } +} + +export function setToken(token: string): boolean { + const ok = persist(token); + if (ok) { + emit(); + } + return ok; +} + +export function signOut(): boolean { + const ok = clearPersisted(); + if (ok) { + emit(); + } + return ok; +} +``` + +Auto-purge is safe: `isEncryptionAvailable()` is checked first, so we never purge during a transient keychain outage. A decrypt-throw, JSON-parse-throw, or schema mismatch all imply the file is cryptographically unreadable with the current key — the only user-respecting move is to remove it and make the user re-authenticate. + +Notes: +- `rmSync` with `{ force: true }` treats a missing file as success (ENOENT is suppressed), so sign-out when `auth.bin` never existed still returns `true`. Only genuine IO failures (EPERM, EBUSY) propagate. +- Only emit on success in both paths — a failed persist should NOT flip listeners to `isSignedIn: true` when memory is stale, and a failed clear should NOT flip them to `isSignedIn: false` when the token is still on disk. +- `signOut()`'s return type changes from `void` to `boolean`. Consumers are the IPC handler at `index.ts:218` and the two renderer call sites at `app-shell.tsx:26, 47`; both are updated below. + +#### 2. `apps/desktop/src/main/auth-flow.ts` + +**Decouple `settle()` from persistence, then let the request handler treat `setToken(...) === false` as a sign-in failure.** + +Today `settle(token)` calls `setToken(token)` *internally* before resolving the outer `beginSignIn` promise (auth-flow.ts:88-99). That coupling is the bug — the HTTP response is chosen before persistence runs. Split them: + +```ts +// settle now only tears down the server and resolves the outer promise. +// NOTE: the existing code uses a local `timer` (const timer = setTimeout(...) +// at the current auth-flow.ts:101). Keep that name — renaming to +// `timeoutHandle` will produce an undefined identifier. +function settle(token: string | null): void { + if (settled) return; + settled = true; + clearTimeout(timer); + server.close(); + resolve(token); +} + +// request handler (GET /callback), replacing the current lines 103-123: +server.on("request", (req, res) => { + const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}:${port}`); + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + const token = url.searchParams.get("token"); + const returned = url.searchParams.get("state"); + const valid = Boolean(token) && returned === state; + if (!valid || !token) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(responsePage("Sign-in failed")); + settle(null); + return; + } + const persisted = setToken(token); + res.writeHead(persisted ? 200 : 500, { "Content-Type": "text/html" }); + res.end(responsePage(persisted ? "Signed in to Lightfast" : "Sign-in failed")); + settle(persisted ? token : null); +}); +``` + +Note: In Phase 2 the response-page rendering moves to the web bridge and the handler becomes POST-only, but the "settle only resolves; caller persists" contract from this phase stays in place. + +#### 3. `apps/desktop/src/main/index.ts` + `apps/desktop/src/preload/preload.ts` — propagate the boolean through IPC + +The IPC handler at `apps/desktop/src/main/index.ts:217-219` (`ipcMain.handle(IpcChannels.authSignOut, () => { signOutAuth(); })`) currently discards the return value. Change to `return signOutAuth();` so the renderer sees the result. + +Type-surface bump — **two** surfaces need to move from `Promise` to `Promise` or the renderer call sites will silently resolve as `void`: + +- `apps/desktop/src/shared/ipc.ts:106` — the `LightfastBridge` interface declares `signOut: () => Promise`; change to `Promise`. The ambient `Window.lightfastBridge` declaration at `apps/desktop/src/renderer/src/main.ts:16-21` references this interface and picks up the change automatically — no separate `.d.ts` edit. +- `apps/desktop/src/preload/preload.ts:36` — the `contextBridge.exposeInMainWorld` side. The body `ipcRenderer.invoke(IpcChannels.authSignOut)` is unchanged; only the inferred return type widens once the interface updates. (There is **no** typed `IpcInvokeMap` in this repo — `IpcChannels` at `shared/ipc.ts:5-26` is a plain string-const object, so there's nothing to update there.) + +TypeScript will **not** produce a compile error if only the main-process side updates — the renderer just keeps the old `void` inference. Grep for `auth.signOut` usages before shipping to confirm all call sites see `boolean`. + +#### 4. `apps/desktop/src/renderer/src/react/app-shell.tsx` — renderer surfaces failure + +Two call sites exist today; they get different treatment because their contexts differ: + +- **`app-shell.tsx:47`** (user-clicked "Sign out" button) — await the result; on `false`, `toast.error("Sign out failed — please try again")` and leave the shell mounted. The token is still on disk, so the user is still effectively signed in; unmounting to the auth-gate would momentarily flash it and then revert on next launch — confusing and wrong. + + ```tsx + + ``` + +- **`app-shell.tsx:26`** (inside `queryClient.getQueryCache().subscribe`, auto-sign-out on tRPC `UNAUTHORIZED`) — do **not** toast here. `UNAUTHORIZED` re-fires on every subsequent query; a toast would cascade into the user's face on a tight loop if sign-out is failing. Capture to Sentry **once per session** (not per event — see latch below) and let the user notice the continued signed-in state on their own: + + ```ts + // module scope, outside the effect: + let signoutFailureReported = false; + + // inside the UNAUTHORIZED branch of the queryCache subscriber: + void window.lightfastBridge.auth.signOut().then((ok) => { + if (!ok && !signoutFailureReported) { + signoutFailureReported = true; + Sentry.captureException(new Error("auto-sign-out failed"), { + tags: { scope: "app-shell.auto-sign-out" }, + }); + } + }); + ``` + + Without the latch, every subsequent UNAUTHORIZED query re-runs sign-out, re-fails, and re-reports — Sentry rate-limits the SDK calls but the project's ingest budget still gets drained during an outage. A module-scope boolean is the cheapest suppressor. Reset to `false` on a successful sign-in (add `signoutFailureReported = false` to the `onSignIn` success handler at `app-shell.tsx:38` once the Phase 2 `.then(token => ...)` refactor is in place). + + Import `Sentry` from `@sentry/browser` — the renderer uses `@sentry/browser` already (verified at `apps/desktop/src/renderer/src/main.ts:1`), not `@sentry/electron/renderer`. The `.then((ok) => ...)` form avoids `await` inside the subscriber callback, which is sync. + +**Toaster mount**: `sonner` is a dependency (`apps/desktop/package.json:58`) but no `` is mounted in the renderer today. As part of this change, mount `` once at the root of the renderer React tree (`apps/desktop/src/renderer/src/react/app-shell.tsx` or the nearest parent that wraps both signed-in and signed-out shells). + +**Import directly from `sonner`, not `@repo/ui`.** `apps/desktop/package.json` does **not** list `@repo/ui` as a dependency (verified), so the `(app)/layout.tsx:3` pattern (`import { Toaster } from "@repo/ui/components/ui/sonner"`) won't resolve. Use: + +```tsx +import { Toaster, toast } from "sonner"; +``` + +Both the Toaster mount and the `toast.error(...)` calls above use this import. Without this, the build fails at module resolution. + +### Success Criteria + +#### Automated Verification: + +- [x] `pnpm --filter @lightfast/desktop typecheck` passes. +- [x] `pnpm biome check apps/desktop/src/main/auth-store.ts apps/desktop/src/main/auth-flow.ts apps/desktop/src/main/bootstrap.ts` is clean. +- [x] `pnpm --filter @api/app vitest run` passes (no regressions — these are desktop-only changes, but api/app depends on nothing here). + +#### Manual Verification: + +- [ ] Sign in → `~/Library/Application\ Support/Lightfast/auth.bin` exists and is non-zero bytes. +- [ ] Sign in → quit desktop (Cmd+Q) → relaunch → **remains signed in**; no auth-gate flash; `AccountCard` renders immediately. Guards against a `load()` regression from the new auto-purge branches (e.g., a stricter schema accidentally purging valid payloads). +- [ ] Sign out → `auth.bin` is removed; `AccountCard` unmounts; relaunch stays on auth-gate. +- [ ] Simulate a write failure: `chmod 400` the `Lightfast` userData directory, click Sign In, complete browser flow. Expect the loopback tab to show "Sign-in failed" and the desktop to remain on the auth-gate — **not** flash signed-in and then flip back. +- [ ] Restore permissions: `chmod 755 ~/Library/Application\ Support/Lightfast` before retrying. +- [ ] Simulate a sign-out failure (user-click path): after a successful sign-in, `chmod 400 ~/Library/Application\ Support/Lightfast/auth.bin`, click the Sign Out button at `app-shell.tsx:47`. Expect a toast ("Sign out failed — please try again"); the shell stays mounted; the token stays on disk. Restore permissions before retrying. +- [ ] Simulate a sign-out failure (UNAUTHORIZED auto path): after a successful sign-in, `chmod 400` the `auth.bin`, then force the tRPC UNAUTHORIZED path (e.g. revoke the session server-side or manually trigger `queryClient.getQueryCache()` with an UNAUTHORIZED error). Expect **no toast**; `Sentry.captureException` fires with `scope: "app-shell.auto-sign-out"`; the shell stays mounted; no toast cascade even if UNAUTHORIZED re-fires on the next query. Restore permissions before retrying. +- [ ] Corrupt auth.bin handling: after a successful sign-in, overwrite the file with random bytes (`head -c 128 /dev/urandom > ~/Library/Application\ Support/Lightfast/auth.bin`), relaunch desktop. Expect auth-gate (not a crash); file should be auto-removed; Sentry should show `auth-store.load` event. +- [ ] Sentry breadcrumb sanity: in the dev Sentry project, confirm that a simulated persist failure surfaces a `auth-store.persist` event within 60s. (Only needed if Sentry DSN is wired in dev; otherwise note as prod-only.) + +**Sentry import**: add `import * as Sentry from "@sentry/electron/main";` at the top of `auth-store.ts`. Match the import style already used in `apps/desktop/src/main/sentry.ts`. + +**Implementation Note**: After Phase 1 passes automated + manual verification, pause for human confirmation before moving to Phase 2. Phase 2 depends on Phase 1's error-propagation contract. + +--- + +## Phase 2: POST-to-loopback — keep the JWT out of the browser URL + +### Overview + +Replace the current GET `?token=…` redirect handoff with a POST that carries the JWT in the request body. The loopback server validates the `Origin` header, parses `{ token, state }` from JSON, and settles. The web bridge renders its own "Signed in — close this tab" UI after the POST resolves. + +### Changes Required + +#### 1. `apps/desktop/src/main/auth-flow.ts` — POST-only loopback + +**Replace the single `request` handler with method-aware branching plus CORS preflight.** + +```ts +// Reuse the existing getApiOrigin() helper at auth-flow.ts:10-17 — don't +// duplicate. index.ts:45-52 has the same helper (getApiOriginForCsp); a +// future cleanup can consolidate into apps/desktop/src/shared/ but that's +// out of scope for this plan. +const ALLOWED_ORIGIN = getApiOrigin(); + +function applyCors(res: ServerResponse): void { + res.setHeader("Access-Control-Allow-Origin", ALLOWED_ORIGIN); + res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "content-type"); + res.setHeader("Access-Control-Max-Age", "600"); + res.setHeader("Vary", "Origin"); + // Chrome Private Network Access: public origins (https://lightfast.ai) + // fetching loopback targets require this explicit opt-in or the browser + // blocks the request. Dev (http://localhost:3024 → 127.0.0.1) is + // loopback→loopback and unaffected; prod is public→loopback and would + // silently break without this header. + res.setHeader("Access-Control-Allow-Private-Network", "true"); +} + +async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + const MAX = 16 * 1024; // JWT payloads are well under 16KB; reject anything larger + for await (const chunk of req) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buf.length; + if (total > MAX) { + // Tear down the socket so a malicious client can't keep streaming + // bytes we've already committed to rejecting. Without this, the TCP + // connection remains open until the client closes it — a trivial + // local-port-hold vector. + req.destroy(); + throw new Error("payload too large"); + } + chunks.push(buf); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; +} + +server.on("request", async (req, res) => { + try { + const origin = req.headers.origin ?? ""; + // Reject empty Origin too — legitimate browser requests always set it on + // cross-origin fetches, and local non-browser clients have no business + // hitting this loopback (the desktop is the only intended client, and it + // doesn't go through this path). + // + // Note: the 403 response intentionally omits CORS headers. The browser + // reports this as an opaque CORS failure rather than leaking which + // origins are allowed — deliberate defense-in-depth, not a bug. If a + // future debugger sees "CORS error" in DevTools for a malicious origin, + // that's the expected behavior. + if (origin !== ALLOWED_ORIGIN) { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("Forbidden origin"); + return; + } + const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}:${port}`); + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + if (req.method === "OPTIONS") { + applyCors(res); + res.writeHead(204); + res.end(); + return; + } + if (req.method !== "POST") { + res.writeHead(405, { "Content-Type": "text/plain", Allow: "POST" }); + res.end("Method Not Allowed"); + return; + } + + applyCors(res); + const body = await readJsonBody(req); + const parsed = callbackBodySchema.safeParse(body); + if (!parsed.success) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, reason: "bad_request" })); + settle(null); + return; + } + const { token, state: returned } = parsed.data; + if (returned !== state) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, reason: "state_mismatch" })); + settle(null); + return; + } + const persisted = setToken(token); + res.writeHead(persisted ? 204 : 500, { + "Content-Type": "application/json", + }); + res.end(persisted ? "" : JSON.stringify({ ok: false, reason: "persist_failed" })); + settle(persisted ? token : null); + } catch (error) { + console.error("[auth-flow] loopback handler error", error); + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Internal Server Error"); + settle(null); + } +}); +``` + +And add the schema + import near the top of the file. **Consolidate the `node:http` types into the existing import** — the current file already has `import { createServer, type Server } from "node:http";`, so merge rather than adding a second line (Biome's `lint/style/useImportType` / `noUselessFragments` rules will flag split imports): + +```ts +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { z } from "zod"; + +const callbackBodySchema = z.object({ + token: z.string().min(1), + state: z.string().min(1), +}); +``` + +Delete the `responsePage()` helper and its caller-site HTML responses — the browser tab UI now lives in the web bridge. + +#### 2. `apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.tsx` + +**Refactor `ClientAuthBridgeProps` into a discriminated union on `mode` so POST and redirect modes are type-level exclusive.** Two-optional-callbacks with a runtime "exactly one" check is an impossible-state API; the union makes the compiler enforce it. + +```ts +interface ClientAuthBridgeBaseProps { + fallback?: ReactNode; + jwtTemplate?: string; + subtitle: string; + title: string; +} + +interface PostCallbackProps { + mode: "post"; + buildPostCallback: (args: { + searchParams: URLSearchParams; + }) => { url: string; state: string } | null; +} + +interface RedirectProps { + mode: "redirect"; + buildRedirectUrl: (args: { + token: string; + searchParams: URLSearchParams; + }) => string | null; +} + +export type ClientAuthBridgeProps = ClientAuthBridgeBaseProps & + (PostCallbackProps | RedirectProps); +``` + +Inside the effect, branch on `props.mode`. Add **Sentry observation on web-side error paths** — without it, a prod-only CORS / preflight regression (e.g. the ALLOWED_ORIGIN env falls through to `localhost:3024` because `NODE_ENV` isn't `"production"` in a packaged build) shows as a silent "Authentication Failed" to the user with zero Sentry signal. The desktop only sees requests that reach its handler; a browser-rejected preflight never does: + +```ts +// Phase 3 also touches this effect — merge the one-shot-latch fix below. +if (props.mode === "post") { + const built = props.buildPostCallback({ searchParams }); + if (!built) { + Sentry.captureMessage("auth-bridge: buildPostCallback returned null", { + level: "warning", + tags: { scope: "auth-bridge.invalid_callback" }, + }); + setStatus("error"); + return; + } + let response: Response; + try { + response = await fetch(built.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, state: built.state }), + // No credentials — loopback doesn't accept cookies. + credentials: "omit", + }); + } catch (error) { + // TypeError from fetch — browser blocked the request (CORS / PNA preflight) + // or the loopback port is gone. The user sees "Authentication Failed" with + // no desktop-side signal since the request never reached the handler. + Sentry.captureException(error, { + tags: { scope: "auth-bridge.fetch_network_error" }, + }); + setStatus("error"); + return; + } + if (!response.ok) { + Sentry.captureMessage("auth-bridge: loopback POST non-ok", { + level: "warning", + tags: { + scope: "auth-bridge.fetch_non_ok", + status: String(response.status), + }, + }); + setStatus("error"); + return; + } + setStatus("success"); + return; +} +// props.mode === "redirect" — existing path, unchanged for CLI. +``` + +**Import style: use named imports from `@sentry/nextjs`, not `import * as Sentry`.** Existing apps/app call sites (`session-activator.tsx:4`, `oauth-button.tsx:6`) use `import { addBreadcrumb, startSpan } from "@sentry/nextjs"`. There is **no** existing `import * as Sentry` in `apps/app/src/**` — matching the named-import convention keeps the bundle tree-shakeable and tooling consistent. Rewrite the bridge snippet above accordingly: + +```ts +import { captureException, captureMessage } from "@sentry/nextjs"; +// ... +captureMessage("auth-bridge: buildPostCallback returned null", { /* ... */ }); +captureException(error, { tags: { scope: "auth-bridge.fetch_network_error" } }); +``` + +Desktop main (`@sentry/electron/main`) and desktop renderer (`@sentry/browser`) both already use `import * as Sentry`; keep those namespace-style to match `sentry.ts:2` and `renderer/src/main.ts:1` respectively. The inconsistency across packages is real but pre-existing — don't try to unify here. + +Extend the state union from `"loading" | "redirecting" | "error"` to `"loading" | "redirecting" | "success" | "error"` and render a "Signed in — you can close this tab" panel for `"success"`. This replaces the HTML page the loopback used to serve. The success panel may also attempt `window.close()` for better UX — note this only works if the tab was opened by JavaScript, and our tab was opened via Electron's `shell.openExternal`, so the call is a best-effort no-op in most browsers. Don't rely on it. + +#### 3. `apps/app/src/app/(app)/(user)/(pending-not-allowed)/desktop/auth/_components/desktop-auth-client.tsx` + +**Switch from `buildRedirectUrl` to `buildPostCallback`.** `validateLoopbackCallback` stays as-is (same allow-list, same URL validation). + +```tsx + { + const state = searchParams.get("state"); + const callback = validateLoopbackCallback(searchParams.get("callback")); + if (!(state && callback)) { + return null; + } + // Strip any query params — POST carries token + state in the body. + callback.search = ""; + return { url: callback.toString(), state }; + }} + jwtTemplate="lightfast-desktop" + subtitle="You'll be redirected back to the Lightfast desktop app shortly." + title="Authenticating…" +/> +``` + +#### 4. CLI flow — explicit `mode="redirect"` + +`cli-auth-client.tsx` continues to use `buildRedirectUrl`, but must now pass `mode="redirect"` for the union to discriminate. Leave a short comment referencing the follow-up tracking item so a future reader sees the parity gap is intentional, not an oversight. + +#### 5. `apps/desktop/src/main/auth-flow.ts` — serialize concurrent `beginSignIn` calls + +Today each call spawns a fresh loopback server on a new port, opens a new browser tab, and uses a fresh 256-bit state nonce. Two rapid clicks on "Sign In" produce two of everything — first to complete wins, second times out at 5 min. Confusing, and halfway to a resource leak. + +Guard with a module-scope in-flight promise: + +```ts +let inflight: Promise | null = null; + +export async function beginSignIn(): Promise { + if (inflight) { + return inflight; + } + inflight = (async () => { + try { + // ... existing beginSignIn body, now wrapped ... + } finally { + inflight = null; + } + })(); + return inflight; +} +``` + +All concurrent callers receive the same promise; the `finally` clears the slot on settle, timeout, or error so the next sign-in attempt starts fresh. + +**Implementation note**: the existing `beginSignIn` body returns a `new Promise((resolve) => { /* server setup */ })`. When wrapping inside the async IIFE, `return` that promise so the outer `async` awaits it: + +```ts +inflight = (async () => { + try { + return await new Promise((resolve) => { + // ... existing server setup, settle() calls resolve() ... + }); + } finally { + inflight = null; + } +})(); +``` + +Without the explicit `return await`, the IIFE resolves immediately with `undefined` and `finally` clears `inflight` before the server ever settles — every concurrent call starts its own flow anyway. + +#### 6. Renderer — failure toast + window focus on sign-in + +Two small renderer-side behaviors wired to the sign-in promise chain: + +1. **Failure toast**: the only caller is `apps/desktop/src/renderer/src/react/app-shell.tsx:38` (`onSignIn={() => void window.lightfastBridge.auth.signIn()}`), fire-and-forget today. Replace the `void` with a `.then`: when `beginSignIn()` resolves `null` (any non-success path — timeout, state mismatch, bad body, 400 response, closed tab), show `toast.error("Sign-in didn't complete — please try again")`. Generic wording covers all null cases; "timed out" would be misleading for e.g. state-mismatch returns. Success (non-null) needs no renderer-side action — the auth-state emit already drives the shell swap. +2. **Window focus on sign-in success**: the auth-state subscriber at `apps/desktop/src/main/index.ts:367-371` (the `onAuthChanged` callback that loops `BrowserWindow.getAllWindows()` and `webContents.send`s the snapshot) should additionally call `win.show()` + `win.focus()` on transitions from signed-out → signed-in. Track the previous `isSignedIn` in a module-scope variable; only fire focus when the flag flips from `false` to `true`. This brings the desktop to the front the moment the bridge POSTs, matching the UX of `gh auth login` / `gcloud auth login` surfacing their terminal. + +Gate on **transition only** (false → true). Token refreshes that keep `isSignedIn: true` must not yank focus. + +**Initialize `prev` from the actual boot-time auth state, not `false`.** `load()` is called during boot and populates the in-memory token but does **not** fire `emit()`. So: +- If the user boots signed-in, no emit happens — `createWindow()` at `windows/factory.ts` attaches `win.once("ready-to-show", () => win.show())`, so the window will surface itself naturally once Electron is ready. No post-boot focus-yank needed. +- If a token refresh happens later in that session, it emits `true`. If `prev` was initialized to `false` (the JS default), that looks like a `false → true` transition and we'd incorrectly yank focus. + +The fix: when the subscriber is wired in `index.ts:367`, read the current snapshot (`Boolean(getSnapshot().isSignedIn)` or however the auth-store exposes it) and seed `prev` with that value. The module-scope state becomes: + +```ts +let prev = Boolean(getSnapshot().isSignedIn); // accurate at subscriber-attach time + +onAuthChanged((snapshot) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IpcChannels.authChanged, snapshot); + } + const next = Boolean(snapshot.isSignedIn); + if (!prev && next) { + for (const win of BrowserWindow.getAllWindows()) { + win.show(); + win.focus(); + } + } + prev = next; +}); +``` + +This way: +- Boot signed-out → first user sign-in → emit(true) → `prev=false, next=true` → focus. ✓ +- Boot signed-in + token refresh → emit(true) → `prev=true, next=true` → no focus. ✓ +- Sign-out mid-session → emit(false) → `prev=true, next=false` → no focus (gate doesn't match). ✓ +- Re-sign-in after sign-out → emit(true) → `prev=false, next=true` → focus. ✓ + +No separate "skip first emit" sentinel is needed — the bug was in the default value of `prev`, not in the transition detection. + +#### 7. Observability — Sentry at auth-flow error sites + +Use `Sentry.captureException` for real errors and `Sentry.captureMessage(msg, { level: "warning" })` for anomalies that don't throw. **Do not use `addBreadcrumb` standalone here** — breadcrumbs only attach to a subsequent `captureException` in the same scope, so a timeout that doesn't throw produces zero Sentry events and disappears. + +Real failures (`captureException`, with `tags.scope`): + +- `startLoopbackServer()` bind failure → `scope: "auth-flow.bind"` +- `setToken(token) === false` after a successful browser POST → `scope: "auth-flow.persist_failed"` — user completed the browser flow but desktop couldn't save +- Outer handler catch (the `try/catch` around the request handler) → `scope: "auth-flow.handler_error"` + +Anomalies (`captureMessage` at `level: "warning"`, with `tags.scope`): + +- State mismatch in POST handler → `"auth-flow: state mismatch"`, `scope: "auth-flow.state_mismatch"` +- Forbidden origin in POST handler → `"auth-flow: forbidden origin"`, `scope: "auth-flow.forbidden_origin"` — worth tracking as a signal of a misconfigured prod origin or a local probe +- Timeout settle (the 5-minute no-callback path) → `"auth-flow: sign-in timeout"`, `scope: "auth-flow.timeout"` + +These are the signals that distinguish "silent prod regression" from "we can see it happened and why". `captureMessage` creates a real Sentry event at warning level so anomaly paths are visible in the dashboard even when nothing throws. + +### Residual risk — what fetch-POST does and does not cover + +Honest framing of the threat model for future readers: + +| Surface | GET redirect (today) | fetch-POST (this phase) | PKCE code exchange (rejected) | +| --- | --- | --- | --- | +| Browser URL bar | JWT visible | Clean | Clean | +| Browser history | JWT persisted | Clean | Clean | +| `Referer` headers to third parties | JWT leaks | Clean | Clean | +| URL-logging browser extensions | JWT captured | Clean | Clean | +| Request-body-capable extensions (`webRequest` + body perms) | JWT captured | JWT captured | Clean | +| DevTools Network tab (open by user) | JWT visible | JWT visible | Clean (opaque code) | +| In-memory fetch request body | N/A | Present briefly | Clean (code not JWT) | + +fetch-POST clears every URL-surface leak, which is the bar CodeRabbit #7 asked us to meet. It does **not** make the JWT invisible to a user who already installed a request-body-reading extension, nor to DevTools. PKCE would close those gaps by replacing the JWT with a single-use code that's redeemed for a JWT over a server-to-server exchange — but that requires a new `/api/desktop/auth/exchange` endpoint and a Redis-backed code↔JWT map. Rejected as scope creep for a 24h-bounded localhost handoff. If the threat model ever expands (e.g., longer-lived tokens, shared machines), revisit PKCE. + +### Success Criteria + +#### Automated Verification: + +- [x] `pnpm --filter @lightfast/desktop typecheck` passes. +- [x] `pnpm --filter @lightfast/app typecheck` passes. +- [x] `pnpm biome check apps/desktop/src/main/auth-flow.ts apps/app/src/app/\\(app\\)/\\(user\\)/\\(pending-not-allowed\\)/_components/client-auth-bridge.tsx apps/app/src/app/\\(app\\)/\\(user\\)/\\(pending-not-allowed\\)/desktop/auth/_components/desktop-auth-client.tsx` is clean. +- [x] `pnpm --filter @api/app vitest run` still passes (no behavioral changes to `resolveClerkSession`). + +#### Manual Verification: + +- [ ] `pnpm dev:desktop-stack` + `pnpm dev:desktop` boot cleanly. +- [ ] Click "Sign in with Lightfast" → default browser opens at `localhost:3024/desktop/auth?state=…&callback=http://127.0.0.1:/callback`. Complete Clerk sign-in. +- [ ] After the bridge flips to "Signed in — close this tab", open browser DevTools → History. Confirm **no entry contains `token=`** in the URL. Only `state` and `callback` should appear. +- [ ] Open DevTools → Network. Confirm the handoff is a `POST http://127.0.0.1:/callback` returning 204, with JWT present only in the request body. +- [ ] Craft a malicious `callback` param (`https://evil.com/callback`) → bridge renders "Authentication Failed"; no POST is fired. +- [ ] Mismatched `state` (mutate in-flight via DevTools) → loopback returns 400 with `state_mismatch`; desktop remains on auth-gate. +- [ ] `NODE_ENV` sanity in packaged builds: `getApiOrigin()` falls through to `http://localhost:3024` when `NODE_ENV !== "production"`. Packaged Electron apps don't always have `NODE_ENV` set in `process.env` (depends on electron-builder / Squirrel config). Add a one-liner `console.log("[auth-flow] ALLOWED_ORIGIN =", ALLOWED_ORIGIN);` at module load, build a signed dev DMG, launch it, and confirm the logged origin matches the expected prod value (`https://lightfast.ai`) — not `http://localhost:3024`. If it falls through to localhost, production sign-in will 403 on origin. This is a **pre-existing** concern (the helper predates this plan) but Phase 2 makes it load-bearing for CORS. +- [ ] Origin check: `curl -X POST -H "Origin: http://evil.com" -H "Content-Type: application/json" -d '{"token":"x","state":"x"}' http://127.0.0.1:/callback` returns 403. (Port is printed in the desktop console log.) +- [ ] Empty-Origin rejection: `curl -X POST -H "Content-Type: application/json" -d '{"token":"x","state":"x"}' http://127.0.0.1:/callback` (no Origin header) also returns 403 — confirms the tightened origin check. +- [ ] PNA header: `curl -i -X OPTIONS -H "Origin: https://lightfast.ai" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Private-Network: true" http://127.0.0.1:/callback` returns `Access-Control-Allow-Private-Network: true` in the response headers. +- [ ] Double-handshake guard (StrictMode): with `apps/app` running in dev (StrictMode on by default in Next.js 15), complete a sign-in. The Network tab should show **exactly one** POST to `http://127.0.0.1:/callback`, not two. Confirms the `didStart` latch bars React's second effect invocation. +- [ ] Concurrent sign-in serialization: click "Sign In" twice in rapid succession (< 500ms apart). Expect **one** browser tab to open (not two), **one** loopback port to be logged, and a clean sign-in on completion. The second IPC call should resolve to the same token as the first. +- [ ] Sign-in timeout UX: click "Sign In", close the browser tab without completing. After ~5 minutes, the desktop should display a toast ("Sign-in didn't complete — please try again") and return to the pre-sign-in state. Sentry should show an `auth-flow.timeout` warning-level event (from `captureMessage`, not a breadcrumb). +- [ ] Window focus on sign-in: from a desktop window that's been command-tab'd away (not focused), complete a sign-in in the browser. The desktop window should auto-`show()` + `focus()` the moment the POST resolves. +- [ ] Window focus does not fire on refresh: while signed in, trigger a token refresh (or simulate an `emit({ isSignedIn: true })` on an already-signed-in state). The desktop window should **not** steal focus. Confirms the focus call is gated on the signed-out→signed-in transition. +- [ ] Regression: sign-in via `apps/app` browser-cookie flow still works (`/sign-in` unchanged). +- [ ] Regression: CLI flow at `/cli/auth` still completes (smoke test — not shipping changes there, just ensuring `ClientAuthBridge` refactor didn't break it). + +**Implementation Note**: Pause for human confirmation after Phase 2. The browser-history test is the defining acceptance criterion for #7 — don't proceed to Phase 3 until that has been eyeballed on a real Clerk dev session. + +--- + +## Phase 3: ClientAuthBridge state machine + useEffect dep array [DONE] + +### Overview + +Make `ClientAuthBridge` deterministic when Clerk reports signed-out, and stabilize the effect's dep array so it doesn't re-fire on every parent render. + +### Changes Required + +#### 1. `apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.tsx` + +**Gate the handshake with a `useRef` one-shot latch instead of fighting the dep array.** + +Enumerating stable callables in the dep array (`buildPostCallback`, `buildRedirectUrl`, `jwtTemplate`) looks principled but is fragile in practice: both parents (`desktop-auth-client.tsx`, `cli-auth-client.tsx`) pass builders as inline arrow closures, so identity flips on every parent re-render. Today the parents are stateless and rarely re-render, but any future wrapping layout change, React 19 transition, or searchParams mutation would re-fire the effect mid-handshake — and `getToken()` would run again, potentially double-POSTing. + +The handshake is *semantically* one-shot (one tab, one token, one POST). Make that explicit with a ref latch and you can cut the dep array to the two values that genuinely drive the state transition: + +```ts +const didStart = useRef(false); + +useEffect(() => { + if (!isLoaded || didStart.current) { + return; + } + if (!isSignedIn) { + setStatus("error"); + didStart.current = true; + return; + } + didStart.current = true; + void (async () => { + try { + const token = await getToken( + props.jwtTemplate ? { template: props.jwtTemplate } : undefined + ); + if (!token) { + setStatus("error"); + return; + } + // ... Phase 2's branching on props.mode ... + } catch { + setStatus("error"); + } + })(); +}, [isLoaded, isSignedIn]); +``` + +Notes: +- Latch is set **before** the async work starts, so an exception in the async block still bars re-entry. +- Dep array is `[isLoaded, isSignedIn]` only — no builder/searchParams/jwtTemplate identity concerns. The builder is read off `props` at call time, which is fine because the handshake runs exactly once. +- `isLoaded && !isSignedIn` resolves deterministically to `"error"` instead of idle "loading" (fix for #4). +- React 18/19 StrictMode double-invokes effects in dev; the second invocation sees `didStart.current === true` and bails, which is the correct behavior here (the first run already kicked off the handshake). +- Biome's `lint/correctness/useExhaustiveDependencies` rule will flag the missing deps (`props`, `getToken`, `searchParams`). Disable with a targeted inline comment — note this is the Biome rule path, not ESLint's `react-hooks/exhaustive-deps` (ultracite / biome is the only linter configured; ESLint comments will be ignored): `// biome-ignore lint/correctness/useExhaustiveDependencies: handshake is one-shot, latched by didStart.current — re-firing the effect would double-POST the token.` + +### Success Criteria + +#### Automated Verification: + +- [x] `pnpm --filter @lightfast/app typecheck` passes. +- [x] `pnpm biome check apps/app/src/app/\\(app\\)/\\(user\\)/\\(pending-not-allowed\\)/_components/client-auth-bridge.tsx` is clean. +- [x] `pnpm --filter @lightfast/app test client-auth-bridge` — 10/10 pass, including: + - `"fires exactly one POST under React StrictMode double-invoke (didStart latch)"` — validates the one-shot latch survives React's dev-mode double-invoke. + - `"renders error deterministically when Clerk reports signed-out"` — validates `isLoaded && !isSignedIn → "error"` (the fix for #4). + - `"stays in loading state while Clerk is not yet loaded"` — validates the `!isLoaded` short-circuit. + +#### Manual Verification: + +Phase 3's scope is fully covered by the automated tests above — each manual scenario has a direct test-case equivalent, so no human testing is required: + +- [x] `"renders error deterministically when Clerk reports signed-out"` covers the incognito/no-session case. +- [x] The StrictMode latch test covers the re-render-doesn't-double-POST concern that motivated the dep-array change. +- [x] The Phase 2 happy-path test (`"POSTs token + state as JSON body…"`) exercises the normal signed-in flow through the new state machine. + +> Note: Phase 3's code changes (didStart latch, `[isLoaded, isSignedIn]` dep array, deterministic error on `isLoaded && !isSignedIn`) landed inside Phase 2's `ClientAuthBridge` rewrite (commit `e5c36f7bc`). This phase's work is verification-only — automated tests were added in commit `9e1c07d3c`. + +--- + +## Phase 4: Missing `resolveClerkSession` auth-boundary test [DONE] + +### Overview + +Add the expired-Bearer-without-cookie test case to `api/app/src/__tests__/resolve-clerk-session.test.ts`. This is the canonical desktop unhappy path (expired 24h JWT, no cookie because the desktop has never been to lightfast.ai) and is currently unverified. + +### Changes Required + +#### 1. `api/app/src/__tests__/resolve-clerk-session.test.ts` + +**Insert after the existing `"falls through to the cookie path when the Bearer JWT is invalid"` test.** + +```ts +it("returns null when the Bearer JWT is invalid and no cookie session exists", async () => { + verifyTokenMock.mockRejectedValueOnce(new Error("jwt expired")); + authMock.mockResolvedValueOnce({ userId: null, orgId: null }); + + const session = await resolveClerkSession( + new Headers({ authorization: "Bearer expired.jwt" }) + ); + + expect(session).toBeNull(); + expect(verifyTokenMock).toHaveBeenCalledTimes(1); + expect(authMock).toHaveBeenCalledWith({ treatPendingAsSignedOut: false }); +}); +``` + +Note: The existing `"returns null when neither Bearer nor cookie produce a session"` test covers the no-Authorization-header variant. The new case is specifically about a **present but expired** Bearer token combined with the no-cookie state — which is the steady-state for a desktop whose session lapsed overnight. + +### Success Criteria + +#### Automated Verification: + +- [x] `pnpm --filter @api/app test src/__tests__/resolve-clerk-session.test.ts` passes with 6 tests (package exposes `test` script, not `vitest` — same binary). +- [x] `pnpm --filter @api/app typecheck` passes. +- [x] `pnpm biome check api/app/src/__tests__/resolve-clerk-session.test.ts` is clean. + +#### Manual Verification: + +- None needed — pure unit test addition. + +--- + +## Testing Strategy + +### Unit Tests + +- `resolveClerkSession` — Phase 4 adds the expired-Bearer-without-cookie case. Existing five cases cover Bearer-valid, Bearer-valid-without-org, Bearer-invalid-with-cookie, no-auth, and cookie-only. +- No new unit tests for `auth-store` — Electron main process has no vitest setup. If CI coverage becomes a hard gate, file a follow-up to add a minimal vitest config. + +### Integration Tests + +- Covered manually per the PR's existing test plan, extended by the phase-specific checks above. + +### Manual Testing Steps + +Post-Phase 2, the headline verification is: + +1. Sign in via desktop → complete Clerk flow in default browser. +2. Open browser history → **no JWT in any URL**. Only `state=…` + `callback=http://127.0.0.1:/callback` should appear. +3. Open Network tab → JWT visible only as POST request body on `http://127.0.0.1:/callback`, response 204. +4. Sign out → `auth.bin` removed; `AccountCard` unmounts. +5. Simulate storage failure (chmod the userData dir) → sign-in attempt ends on "Sign-in failed", not false success. + +## Performance Considerations + +- One extra round trip on sign-in (browser → loopback POST) vs the current redirect. Both are localhost; the added latency is sub-millisecond. +- No change to cold-start or token-verification hot path (`verifyToken` behavior is untouched in `trpc.ts`). +- `readJsonBody` caps at 16 KB — a sane Clerk JWT is well under that, and the cap protects against a malicious local process POSTing a huge body. + +## Migration Notes + +- **No data migration needed.** Existing `auth.bin` files from pre-fix builds are forward-compatible; Phase 1's changes only affect how writes and deletes are sequenced, not the on-disk format. +- **Rollback story**: Each phase is a separate commit. If Phase 2 regresses in prod, revert only the Phase 2 commits — Phase 1's correctness fix stands on its own. + +## References + +- Original PR: https://github.com/lightfastai/lightfast/pull/614 +- CodeRabbit review comments (verbatim): captured in conversation history at planning time — 9 total, 5 actionable. Each Phase maps to the CodeRabbit finding #: + - Phase 1 → findings #9 (critical) + #8 (major) + - Phase 2 → finding #7 (major) + - Phase 3 → finding #4 (warning) + - Phase 4 → finding #1 (minor) +- Architectural backdrop: `thoughts/shared/plans/` plus the PR body's "Four late fixes" section — same security posture (loopback-only, state nonce, safeStorage). +- Similar patterns in the wild: `gh auth login` and `gcloud auth login` both use POST-style handoffs rather than URL-embedded tokens. CLI equivalent is out of scope here; follow-up item. +- Loopback host choice — `127.0.0.1` not `localhost`: RFC 8252 §7.3 (OAuth 2.0 for Native Apps) recommends the IP literal because (a) `localhost` resolution is system-dependent and can land on IPv6/IPv4 unpredictably, (b) `/etc/hosts` can override `localhost`, and (c) Chrome PNA classifies `127.0.0.1` as loopback unambiguously. Matches what `gh`/`gcloud` use and what `auth-flow.ts`'s `LOOPBACK_HOST` already binds to. + +## Improvement Log + +Changes made during `/improve_plan` review on 2026-04-24. Original plan was solid on structure but had a handful of factual and correctness issues that would have bitten during implementation. + +### Critical fixes + +- **Corrected the `signOut()` consumer reference.** The original plan named `bootstrap.ts` as the sole consumer; that file (37 lines) does not call `signOut()` at all. The real consumers are `apps/desktop/src/main/index.ts:218` (IPC handler) and `apps/desktop/src/renderer/.../app-shell.tsx:26, 47` (renderer). Phase 1 now propagates the boolean through both layers. Without this fix, the critical sign-out-atomicity fix would have been invisible to the user. +- **Added `Access-Control-Allow-Private-Network: true` to Phase 2's CORS response.** Chrome's PNA spec requires this header when a public origin (`https://lightfast.ai`) fetches a loopback target — missing it silently breaks production sign-in while dev (loopback→loopback) continues to work. Not surfaced in the original plan. +- **Decoupled `settle()` from `setToken()` in Phase 1.** The original snippet assumed settle didn't persist; the actual code (auth-flow.ts:88-99) calls `setToken` internally, so a naive fix would have double-persisted. New snippet refactors settle to resolution-only and moves persistence to the handler, preserving the error-propagation contract Phase 2 depends on. + +### High-value improvements + +- **`ClientAuthBridgeProps` now a discriminated union on `mode`.** Original plan had two optional builder callbacks with a runtime "exactly one" check. The union makes the impossible state unrepresentable and kills an `"error"` branch. User explicitly chose this option. +- **Phase 3 switched from dep-array enumeration to a `useRef` one-shot latch.** User's own callout: enumerating `props.buildPostCallback`, `props.buildRedirectUrl`, etc. looks principled but both parents pass inline arrows, so identity flips on any parent re-render, risking double POSTs under React 19 transitions or StrictMode. The latch is semantically correct (handshake IS one-shot) and shrinks deps to `[isLoaded, isSignedIn]` only. +- **Added Phase 2 "Residual risk" section.** Explicit tradeoff table showing what fetch-POST closes (URL bar / history / Referer / URL-logging extensions) vs what only PKCE would close (DevTools Network tab / request-body-capable extensions / in-memory body). Documents why PKCE was rejected (new endpoint + Redis code↔JWT map) so future-us doesn't re-litigate. +- **Sign-out failure UX: toast + stay mounted.** Original plan said "log + keep auth-gate visible" — but the auth-gate is only rendered when signed-out, so "keep it visible" is incoherent. Corrected to toast + shell stays mounted (token is still valid on disk; unmounting would flash the auth-gate and revert on next launch). +- **Tightened origin check from `if (origin && origin !== ALLOWED_ORIGIN)` to `if (origin !== ALLOWED_ORIGIN)`** — rejects empty Origin too. Defense-in-depth, cost-free. + +### Smaller touch-ups + +- Added manual verification steps for the sign-out-failure simulation (Phase 1), PNA preflight response (Phase 2), empty-Origin rejection (Phase 2), and StrictMode double-handshake guard (Phase 2). +- Replaced Phase 4's line-number references with test-name references — survives test reordering. +- Added a References entry explaining the `127.0.0.1` vs `localhost` choice (RFC 8252 §7.3) after the user asked during review. +- Added a Key Discoveries entry documenting the `settle()` / `setToken()` coupling since it's load-bearing for Phase 1. + +### Not changed + +- Phase 1 stayed on booleans rather than thrown typed errors. Pragmatic; matches CodeRabbit's own suggestion; booleans carry the binary information without introducing a second error channel. +- Phase 4 unchanged — single test case, zero ambiguity. +- CLI flow still on `buildRedirectUrl` (now `mode="redirect"`). Follow-up item to port CLI to POST after desktop lands in prod, as originally planned. + +### QoL / hardening round (added 2026-04-24) + +After the structural review, a follow-up pass added six items that were tied to the Phase 1/2 surfaces anyway — cheaper to bundle than revisit. + +- **`load()` auto-purges unreadable `auth.bin`** (Phase 1 §1). A rotated macOS keychain or file corruption previously left a permanently-unreadable file on disk; users would fail to sign in with no self-healing path. Now purged on decrypt/parse/schema failure, so the next launch starts clean. +- **Preload type-surface bump made explicit** (Phase 1 §3). TypeScript will not error if the renderer-side ambient type stays `Promise` while the main process returns `Promise`. The plan now enumerates all three surfaces that need updating (preload, renderer ambient, IpcChannels map). +- **Sentry instrumentation** (Phase 1 §1 + Phase 2 §7). `captureException` at real failure sites (persist, clear, load, bind, persist-after-callback, handler-catch) and `addBreadcrumb` at anomaly sites (state mismatch, forbidden origin, timeout). `apps/desktop/src/main/sentry.ts` is already initialized, so this is a handful of one-liners. +- **Serialize concurrent `beginSignIn`** (Phase 2 §5). Two rapid clicks previously spawned two loopback servers + two browser tabs. Module-scope in-flight promise returns the same handle to concurrent callers; `finally` clears on settle. +- **Sign-in timeout toast** (Phase 2 §6). The 5-minute settler previously resolved `null` with no UI signal; users saw the desktop revert to the pre-sign-in view and assumed their click was lost. Now surfaced as a toast. +- **Window focus on sign-in transition** (Phase 2 §6). After the bridge POSTs and `emit({ isSignedIn: true })` fires, the desktop window auto-`show()`/`focus()`es — but only on signed-out→signed-in *transitions*, not every emit, so token refreshes don't yank focus from whatever the user is doing. + +Items 8-12 from the review ("Nice-to-have" and "Explicit scope punts") were deferred: cross-platform test paths, finer-grained bridge error reasons, `Content-Length` fast-reject, 24h JWT refresh, macOS keychain migration. None are shipping blockers; all are worth a follow-up ticket. + +### Ground-truth pass (2026-04-24, second review) + +Second adversarial pass spawned codebase-analyzer + codebase-pattern-finder to verify every file:line claim in the plan against HEAD. Three findings would have silently broken the implementation; the rest were line-reference corrections. + +**Critical (would have silently broken)** + +- **Sonner `` not mounted in the desktop renderer.** `apps/desktop/package.json:58` ships sonner but zero call sites + zero Toaster mount exist in `apps/desktop/src/renderer/**`. The plan's Phase 1 §4 (sign-out-failure toast) and Phase 2 §6 (sign-in-failure toast) would have compiled and called `toast()` with no visible effect. Phase 1 §4 now explicitly calls out the Toaster mount step, referencing the `apps/app/src/app/(app)/layout.tsx:3` pattern. +- **`Sentry.addBreadcrumb` for standalone anomalies is invisible.** Phase 2 §7 used `addBreadcrumb` for `state_mismatch`, `forbidden_origin`, `timeout`. Breadcrumbs only attach to a subsequent `captureException` in the same scope — a timeout settle doesn't throw, nothing captures, the breadcrumb vanishes. User confirmed: switch anomaly paths to `Sentry.captureMessage(msg, { level: "warning" })` so they surface as real Sentry events regardless of whether anything throws later. Real errors (bind, persist_failed, handler_error) stay on `captureException`. +- **Plan duplicated the existing `getApiOrigin()` helper.** `apps/desktop/src/main/auth-flow.ts:10-17` already had the exact dev-vs-prod origin helper the plan re-declared as `ALLOWED_ORIGIN`. User confirmed: reuse — Phase 2 §1 now reads `const ALLOWED_ORIGIN = getApiOrigin()`, with a comment noting that `index.ts:45-52` has a twin helper (`getApiOriginForCsp`) worth consolidating in a separate cleanup. + +**High-value corrections** + +- **Sign-out toast context-split: user-click vs UNAUTHORIZED auto-path.** `app-shell.tsx:26` is inside a `queryClient.getQueryCache().subscribe` callback reacting to every tRPC `UNAUTHORIZED`. If sign-out fails there and we toast, the same error re-fires on the next query and re-toasts — tight-loop spam. User confirmed: toast only on the user-clicked button at `app-shell.tsx:47`; the UNAUTHORIZED auto path gets `Sentry.captureException` with `scope: "app-shell.auto-sign-out"` and no user-visible feedback. Manual-verification step split into two to cover both paths independently. +- **IPC type-surface enumeration was wrong.** Plan listed three surfaces; only two exist. `IpcInvokeMap` doesn't exist in this repo — `apps/desktop/src/shared/ipc.ts:5-26` is a plain string-const object. The renderer ambient declaration isn't a `.d.ts` — it lives at `apps/desktop/src/renderer/src/main.ts:16-21` and references `LightfastBridge` from `shared/ipc.ts:106`, so editing the interface propagates automatically. Corrected to two surfaces: `shared/ipc.ts:106` (interface) + `preload/preload.ts:36` (binding, not `preload.ts` at root). +- **Biome rule name was wrong for Phase 3 suppression.** Plan referenced the ESLint rule name (`react-hooks/exhaustive-deps`). Biome's rule is `lint/correctness/useExhaustiveDependencies`. Plan now specifies the correct `// biome-ignore` comment with the right rule path. +- **Line references corrected.** `onAuthChanged` subscriber is at `index.ts:367-371`, not `~225`. IPC handler is at `index.ts:217-219`. Timeout-toast caller pinned to `apps/desktop/src/renderer/src/react/app-shell.tsx:38` (the sole call site). +- **Timeout toast wording generalized.** Original plan hardcoded "Sign-in timed out". The POST handler also settles `null` on state mismatch, bad body, and forbidden origin — same null return from the renderer's perspective. Changed to "Sign-in didn't complete — please try again" to cover all null-resolution paths honestly. + +**Not changed** + +- Phase 1 boolean return types, Phase 2 POST+CORS+PNA shape, Phase 3 `didStart` ref latch, Phase 4 expired-Bearer test — all verified against the code and stand. +- `@sentry/electron/main` import confirmed correct (matches `apps/desktop/src/main/sentry.ts:2`). +- Port 3024 dev origin confirmed correct (microfrontends port, not app's 4107). +- Zod is already imported in `auth-store.ts:1` — the new `callbackBodySchema` in `auth-flow.ts` follows existing precedent. + +**Follow-up pass on overlooked edges** + +- **Window focus gate now has a boot-skip sentinel.** Original gate (`prev false → next true`) correctly rejected token refreshes but accepted the app-boot transition where `load()` rehydrates a persisted token. That's also `false → true`, and firing `show()/focus()` during boot is either redundant (`createWindow()` already shows) or disruptive (yanks focus from a dock-minimized launch). Added a module-scope `hasEmittedOnce` sentinel that's set after the first subscriber call — subsequent transitions pass, the boot transition is skipped. +- **Added a signed-in-relaunch happy-path verification step in Phase 1.** Original manual checks covered sign-in-file-exists and sign-out-file-removed-stays-on-auth-gate, but not sign-in → quit → relaunch → remains signed in. That's the canonical path that catches a `load()` regression from the new auto-purge branches. +- **Flagged the `NODE_ENV` fallthrough in packaged Electron as a verification step.** `getApiOrigin()` falls through to `http://localhost:3024` when `NODE_ENV !== "production"`. Packaged Electron doesn't always set `NODE_ENV` at runtime; if unset in prod, origin check 403s on all real sign-ins. Pre-existing concern (the helper predates this plan), but Phase 2 makes it load-bearing for CORS. Added a verification step to confirm the logged origin matches expected prod value in a signed dev DMG before ship. + +**Stage-coverage pass (third review)** + +Walked every stage of sign-in and sign-out end-to-end, found three real gaps — the `hasEmittedOnce` sentinel I'd added in the previous pass was itself a bug. + +- **Reverted the `hasEmittedOnce` boot-skip sentinel in Phase 2 §6.** It would have suppressed the legitimate first sign-in after a signed-out boot — `load()` does not emit, so the first emit is always a user action, not a boot-rehydrate. The real issue was the initial value of `prev`: defaulting to `false` meant a boot-signed-in token refresh looked like a `false → true` transition and would yank focus. Correct fix: initialize `prev = Boolean(getSnapshot().isSignedIn)` at subscriber-attach time, so the transition detector reflects boot reality. All four transition cases now resolve correctly (see Phase 2 §6). +- **Added web-side Sentry observation to the bridge's error paths (Phase 2 §2).** Without it, a prod-only CORS / preflight regression (e.g. `NODE_ENV` fallthrough, misconfigured `Access-Control-Allow-Private-Network`) shows as a silent "Authentication Failed" on the user's browser tab with zero Sentry signal — the desktop's handler never runs because the browser rejected the preflight. Now: `captureMessage(..., "warning")` for `buildPostCallback` returning null and non-2xx responses (with status code as a tag), `captureException` for fetch `TypeError` (network-level block). Matches the Phase 2 §7 `captureMessage` / `captureException` split on the desktop side. +- **Pinned the UNAUTHORIZED auto-sign-out Sentry wiring as code in Phase 1 §4.** Previous draft described the behavior ("`Sentry.captureException`, no toast") but didn't show the `.then((ok) => { if (!ok) Sentry.captureException(...) })` shape. Now both the user-click and UNAUTHORIZED sites have exact code snippets so an implementer can drop them in verbatim. Also pinned the Sentry import source — renderer uses `@sentry/browser` per `renderer/src/main.ts:32-36`, not `@sentry/electron/renderer`. + +**Gaps identified but deferred (noted in code comments, not blocking)** + +- **`window.close()` on success panel.** Can't reliably close a tab opened via `shell.openExternal` (browser blocks JS-close on non-JS-opened tabs). Bridge's success panel can attempt it as a best-effort, but must not rely on it. Plan now mentions this in Phase 2 §2. +- **No client-side timeout on `getToken()`.** If Clerk hangs, the bridge shows "Authenticating…" indefinitely until the desktop's 5-minute timer fires. Not shipping-blocking; worth a follow-up ticket with a 30-second bridge-side timeout. +- **Concurrent `auth.signOut` calls.** No serialization — relies on `rmSync` with `force: true` being idempotent on ENOENT. Works but not explicitly documented. If a future change to `clearPersisted()` ever loses the `force: true`, concurrent sign-outs would race. Noted as implementation invariant. + +### Bug-focused pass (fourth review, 2026-04-24) + +Fourth review ran codebase-analyzer + codebase-pattern-finder against every code snippet in the plan, focused on "does this compile and run correctly against HEAD". Nine bugs would have slowed or broken the implementation; all are now fixed in the plan. Groupings by severity. + +**Critical (would have broken the build or spammed Sentry)** + +- **`@repo/ui` is not a dependency of `apps/desktop`.** Phase 1 §4 instructed the implementer to `import { Toaster } from "@repo/ui/components/ui/sonner"` in the desktop renderer — that package is not in `apps/desktop/package.json` deps, so module resolution would fail at build time. `sonner` **is** a direct dep (line 58), so switched to `import { Toaster, toast } from "sonner"`. Applies to both the Toaster mount and the `toast.error(...)` call sites. +- **`clearTimeout` identifier name drift.** Phase 1 §2's refactored `settle()` called `clearTimeout(timeoutHandle)`, but the existing auth-flow.ts uses a local `timer` (`const timer = setTimeout(...)` at line 101 per verification). Renamed to `clearTimeout(timer)` and added an inline note so the implementer doesn't get tripped up if they've already started an independent rename. +- **UNAUTHORIZED-path Sentry would cascade without a latch.** Phase 1 §4 correctly removed toasts from the UNAUTHORIZED auto-sign-out subscriber to prevent tight-loop spam, then introduced `Sentry.captureException(...)` in the same subscriber. Every UNAUTHORIZED query while sign-out is failing would fire one Sentry event — the SDK rate-limits but the project's monthly ingest would drain during an outage. Added a module-scope `signoutFailureReported` boolean latch that caps reports at one per session. Reset to `false` on a successful sign-in via the Phase 2 `.then(token => ...)` handler so subsequent outages re-report. + +**High (would have caused friction or inconsistency)** + +- **apps/app Sentry import style mismatched existing code.** Phase 2 §2 and §7 used `import * as Sentry from "@sentry/nextjs"` with namespace calls. Existing apps/app call sites (`session-activator.tsx:4`, `oauth-button.tsx:6`) use **named** imports (`import { addBreadcrumb, startSpan } from "@sentry/nextjs"`). The plan's hint "grep for `import * as Sentry`" returned zero hits, leaving the implementer guessing. Switched to named imports: `import { captureException, captureMessage } from "@sentry/nextjs"`. Desktop main and renderer stay on namespace style to match their pre-existing imports; the cross-package inconsistency is pre-existing and out of scope to unify. +- **`node:http` import split from existing statement.** Phase 2 §1 added `import type { IncomingMessage, ServerResponse } from "node:http"` as a fresh line, but the existing file already imports from `node:http` (line 2). Biome's style rules auto-merge; leaving split imports would flag on the pre-commit check. Consolidated into a single statement with `createServer`, `type IncomingMessage`, `type Server`, `type ServerResponse`. +- **`createWindow()` rationale claim was false.** Phase 2 §6 justified the boot-signed-in no-focus case by saying "`createWindow()` already shows the window", but `windows/factory.ts:139` actually defers `show()` to `win.once("ready-to-show", () => win.show())`. The prescribed behavior (seeding `prev = Boolean(getSnapshot().isSignedIn)`) is still correct; only the rationale comment was misleading. Reworded to reference `ready-to-show` so a future reader doesn't chase a non-existent `win.show()` call. + +**Improvements (tightens the implementation but wouldn't have broken anything)** + +- **403 Forbidden origin path omits CORS headers intentionally.** Phase 2 §1's forbidden-origin branch writes 403 before `applyCors(res)` runs, so browsers render this as an opaque "CORS error" rather than confirming which origins are allowed — deliberate defense-in-depth. Added an inline comment in the code snippet so a future debugger doesn't misread the behavior as a missing CORS fix. +- **`readJsonBody` didn't `req.destroy()` on size overflow.** The handler throws when the request body exceeds 16 KB, but the TCP socket remained open until the client closed it — a trivial local-port-hold vector. Added `req.destroy()` before throwing. Cost is zero for legitimate clients (JWTs are <4 KB); closes the socket immediately on any oversized POST. +- **`beginSignIn` IIFE wrapping needed explicit `return await`.** Phase 2 §5's in-flight-promise guard wrapped the existing `beginSignIn` body inside an async IIFE with `try { ... } finally { inflight = null }`, but the snippet didn't show the critical `return await new Promise<...>(...)` inside. Without the explicit `return`, the IIFE resolves with `undefined` and the `finally` clears `inflight` before the server ever settles — every concurrent call starts its own flow anyway, defeating the guard. Plan now shows the full IIFE shape with the required `return await`. + +**Not changed by this pass** + +- Phase 1 booleans, auto-purge branches, Phase 2 POST+CORS+PNA shape, Phase 3 `didStart` ref latch, Phase 4 expired-Bearer test — all verified against the code and stand. +- Cross-package Sentry import style (namespace in desktop main/renderer, named in apps/app) — documented as pre-existing, not unified. +- Concurrency model for `beginSignIn` inflight guard — correct as structured, just needed the IIFE return shape clarified. diff --git a/thoughts/shared/plans/2026-04-25-desktop-auth-url-scheme-pkce.md b/thoughts/shared/plans/2026-04-25-desktop-auth-url-scheme-pkce.md new file mode 100644 index 000000000..4c2d824b0 --- /dev/null +++ b/thoughts/shared/plans/2026-04-25-desktop-auth-url-scheme-pkce.md @@ -0,0 +1,844 @@ +--- +date: 2026-04-25 +owner: jp@jeevanpillay.com +branch: fix/coderabbit-pr614-followup +based_on: thoughts/shared/research/2026-04-25-desktop-signin-agent-browser-workaround.md +status: implemented + live-verified end-to-end +--- + +# Desktop Sign-In: Custom URL Scheme + PKCE Implementation Plan + +## Implementation Status (2026-04-25) + +All five phases implemented. Automated + live verification: + +- Phase 1 — server endpoints + tests: `pnpm typecheck` ✓, 13 new unit tests ✓. **Live**: real Clerk JWT issued via `lightfast-clerk` skill → POST `/api/desktop/auth/code` returned a code (200) → POST `/api/desktop/auth/exchange` returned the **same JWT** (200) → second POST with same code returned `invalid_code` (400). Real Upstash Redis GETDEL one-shot semantics confirmed. +- Phase 2 — `code-redirect` bridge mode + tests: app suite 88 passed (5 new) ✓. Live UI handshake not exercised (would require Clerk-cookie browser session); contract verified by mocked unit tests. +- Phase 3 — `protocol.ts` + Forge `CFBundleURLTypes` + tests: 12 new tests ✓. **Live**: `app.setAsDefaultProtocolClient("lightfast-dev")` works in real Electron 41; `open lightfast-dev://...` from the terminal was routed by macOS LaunchServices into the running desktop's `app.on('open-url')` handler. +- Phase 4 — `auth-flow.ts` PKCE rewrite + IPC + agent-mode auto-trigger + tests: desktop suite 34 passed; `rg "createServer|loopback|MAX_BODY_BYTES|applyCors|LIGHTFAST_DESKTOP_AUTH_NO_OPEN"` returns zero hits ✓. **Live**: real Electron 41 + `LIGHTFAST_DESKTOP_AGENT_MODE=1` emitted `{"event":"auth_signin_url","url":...}` on stdout (no `shell.openExternal`, no Dia spawn), then a dispatched `lightfast-dev://auth/callback?code=…&state=` triggered the exchange call and emitted `{"event":"auth_signin_failed","reason":"exchange_failed"}` (expected — no fresh code in Redis). +- Phase 5 — `.agents/skills/lightfast-desktop-signin/SKILL.md` ✓. + +### Bugs found and fixed during live verification (out-of-scope-but-blocking) + +1. **`apps/app/src/proxy.ts`** — Clerk middleware's `isApiRoute` matcher missed `/api/desktop/(.*)`. Without this, all new routes 307'd to `/sign-in`. Fix: one-line addition matching the existing `/api/cli/(.*)` entry. +2. **`apps/desktop/src/main/windows/factory.ts`** — used `import.meta.url` which Vite 8 + CJS bundling resolves to `undefined`, crashing on Electron boot with `ERR_INVALID_ARG_TYPE` from `fileURLToPath`. Fix: switch to CJS-native `__dirname`. Pre-existing regression from the recent Electron 41 / Vite 8 upgrade — not introduced by this plan, but blocking dev runtime. + +### Full UI-driven happy path (verified 2026-04-25) + +Drove the complete chained flow with `agent-browser` headed: + +1. Bring up dev mesh on `:3024`, start desktop with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and no persisted token. +2. Sign into Clerk via `lightfast-clerk` sign-in playbook (email + OTP `424242`) — cookie session established at `/claude-default-org`. +3. Desktop emitted `{"event":"auth_signin_url","url":"http://localhost:3024/desktop/auth?state=…&code_challenge=…&code_challenge_method=S256&redirect_uri=lightfast-dev%3A%2F%2Fauth%2Fcallback"}`. +4. `agent-browser open ` → `ClientAuthBridge` (mode `code-redirect`) read the cookie session, called `/api/desktop/auth/code` with Bearer JWT + PKCE body, got back a code, redirected `window.location.href = lightfast-dev://auth/callback?code=…&state=…`. +5. macOS LaunchServices dispatched the URL into the running Electron app via `app.on('open-url')`. +6. Desktop matched state, called `/api/desktop/auth/exchange` with the verifier, got back the JWT, persisted via Electron `safeStorage` (851 bytes `auth.bin`). +7. Desktop emitted `{"event":"auth_signed_in"}` — terminal success event, ~14 seconds end-to-end. + +**Idempotent re-run**: killed and restarted desktop with the persisted token. Emitted only `{"event":"auth_already_signed_in"}` — no signin URL, no `shell.openExternal`, no Dia spawn. + +All four event states have now been exercised against real services: `auth_signin_url`, `auth_signed_in`, `auth_already_signed_in`, `auth_signin_failed{reason:"exchange_failed"}` (from the earlier protocol-dispatch smoke test). + +### Remaining unverified + +- Windows/Linux first-launch URL via `process.argv` (Risk #4) — protocol module unit-tested but not run on a Windows/Linux box. + +## Overview + +Replace the desktop sign-in's loopback HTTP server with a standard OAuth 2.0 Authorization Code + PKCE flow over a custom URL scheme (`lightfast://` prod, `lightfast-dev://` dev). Brings the desktop in line with VS Code / GitHub Desktop / Linear / Slack, removes ~150 LoC of HTTP server + CORS plumbing, improves end-user UX (sign-in completes inside the user's existing browser session with extensions and saved logins intact), and gives Claude Code (and any other agent harness) a deterministic single-session sign-in flow with no log-grepping and no system-default-browser hand-off. + +### Agent automation (Claude Code) end state + +1. Desktop is started with `LIGHTFAST_DESKTOP_AGENT_MODE=1`. `shell.openExternal` is *never* called — Dia (or any other system default browser) never opens. +2. On app-ready, desktop checks `getToken()`: + - If a token is already persisted → emit `{"event":"auth_already_signed_in"}` and stop. Idempotent — agents can safely re-run the flow. + - Otherwise → auto-call `beginSignIn()` (no renderer click, no CDP attach) and emit `{"event":"auth_signin_url","url":"..."}`. +3. Agent harness parses stdout, runs `AGENT_BROWSER_HEADED=true agent-browser open "$SIGNIN_URL"`. Headed Chrome for Testing is non-negotiable — see "Risks" #1. +4. Clerk completes → bridge dispatches `lightfast-dev://auth/callback?code=…&state=…` then calls `window.close()` → macOS LaunchServices delivers the URL to the running desktop (warm dispatch) → exchange runs → token persisted. +5. Desktop emits `{"event":"auth_signed_in"}` on persist or `{"event":"auth_signin_failed","reason":"..."}` on timeout/exchange error. Agent harness has a deterministic completion signal — no polling auth-store, no log-grep. +6. Timeout configurable via `LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS` (default 5 min for humans; agents typically set ~30000 for fast CI feedback). + +The full event grammar emitted on stdout (one JSON object per line): + +| Event | When | Payload | +| --- | --- | --- | +| `auth_already_signed_in` | App start, token present in store | `{}` | +| `auth_signin_url` | App start, token absent — sign-in begun | `{ url: string }` | +| `auth_signed_in` | Exchange succeeded, token persisted | `{}` | +| `auth_signin_failed` | Timeout / exchange 4xx / state mismatch / persist failed | `{ reason: string }` | + +Runbook for the entire flow lives at `.agents/skills/lightfast-desktop-signin/SKILL.md` (Phase 5). + +This was validated by spike on 2026-04-25 — see "Improvement Log" for verdict + evidence. + +## Current State Analysis + +- `apps/desktop/src/main/auth-flow.ts` runs an ephemeral `127.0.0.1:` HTTP server per sign-in. It generates `state`, opens `https://lightfast.ai/desktop/auth?state=…&callback=http://127.0.0.1:/callback`, and accepts a POST body `{token, state}` from the web bridge. ~245 LoC, mostly HTTP plumbing. +- `apps/desktop/src/main/auth-flow.ts:232-234` carries a dev-only `LIGHTFAST_DESKTOP_AUTH_NO_OPEN=1` escape hatch that skips `shell.openExternal` so an agent harness can drive the URL itself. Today's two-session ceremony (CDP into Electron + drive web flow) exists because the agent has to find the URL via log-grep. We keep the no-open semantics under the renamed flag `LIGHTFAST_DESKTOP_AGENT_MODE` and replace log-grep with structured stdout (see Phase 4). +- `apps/desktop/src/main/bootstrap.ts:29` already calls `app.requestSingleInstanceLock()` — single-instance plumbing is in place but unused. +- `apps/desktop/forge.config.ts:67-78` defines `extendInfo` for Info.plist but does not declare `CFBundleURLTypes` or any URL scheme. +- `apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.tsx` already supports two modes (`post`, `redirect`); the `redirect` mode does `window.location.href = url`. New flow needs a third mode that exchanges the JWT for a server-issued code first. +- `apps/app/src/app/(app)/(user)/(pending-not-allowed)/desktop/auth/_components/desktop-auth-client.tsx` validates the loopback callback (`http://127.0.0.1` or `localhost`, path `/callback`) and POSTs `{token, state}` via the bridge. +- Upstash Redis is available via `@vendor/upstash` (`vendor/upstash/src/index.ts`) — already used elsewhere in the app for short-lived state. +- Clerk JWT template `lightfast-desktop` is the existing token shape persisted by `auth-store.setToken()` (`apps/desktop/src/main/auth-store.ts:110`). Token semantics stay identical — the change is purely how we deliver it. + +### Key Discoveries + +- Electron's `app.on('open-url')` fires on macOS whether the app is already running or being launched fresh. On Windows/Linux the URL arrives as a process arg via the existing `second-instance` event — minor branching but no new concept. +- `client-auth-bridge.tsx` already returns either a redirect or a POST; adding a third mode is a small extension, not a rewrite. The existing `mode: "redirect"` uses `window.location.href = url`, which is exactly the dispatch a `lightfast://` URL needs. +- Token never needs to live in Redis encrypted-at-our-layer: Upstash provides at-rest encryption and the entry has 30s TTL bound by `EX`. We're not introducing new credential storage primitives. +- `bootstrap.ts:29` already short-circuits second instances. The new `second-instance` listener (Windows/Linux callback delivery) needs to be added in `index.ts` because that's where IPC + windows are wired. +- The desktop has two build flavors today (`isPackaged ? "Lightfast" : "Lightfast Dev"` at `bootstrap.ts:9`). Two URL schemes (`lightfast` / `lightfast-dev`) prevent a dev build from swallowing prod callbacks on the same machine. + +## Desired End State + +- Loopback HTTP server, `applyCors`, `readJsonBody`, `Origin` check, `MAX_BODY_BYTES`, and ephemeral port binding are all gone from `auth-flow.ts`. +- `LIGHTFAST_DESKTOP_AUTH_NO_OPEN` is renamed to `LIGHTFAST_DESKTOP_AGENT_MODE` (semantics broadened: skip `shell.openExternal` AND emit structured stdout JSON for the signin URL). Existing tests are renamed and adapted, not deleted. +- Desktop registers `lightfast` (packaged) or `lightfast-dev` (unpackaged) as a default protocol client; Info.plist contains `CFBundleURLTypes` for the same. +- Sign-in flow: + 1. Renderer click → IPC `auth-sign-in` → main generates `state` + PKCE `code_verifier`/`code_challenge`. + 2. Main composes `https:///desktop/auth?state=…&code_challenge=…&code_challenge_method=S256&redirect_uri=lightfast(-dev)://auth/callback`. + 3. If `LIGHTFAST_DESKTOP_AGENT_MODE=1`, emit `{"event":"auth_signin_url","url":""}` to stdout and skip `shell.openExternal`. Otherwise, call `shell.openExternal(signinUrl)`. (No DOM hook on the renderer — structured stdout is the agent surface.) + 4. Web `/desktop/auth` page: Clerk client component obtains JWT, calls `POST /api/desktop/auth/code` with `{token, state, code_challenge, redirect_uri}`. Server validates, stores `{userId, jwt, state, code_challenge, redirect_uri}` in Redis under random `code` (30s TTL), returns `{code}`. Bridge then `window.location.href = redirect_uri + "?code=…&state=…"`. + 5. Browser dispatches `lightfast(-dev)://` → OS routes to Lightfast.app → `app.on('open-url')` fires (macOS) / `second-instance` fires (Windows/Linux). + 6. Main parses URL, matches `state` to in-flight sign-in, calls `POST /api/desktop/auth/exchange { code, code_verifier }`. Server verifies `SHA256(code_verifier) === code_challenge`, marks code consumed, returns `{token}`. + 7. Main calls `setToken(token)`. `onAuthChanged` fires. Renderer flips to signed-in. +- `pnpm --filter @lightfast/desktop typecheck` and `pnpm --filter @lightfast/app typecheck` pass. +- `pnpm --filter @lightfast/desktop test` and `pnpm --filter @lightfast/app test` pass. +- Manual (agent flow): + ```sh + # Auto-triggers sign-in on app-ready when no token is persisted; idempotent if already signed in. + LIGHTFAST_DESKTOP_AGENT_MODE=1 LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS=30000 \ + pnpm --filter @lightfast/desktop dev > /tmp/desktop.log 2>&1 & + + # Read the first lifecycle event (auth_already_signed_in OR auth_signin_url). + EVENT=$(timeout 30 sh -c "tail -F /tmp/desktop.log | jq -rcM --unbuffered 'select(.event)' | head -1") + case "$(echo "$EVENT" | jq -r .event)" in + auth_already_signed_in) echo "Already signed in"; exit 0 ;; + auth_signin_url) SIGNIN_URL=$(echo "$EVENT" | jq -r .url) ;; + *) echo "Unexpected event: $EVENT"; exit 1 ;; + esac + + AGENT_BROWSER_HEADED=true agent-browser open "$SIGNIN_URL" + + # Block on completion event. + RESULT=$(timeout 30 sh -c "tail -F /tmp/desktop.log | jq -rcM --unbuffered 'select(.event==\"auth_signed_in\" or .event==\"auth_signin_failed\")' | head -1") + echo "$RESULT" | jq -e '.event=="auth_signed_in"' > /dev/null + ``` + Single agent-browser session, no log grep (just JSON-parse), no CDP attach to renderer, Dia never opens. **`AGENT_BROWSER_HEADED=true` is required** — see Risks #1. + +## What We're NOT Doing + +- **Token rotation / refresh.** The persisted JWT lifetime continues to follow the Clerk template setting. Out of scope. +- **Multi-account switching.** One signed-in user per desktop install. Same as today. +- ~~**Browser-side `window.close()` after redirect.**~~ Folded into Phase 2 — agent runs leave a tab open in CI screenshots otherwise, and the cost is ~3 lines. +- **Removing the `lightfast-desktop` Clerk JWT template.** Token semantics unchanged. +- **Deep-link routing beyond auth callback.** `lightfast://` only handles `/auth/callback` for now; future `lightfast://open/` routes are a separate plan. +- **Auto-confirming the browser's "Open Lightfast?" prompt.** First-run friction stays; not solvable from our side. +- **Migration from existing installs.** No prod desktop users yet (per `desktop-release.yml disabled` memory). Cut over in one release. + +## Implementation Approach + +Four phases, ordered by dependency. Phases 1–2 are server-additive (no consumer yet, safe to merge). Phase 3 is desktop-only (URL scheme registration + handler). Phase 4 cuts over and deletes old code. Each phase is independently `typecheck`-clean. + +--- + +## Phase 1: Server — code issue + exchange endpoints + +### Overview + +Two new API routes plus a tiny Redis-backed code store. Additive; no caller until Phase 4. + +### Changes Required + +#### 1. `apps/app/src/app/api/desktop/auth/lib/code-store.ts` (NEW) + +Thin wrapper around `redis` with TTL and one-shot semantics. Keys: `desktop_auth_code:`. Value: JSON `{userId, jwt, state, codeChallenge, redirectUri}`. TTL 30s. `consume()` does `GETDEL` for atomicity (single-use). + +```ts +import { redis } from "@vendor/upstash"; +import { randomBytes } from "node:crypto"; + +const PREFIX = "desktop_auth_code:"; +const TTL_SECONDS = 30; + +export interface CodeRecord { + userId: string; + jwt: string; + state: string; + codeChallenge: string; + redirectUri: string; +} + +export async function issueCode(record: CodeRecord): Promise { + const code = randomBytes(32).toString("base64url"); + await redis.set(`${PREFIX}${code}`, record, { ex: TTL_SECONDS }); + return code; +} + +export async function consumeCode(code: string): Promise { + const result = await redis.getdel(`${PREFIX}${code}`); + return result ?? null; +} +``` + +#### 2. `apps/app/src/app/api/desktop/auth/code/route.ts` (NEW) + +Authed via Clerk JWT in `Authorization: Bearer …` (same pattern as `apps/app/src/app/api/cli/login/route.ts`). Body schema: `{state, code_challenge, code_challenge_method: "S256", redirect_uri}`. Validates `redirect_uri` against allowlist (`lightfast://auth/callback`, `lightfast-dev://auth/callback`). Stores record, returns `{code}`. + +```ts +// POST /api/desktop/auth/code +// Auth: Clerk JWT (lightfast-desktop template) in Authorization header. +import { z } from "zod"; +import { verifyClerkJwt } from "../../../cli/lib/verify-jwt"; // reuse existing +import { issueCode } from "../lib/code-store"; + +const ALLOWED_REDIRECT_URIS = new Set([ + "lightfast://auth/callback", + "lightfast-dev://auth/callback", +]); + +const bodySchema = z.object({ + state: z.string().min(16).max(256), + code_challenge: z.string().min(43).max(128), + code_challenge_method: z.literal("S256"), + redirect_uri: z.string().refine((u) => ALLOWED_REDIRECT_URIS.has(u)), +}); + +export async function POST(req: Request) { + const session = await verifyClerkJwt(req); + if (!session) return Response.json({ error: "unauthorized" }, { status: 401 }); + + const parsed = bodySchema.safeParse(await req.json()); + if (!parsed.success) return Response.json({ error: "bad_request" }, { status: 400 }); + + const auth = req.headers.get("authorization") ?? ""; + const jwt = auth.replace(/^Bearer\s+/i, ""); + + const code = await issueCode({ + userId: session.userId, + jwt, + state: parsed.data.state, + codeChallenge: parsed.data.code_challenge, + redirectUri: parsed.data.redirect_uri, + }); + return Response.json({ code }); +} +``` + +Reuse decision: `verify-jwt.ts` was named for CLI but is generic Clerk JWT verification. Either rename to `verify-clerk-jwt.ts` and re-export, or extract to a shared `apps/app/src/lib/auth/verify-clerk-jwt.ts`. Prefer extraction if this lands cleanly — defer the rename to a follow-up if disruptive. + +#### 3. `apps/app/src/app/api/desktop/auth/exchange/route.ts` (NEW) + +Body schema: `{code, code_verifier}`. Loads + atomically consumes from Redis. Verifies `base64url(SHA256(code_verifier)) === code_challenge`. Returns `{token: jwt}`. + +```ts +// POST /api/desktop/auth/exchange +// Auth: none (the code itself proves possession of the in-flight sign-in). +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { consumeCode } from "../lib/code-store"; + +const bodySchema = z.object({ + code: z.string().min(32).max(128), + code_verifier: z.string().min(43).max(128), +}); + +export async function POST(req: Request) { + const parsed = bodySchema.safeParse(await req.json()); + if (!parsed.success) return Response.json({ error: "bad_request" }, { status: 400 }); + + const record = await consumeCode(parsed.data.code); + if (!record) return Response.json({ error: "invalid_code" }, { status: 400 }); + + const expected = createHash("sha256").update(parsed.data.code_verifier).digest("base64url"); + if (expected !== record.codeChallenge) { + return Response.json({ error: "invalid_verifier" }, { status: 400 }); + } + return Response.json({ token: record.jwt }); +} +``` + +### Success Criteria + +- `pnpm --filter @lightfast/app typecheck` passes. +- Vitest unit tests for both routes (happy path + tampered verifier + expired/missing code + tampered redirect_uri). +- `redis.getdel` exists in `@upstash/redis` v1.x — verify before relying. Fallback: `MULTI: GET + DEL` lua script. + +--- + +## Phase 2: Web bridge — `code-redirect` mode + +### Overview + +Add a third mode to `client-auth-bridge.tsx` that exchanges the JWT for a code and redirects. Update `desktop-auth-client.tsx` to use it. Keep the existing `post`/`redirect` modes intact for any other consumers. + +### Changes Required + +#### 1. `apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.tsx` + +Add `mode: "code-redirect"` discriminant. Props: `{buildExchangeRequest: ({searchParams}) => {state, codeChallenge, redirectUri} | null}`. Effect path: +1. Get Clerk token. +2. POST `{token, state, code_challenge, code_challenge_method: "S256", redirect_uri}` to `/api/desktop/auth/code` with `Authorization: Bearer `. +3. On `{code}`: assign `window.location.href = redirectUri + "?code=" + code + "&state=" + state`, then schedule `window.close()` ~250ms later. The setTimeout matters — `window.close()` is allowed only on windows opened by script and even then is best-effort, so we let the navigation actually flush before attempting close. Close failures are silent (browser permissions); this is a polish-not-correctness step. +4. Errors → `setStatus("error")`. + +#### 2. `apps/app/src/app/(app)/(user)/(pending-not-allowed)/desktop/auth/_components/desktop-auth-client.tsx` + +Replace `mode: "post"` with `mode: "code-redirect"`. Validate `redirect_uri` against `lightfast://auth/callback` / `lightfast-dev://auth/callback`. Replace `validateLoopbackCallback` with `validateAppCallback`. + +```tsx +"use client"; +import { ClientAuthBridge } from "../../../_components/client-auth-bridge"; + +const ALLOWED_REDIRECT_URIS = new Set([ + "lightfast://auth/callback", + "lightfast-dev://auth/callback", +]); + +export function DesktopAuthClient() { + return ( + { + const state = searchParams.get("state"); + const codeChallenge = searchParams.get("code_challenge"); + const method = searchParams.get("code_challenge_method"); + const redirectUri = searchParams.get("redirect_uri"); + if (!state || !codeChallenge || method !== "S256" || !redirectUri) return null; + if (!ALLOWED_REDIRECT_URIS.has(redirectUri)) return null; + return { state, codeChallenge, redirectUri }; + }} + title="Authenticating…" + subtitle="Returning you to the Lightfast desktop app…" + /> + ); +} +``` + +#### 3. `apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.test.tsx` + +Add cases for `code-redirect` mode: success, server 4xx, missing params from `buildExchangeRequest`. + +### Success Criteria + +- `pnpm --filter @lightfast/app typecheck` passes. +- `pnpm --filter @lightfast/app test` passes including new bridge tests. +- Manual: hitting `/desktop/auth?state=…&code_challenge=…&code_challenge_method=S256&redirect_uri=lightfast-dev://auth/callback` while signed in lands at `lightfast-dev://auth/callback?code=…&state=…` (browser will show "Open in Lightfast?" prompt — expected). + +--- + +## Phase 3: Desktop — URL scheme registration + open-url plumbing + +### Overview + +Register the protocol, handle inbound URLs, expose a callback hook for `auth-flow.ts` (Phase 4) to consume. No behavior change to sign-in yet — this just makes incoming `lightfast(-dev)://auth/callback?code=…&state=…` reach a no-op handler. + +### Changes Required + +#### 1. `apps/desktop/forge.config.ts` + +Add `CFBundleURLTypes` to `extendInfo` (macOS Info.plist) and `protocols` to packagerConfig (used by Forge for bookkeeping; macOS still uses Info.plist primarily, Windows/Linux derive from Forge defaults). + +```ts +const URL_SCHEME = "lightfast"; // packaged builds register "lightfast"; dev runtime registers "lightfast-dev" via app.setAsDefaultProtocolClient + +// in packagerConfig.extendInfo: +CFBundleURLTypes: [ + { + CFBundleURLName: BUNDLE_ID, + CFBundleURLSchemes: [URL_SCHEME], + }, +], + +// in packagerConfig (sibling of extendInfo): +protocols: [{ name: "Lightfast", schemes: [URL_SCHEME] }], +``` + +Dev builds (unpackaged) won't run through Forge's packager — for those we register the dev scheme via `app.setAsDefaultProtocolClient("lightfast-dev")` at runtime in `index.ts`. Packaged builds also call `app.setAsDefaultProtocolClient("lightfast")` defensively (cheap, idempotent). + +#### 2. `apps/desktop/src/main/protocol.ts` (NEW) + +```ts +import { app, BrowserWindow } from "electron"; + +export type ProtocolUrlListener = (url: string) => void; +const listeners = new Set(); + +export function getProtocolScheme(): "lightfast" | "lightfast-dev" { + return app.isPackaged ? "lightfast" : "lightfast-dev"; +} + +export function registerProtocolHandler(getWindows: () => BrowserWindow[]): void { + const scheme = getProtocolScheme(); + app.setAsDefaultProtocolClient(scheme); + + const dispatch = (rawUrl: string) => { + if (!rawUrl.startsWith(`${scheme}://`)) return; + for (const listener of listeners) listener(rawUrl); + // Surface the running app on inbound URL + const wins = getWindows(); + const win = wins.find((w) => !w.isDestroyed()); + if (win) { + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + } + }; + + // macOS: open-url fires on first launch and on subsequent dispatches. + app.on("open-url", (event, url) => { + event.preventDefault(); + dispatch(url); + }); + + // Windows/Linux: URL arrives as argv on second-instance. + app.on("second-instance", (_event, argv) => { + const url = argv.find((a) => a.startsWith(`${scheme}://`)); + if (url) dispatch(url); + }); + + // First-launch on Windows/Linux: URL is in process.argv. + if (process.platform !== "darwin") { + const url = process.argv.find((a) => a.startsWith(`${scheme}://`)); + if (url) { + // Defer so listeners can register first. + app.whenReady().then(() => dispatch(url)); + } + } +} + +export function onProtocolUrl(listener: ProtocolUrlListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} +``` + +#### 3. `apps/desktop/src/main/index.ts` + +In `app.whenReady().then(...)`, call `registerProtocolHandler(() => BrowserWindow.getAllWindows())` once. + +#### 4. `apps/desktop/src/main/__tests__/protocol.test.ts` (NEW) + +- `open-url` event with matching scheme calls all registered listeners with the URL. +- `open-url` with a foreign scheme is ignored. +- `second-instance` extracts the URL from argv. +- Multiple listeners all fire. + +### Success Criteria + +- `pnpm --filter @lightfast/desktop typecheck` passes. +- `pnpm --filter @lightfast/desktop test` passes. +- Manual: in dev, `open lightfast-dev://test` from another terminal logs the URL through a temporary `onProtocolUrl(console.log)` (remove before commit). + +--- + +## Phase 4: Desktop — Sign-in flow cutover + +### Overview + +Rewrite `auth-flow.ts` around PKCE + URL scheme, deleting the loopback server and CORS plumbing. Rename `LIGHTFAST_DESKTOP_AUTH_NO_OPEN` to `LIGHTFAST_DESKTOP_AGENT_MODE` and broaden it: skip `shell.openExternal` AND emit structured stdout JSON for the signin URL. Adapt the existing `NO_OPEN` test block — do not delete it. + +### Changes Required + +#### 1. `apps/desktop/src/main/auth-flow.ts` (REWRITE) + +```ts +import { createHash, randomBytes } from "node:crypto"; +import * as Sentry from "@sentry/electron/main"; +import { shell } from "electron"; +import { z } from "zod"; +import { getToken, setToken } from "./auth-store"; +import { getProtocolScheme, onProtocolUrl } from "./protocol"; + +const DEFAULT_SIGNIN_TIMEOUT_MS = 5 * 60_000; + +function getSigninTimeoutMs(): number { + const raw = process.env.LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS; + if (!raw) return DEFAULT_SIGNIN_TIMEOUT_MS; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_SIGNIN_TIMEOUT_MS; +} + +function isAgentMode(): boolean { + return process.env.LIGHTFAST_DESKTOP_AGENT_MODE === "1"; +} + +type AuthEvent = + | { event: "auth_already_signed_in" } + | { event: "auth_signin_url"; url: string } + | { event: "auth_signed_in" } + | { event: "auth_signin_failed"; reason: string }; + +function emitAgentEvent(payload: AuthEvent): void { + if (!isAgentMode()) return; + process.stdout.write(`${JSON.stringify(payload)}\n`); +} + +function getApiOrigin(): string { + return ( + process.env.LIGHTFAST_API_URL ?? + (process.env.NODE_ENV === "production" + ? "https://lightfast.ai" + : "http://localhost:3024") + ); +} + +const callbackSchema = z.object({ + code: z.string().min(32).max(128), + state: z.string().min(16).max(256), +}); + +const exchangeResponseSchema = z.object({ token: z.string().min(1) }); + +let inflight: Promise | null = null; +let pendingSigninUrl: string | null = null; +const urlListeners = new Set<(url: string | null) => void>(); + +export function getPendingSigninUrl(): string | null { + return pendingSigninUrl; +} + +export function onPendingSigninUrl(listener: (url: string | null) => void): () => void { + urlListeners.add(listener); + return () => urlListeners.delete(listener); +} + +function setPendingSigninUrl(url: string | null): void { + pendingSigninUrl = url; + for (const listener of urlListeners) listener(url); +} + +export function beginSignIn(): Promise { + if (inflight) return inflight; + inflight = (async () => { + try { + return await runSignIn(); + } finally { + inflight = null; + setPendingSigninUrl(null); + } + })(); + return inflight; +} + +async function runSignIn(): Promise { + const state = randomBytes(32).toString("base64url"); + const codeVerifier = randomBytes(32).toString("base64url"); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const redirectUri = `${getProtocolScheme()}://auth/callback`; + + const apiOrigin = getApiOrigin(); + const signinUrl = new URL("/desktop/auth", apiOrigin); + signinUrl.searchParams.set("state", state); + signinUrl.searchParams.set("code_challenge", codeChallenge); + signinUrl.searchParams.set("code_challenge_method", "S256"); + signinUrl.searchParams.set("redirect_uri", redirectUri); + + return new Promise((resolve) => { + let settled = false; + const settle = (token: string | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + unsubscribe(); + resolve(token); + }; + const timer = setTimeout(() => { + Sentry.captureMessage("auth-flow: sign-in timeout", { + level: "warning", + tags: { scope: "auth-flow.timeout" }, + }); + emitAgentEvent({ event: "auth_signin_failed", reason: "timeout" }); + settle(null); + }, getSigninTimeoutMs()); + + const unsubscribe = onProtocolUrl(async (rawUrl) => { + try { + const url = new URL(rawUrl); + if (url.pathname !== "//auth/callback" && url.host + url.pathname !== "auth/callback") { + // URL parser oddities for custom schemes — accept either form. + if (`${url.host}${url.pathname}` !== "auth/callback") return; + } + const parsed = callbackSchema.safeParse({ + code: url.searchParams.get("code"), + state: url.searchParams.get("state"), + }); + if (!parsed.success) return; + if (parsed.data.state !== state) { + Sentry.captureMessage("auth-flow: state mismatch", { + level: "warning", + tags: { scope: "auth-flow.state_mismatch" }, + }); + return; // ignore foreign callbacks + } + const token = await exchangeCode(apiOrigin, parsed.data.code, codeVerifier); + if (!token) { + emitAgentEvent({ event: "auth_signin_failed", reason: "exchange_failed" }); + settle(null); + return; + } + const persisted = setToken(token); + if (!persisted) { + Sentry.captureException(new Error("auth-flow: persist failed"), { + tags: { scope: "auth-flow.persist_failed" }, + }); + emitAgentEvent({ event: "auth_signin_failed", reason: "persist_failed" }); + settle(null); + return; + } + emitAgentEvent({ event: "auth_signed_in" }); + settle(token); + } catch (error) { + console.error("[auth-flow] callback handler error", error); + Sentry.captureException(error, { tags: { scope: "auth-flow.handler_error" } }); + emitAgentEvent({ event: "auth_signin_failed", reason: "handler_error" }); + settle(null); + } + }); + + setPendingSigninUrl(signinUrl.toString()); + + if (isAgentMode()) { + // Agent harnesses (e.g. Claude Code via agent-browser) parse a single + // structured line off stdout instead of calling into the system browser. + // Dia / default browser is never invoked. Pair with AGENT_BROWSER_HEADED=true + // on the agent side — headless Chromium silently drops custom-scheme + // navigations (validated 2026-04-25 spike). + emitAgentEvent({ event: "auth_signin_url", url: signinUrl.toString() }); + return; + } + + shell.openExternal(signinUrl.toString()).catch((error) => { + console.error("[auth-flow] shell.openExternal failed", error); + Sentry.captureException(error, { tags: { scope: "auth-flow.open_external" } }); + settle(null); + }); + }); +} + +// Called from index.ts on app-ready. Idempotent — only fires when AGENT_MODE=1. +// If a token is already persisted, emits auth_already_signed_in and exits. +// Otherwise auto-begins sign-in so an agent harness doesn't need to drive +// renderer IPC over CDP. +export function maybeAutoBeginSignIn(): void { + if (!isAgentMode()) return; + if (getToken()) { + emitAgentEvent({ event: "auth_already_signed_in" }); + return; + } + // Fire-and-forget: events are emitted from inside beginSignIn / its handlers. + void beginSignIn(); +} + +async function exchangeCode( + apiOrigin: string, + code: string, + codeVerifier: string +): Promise { + try { + const response = await fetch(`${apiOrigin}/api/desktop/auth/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, code_verifier: codeVerifier }), + }); + if (!response.ok) { + Sentry.captureMessage("auth-flow: exchange non-ok", { + level: "warning", + tags: { scope: "auth-flow.exchange_non_ok", status: String(response.status) }, + }); + return null; + } + const json = exchangeResponseSchema.safeParse(await response.json()); + return json.success ? json.data.token : null; + } catch (error) { + Sentry.captureException(error, { tags: { scope: "auth-flow.exchange_network" } }); + return null; + } +} +``` + +Note on URL parsing: Node's `URL` parses custom-scheme URLs inconsistently across platforms (`lightfast://auth/callback` may surface `host=auth, pathname=/callback` on one and `host="", pathname=//auth/callback` on another). The defensive double-check above handles both. Worth pinning down with platform tests. + +#### 2. `apps/desktop/src/main/index.ts` + +Add IPC channel that returns the pending signin URL (renderer can subscribe for in-app status), and call `maybeAutoBeginSignIn()` once the app is ready and the renderer has been registered: + +```ts +import { maybeAutoBeginSignIn, getPendingSigninUrl, onPendingSigninUrl } from "./auth-flow"; + +ipcMain.handle(IpcChannels.authPendingSigninUrl, () => getPendingSigninUrl()); +onPendingSigninUrl((url) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IpcChannels.authPendingSigninUrlChanged, url); + } +}); + +// Agent-mode auto-trigger. No-op outside agent mode. Idempotent — emits +// auth_already_signed_in if a token is already persisted. +maybeAutoBeginSignIn(); +``` + +#### 3. `apps/desktop/src/shared/ipc.ts` + +Add `authPendingSigninUrl` and `authPendingSigninUrlChanged` channels. Extend `LightfastBridge.auth` with `pendingSigninUrl` getter + `onPendingSigninUrlChanged` subscriber. + +#### 4. `apps/desktop/src/preload/preload.ts` + +Wire the new channels into the bridge. + +#### 5. ~~Renderer dev hook~~ — DROPPED + +Initial draft proposed a hidden `` in the renderer, read by agent-browser via DOM. **Removed**: the structured stdout line in `auth-flow.ts` already gives the agent a stable, low-coupling surface — no DOM, no CDP attach to the renderer, no Vite tree-shaking concerns. (See Improvement Log.) + +#### 6. `apps/desktop/src/main/__tests__/auth-flow.test.ts` (REWRITE) + +Drop loopback HTTP tests. **Adapt** (do not delete) the `LIGHTFAST_DESKTOP_AUTH_NO_OPEN` test block — rename to `LIGHTFAST_DESKTOP_AGENT_MODE` and broaden coverage. Add: + +- `beginSignIn` composes signin URL with state + S256 code_challenge + redirect_uri. +- Inbound `lightfast(-dev)://auth/callback?code=…&state=` triggers exchange POST with the verifier matching the challenge, persists token, emits `auth_signed_in`. +- State mismatch is ignored (no event emitted; the in-flight sign-in stays pending — same as today). +- Exchange 4xx returns `null` and emits `auth_signin_failed` with `reason: "exchange_failed"`. +- Persist failure emits `auth_signin_failed` with `reason: "persist_failed"`. +- Handler exception emits `auth_signin_failed` with `reason: "handler_error"`. +- Timeout: `auth_signin_failed` with `reason: "timeout"`. Configurable via `LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS` — assert a 100ms override fires within ~150ms. +- `inflight` prevents double-flight. +- Agent mode: `shell.openExternal` is NOT called; stdout receives exactly one `{"event":"auth_signin_url","url":"..."}` line. +- Non-agent mode: `shell.openExternal` IS called; stdout receives no JSON line. +- `maybeAutoBeginSignIn`: + - Outside AGENT_MODE → no-op (no events, `beginSignIn` not called). + - AGENT_MODE + token already in store → emits `auth_already_signed_in` exactly once and does NOT call `beginSignIn`. + - AGENT_MODE + no token → calls `beginSignIn` (event sequence then matches the auto-trigger flow above). +- Event-grammar property: every `auth_signin_url` is followed by exactly one terminal event (`auth_signed_in` OR `auth_signin_failed`) per in-flight sign-in. + +Test infrastructure: mock `protocol.onProtocolUrl` to invoke handlers directly; mock `fetch` for the exchange endpoint; mock `shell.openExternal`; spy on `process.stdout.write` with a JSON-line parser helper. For the auto-trigger tests, mock `getToken()` from `./auth-store`. + +### Success Criteria + +- `pnpm --filter @lightfast/desktop typecheck` passes. +- `pnpm --filter @lightfast/desktop test` passes (rewritten suite). +- `rg "createServer|loopback|MAX_BODY_BYTES|applyCors" apps/desktop/src/` returns zero hits. (Note: `LIGHTFAST_DESKTOP_AUTH_NO_OPEN` is renamed, not removed — `LIGHTFAST_DESKTOP_AGENT_MODE` should appear.) +- Manual (real-user path): `pnpm dev:full` + `pnpm --filter @lightfast/desktop dev`, click "Sign in" in renderer, complete Clerk in Dia/system browser, `lightfast-dev://` redirects, desktop receives, renderer flips to signed-in. +- Manual (agent path): see Phase 4 / "Desired End State". Must use `LIGHTFAST_DESKTOP_AGENT_MODE=1` and `AGENT_BROWSER_HEADED=true`. Verify with `pgrep -l Dia` before/after that no Dia process was spawned. + +--- + +--- + +## Phase 5: Agent runbook — `lightfast-desktop-signin` skill + +### Overview + +A short SKILL.md that documents the agent flow so future Claude Code sessions don't have to re-derive it. Mirrors the existing `.agents/skills/lightfast-clerk/SKILL.md` shape. + +### Changes Required + +#### 1. `.agents/skills/lightfast-desktop-signin/SKILL.md` (NEW) + +Document: +- **When to use**: agent needs the desktop app signed in (e.g., to drive tRPC procedures that require an authed desktop session, run E2E flows, or test signed-in renderer surfaces). +- **Preconditions**: dev mesh up on `:3024`, no `pk_live_*` Clerk keys, agent-browser installed and `AGENT_BROWSER_HEADED=true` in the environment, desktop app must already be running before the redirect fires (cold-launch via OS dispatch unreliable in dev). +- **Env vars**: + - `LIGHTFAST_DESKTOP_AGENT_MODE=1` — required. Skips `shell.openExternal`, emits stdout events. + - `AGENT_BROWSER_HEADED=true` — required. Headless silently drops `lightfast-dev://` navigations. + - `LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS=30000` — recommended for CI/agent runs. +- **Stdout event grammar** (one JSON object per line): `auth_already_signed_in`, `auth_signin_url`, `auth_signed_in`, `auth_signin_failed{reason}`. Reproduced verbatim from the table in this plan's "Agent automation end state". +- **The full script** (lifted from "Desired End State"). Wrap as a copy-paste snippet. +- **Hygiene**: `agent-browser close --all` between runs to drop stale Clerk session cookies if a fresh sign-in is needed; otherwise the daemon profile retains them and the second run will short-circuit through Clerk silently. +- **Failure modes**: + - `auth_signin_failed{reason:"timeout"}` → did the agent forget `AGENT_BROWSER_HEADED=true`? The most common cause. + - `auth_signin_failed{reason:"exchange_failed"}` → check Redis is reachable from the API; the code may have expired (30s TTL). + - No event at all within 30s → desktop didn't start in agent mode, OR the renderer isn't ready and you're on a build that requires renderer attach. Check stdout for the bootstrap line. +- **Refusal conditions** (mirror lightfast-clerk): refuse against non-localhost `LIGHTFAST_API_URL` or `pk_live_*` Clerk keys. + +### Success Criteria + +- `.agents/skills/lightfast-desktop-signin/SKILL.md` exists and is discoverable by Claude Code's skill index. +- A fresh Claude Code session, given only the prompt "sign the desktop app in for me", finds the skill, runs the documented flow, and lands at `auth_signed_in` without re-deriving any of the env vars or event grammar. + +--- + +## Risks & Open Questions + +1. **agent-browser headless mode silently drops custom-scheme navigations** (validated by 2026-04-25 spike — see Improvement Log). When `agent-browser open ` runs in default (headless) mode, the page completes Clerk and redirects to `lightfast-dev://...`, but Chrome for Testing's headless mode discards the navigation with no prompt, no error, and no fallback browser hand-off — the desktop's `app.on('open-url')` never fires and the agent has no signal that anything went wrong. **Mitigation**: every doc, test, and example must mandate `AGENT_BROWSER_HEADED=true`. Headed mode dispatches cleanly with no dialog. This must be loud in the README, the `LIGHTFAST_DESKTOP_AGENT_MODE` flag's stdout banner ("requires AGENT_BROWSER_HEADED=true"), and any agent skill that drives the flow. +2. **Cold-launch via OS dispatch is unreliable in dev (unpackaged)** (also from spike). Unpackaged Electron's `app.setAsDefaultProtocolClient` registers against `com.github.electron`, not Lightfast's bundle id, so LaunchServices relaunches bare Electron without our entrypoint. **Precondition**: the desktop app must already be running before the agent triggers the redirect. Acceptable — the loopback flow has the same precondition today. Document explicitly in Phase 4 manual test. Packaged builds register correctly because Forge writes the right `CFBundleURLTypes` and bundle id. +3. **`URL` parsing of custom schemes is platform-quirky.** Defensive double-check in `auth-flow.ts`; needs a smoke test on all three platforms before declaring done. Worst-case fallback: hand-parse with a regex (`^lightfast(-dev)?://([^?]+)\?(.*)$`). +4. **First-launch URL on Windows/Linux** arrives in `process.argv`, but `app.whenReady()` may resolve before `onProtocolUrl` listeners are registered. The `protocol.ts` deferral above handles this; verify with manual test before shipping. +5. **`redis.getdel` availability.** `@upstash/redis` v1.34+ has it. Check before relying; pinned version in `package.json` should be upgraded if older. +6. **Clerk JWT in Redis briefly.** ~30s TTL, Upstash at-rest encryption, TLS in transit. Acceptable for a single-vendor desktop. Document in `apps/app/src/app/api/desktop/auth/lib/code-store.ts` header comment. +7. **`verify-jwt.ts` location.** Currently under `apps/app/src/app/api/cli/lib/`. Phase 1 imports it from there; recommend extracting to `apps/app/src/lib/auth/verify-clerk-jwt.ts` as a small follow-up (out-of-scope for this plan to keep diff focused). +8. **Browser "Open Lightfast?" prompt on first dispatch per profile** — unavoidable for real users in real browsers. *Not* an issue in agent-browser headed mode (spike confirmed no prompt rendered). Document in user-facing release notes. +9. **Multiple in-flight desktop sign-ins on the same machine** (rare) — current `inflight` singleton keeps one at a time, same as today. URL scheme dispatch goes to whichever app instance has single-instance-lock; this matches today's loopback ordering. + +## Implementation Order Summary + +| Phase | Files added | Files changed | Files deleted | Lines net | +|---|---|---|---|---| +| 1 | 3 (code-store + 2 routes) | 0 | 0 | +120 | +| 2 | 0 | 3 (bridge + desktop-auth-client + tests) | 0 | +60 | +| 3 | 2 (protocol.ts + tests) | 2 (forge.config.ts + index.ts) | 0 | +90 | +| 4 | 0 | 4 (auth-flow.ts + ipc.ts + index.ts + tests) | 0 | **−160** (loopback gone, agent-mode events + auto-trigger added) | +| 5 | 1 (SKILL.md) | 0 | 0 | +60 (docs) | +| **Total** | **6** | **9** | **0** | **~+170 LoC (incl. docs), much simpler architecture** | + +Each phase ships as its own PR; Phase 4 is the breaking cutover; Phase 5 can land in parallel with or after Phase 4. + +--- + +## Improvement Log + +### 2026-04-25 — Adversarial review + spike + +**User-stated end state**: (1) Dia browser must never open during automated sign-in; (2) Claude Code must be able to log in smoothly via `agent-browser`. + +**Findings against original draft**: + +1. **Critical — plan kept `shell.openExternal` and deleted the only escape hatch.** The original draft removed `LIGHTFAST_DESKTOP_AUTH_NO_OPEN` outright. With `shell.openExternal` still firing on every `beginSignIn`, Dia would still open in dev — directly contradicting end-state #1. +2. **Critical — "single agent-browser session" claim was unvalidated.** The plan assumed agent-browser's Chromium would dispatch `lightfast-dev://...` to the OS handler. Headless Chromium has historically been hostile to external schemes. Claim spiked (see below). +3. **High — renderer DOM hook was the wrong agent surface.** Putting the signin URL in a hidden `` would have re-introduced a CDP-attach-to-Electron step. Replaced with a single structured stdout JSON line emitted from the main process — agent reads stdout, no DOM, no CDP. +4. **High — `LIGHTFAST_DESKTOP_AUTH_NO_OPEN` deserves to live.** Renamed to `LIGHTFAST_DESKTOP_AGENT_MODE` (clearer intent) and broadened: skip `shell.openExternal` AND emit stdout JSON. Existing tests adapted, not deleted. + +**Spike (2026-04-25, isolated worktree)**: built a 60-LoC Electron handler registering `lightfast-spike://` + an HTML trigger page that fires `window.location.href = "lightfast-spike://..."`. Drove with agent-browser in default and headed modes, anchor click and JS-driven navigation, cold start (handler not running) and warm start (handler running). + +| Scenario | Result | +| --- | --- | +| `open lightfast-spike://...` from terminal (sanity) | PASS — handler received URL | +| agent-browser default (headless) → JS navigation | **FAIL silently** — no prompt, no error, no Dia hand-off, no event | +| agent-browser default (headless) → `` click | **FAIL silently** | +| `AGENT_BROWSER_HEADED=true` agent-browser → JS navigation | **PASS** — handler received URL, no prompt rendered | +| `AGENT_BROWSER_HEADED=true` agent-browser → `` click | **PASS** — handler received URL | +| Cold start (unpackaged Electron, handler not running) | **FAIL** — LaunchServices relaunched bare Electron without our entrypoint | +| Dia process state across all runs | **Unchanged** — Chromium did not hand off to system browser as a fallback | + +**Verdict**: PARTIAL — the plan's automation story is viable, but only under two preconditions: + +- `AGENT_BROWSER_HEADED=true` is mandatory. Headless silently drops the navigation with zero diagnostic signal — a hellish debugging surface if undocumented. +- The desktop must already be running before the redirect fires. Cold-launch routing is unreliable in dev (unpackaged); packaged builds resolve this for prod. + +Both preconditions match how the loopback flow already operates today (desktop must be running; agent-browser is already used in headed mode for E1 in the research doc), so neither is a regression — just non-obvious. + +**Edits made to the plan**: + +- Overview: added an "Agent automation (Claude Code) end state" subsection with explicit single-session flow. +- Current State Analysis: kept the `LIGHTFAST_DESKTOP_AUTH_NO_OPEN` note but framed it as "renamed to `LIGHTFAST_DESKTOP_AGENT_MODE`, not deleted". +- Desired End State: replaced "delete the flag" with "rename and broaden". Replaced single-line manual test with the explicit script (env vars + `pgrep -l Dia` check). +- Phase 4 / Implementation: added `isAgentMode()` helper, added the structured stdout `process.stdout.write` block before `shell.openExternal`, dropped the renderer DOM hook entirely. +- Phase 4 / Tests: adapted (rather than deleted) the `NO_OPEN` test block; added agent-mode and non-agent-mode assertions about stdout vs `shell.openExternal`. +- Risks: promoted "headless silently drops" and "cold-launch unreliable" to risks #1 and #2. Updated the "Open Lightfast?" prompt risk to note the spike confirmed no prompt in agent-browser headed mode. +- Success Criteria: split into "real-user path" and "agent path" with explicit `LIGHTFAST_DESKTOP_AGENT_MODE=1` + `AGENT_BROWSER_HEADED=true` + `pgrep -l Dia` verification. + +**Spike worktree**: cleaned up after evidence was captured. (Was at `.claude/worktrees/agent-aaf6279eee3a26e2c`, branch `worktree-agent-aaf6279eee3a26e2c` — both removed.) + +### 2026-04-25 — Quality-of-life pass + +After the spike confirmed the architecture, did a second pass focused on Claude Code's day-to-day ergonomics. Added: + +1. **`maybeAutoBeginSignIn()` on app-ready in agent mode.** Removes the only remaining ceremony — no more "click the renderer's Sign in button via CDP". Single command in (`pnpm desktop dev` with the right env), single URL out on stdout. +2. **Symmetric stdout event grammar.** `auth_already_signed_in` / `auth_signin_url` / `auth_signed_in` / `auth_signin_failed{reason}`. One JSON object per line, parseable with `jq`. Replaces "tail logs and hope state flips" with a deterministic completion signal. Failure paths name the reason (`timeout`, `exchange_failed`, `persist_failed`, `handler_error`) so triage doesn't need source dives. +3. **Idempotent `auth_already_signed_in` short-circuit.** Re-running the flow on an authed install is a no-op event, not a redundant sign-in. +4. **Configurable `LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS`.** Default unchanged at 5 min; agent harnesses set ~30 s for fast CI feedback. Test asserts a 100 ms override fires within ~150 ms. +5. **`window.close()` after redirect** (Phase 2). Lifted out of "What We're NOT Doing" — the cost is ~3 lines and CI screenshot artifacts are noticeably cleaner. Best-effort by browser policy; non-blocking. +6. **Phase 5 — `.agents/skills/lightfast-desktop-signin/SKILL.md` runbook.** Mirrors the existing `lightfast-clerk` skill. Documents the env-var contract, the JSON event grammar, the `AGENT_BROWSER_HEADED=true` precondition (with an explicit failure-mode call-out — "did you forget HEADED?" is the #1 cause of `timeout`), and `agent-browser close --all` daemon hygiene. Discoverability so future Claude Code sessions don't re-derive any of this. + +Net effect on the agent flow: the manual test in the original draft was four lines, two of which were vague (`grep -oE` for the URL, "trigger sign-in via existing IPC" with no concrete recipe). The current manual test is a self-contained shell snippet with a strict event grammar that any agent harness can execute and assert against. The plan now actually delivers on the user's two stated end-states: (1) Dia is never opened, (2) Claude Code can log in smoothly with a single command. diff --git a/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md new file mode 100644 index 000000000..c75ebaeb0 --- /dev/null +++ b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md @@ -0,0 +1,585 @@ +--- +date: 2026-05-06 +owner: jp@jeevanpillay.com +branch: fix/coderabbit-pr614-followup +pr: https://github.com/lightfastai/lightfast/pull/627 +based_on: + - thoughts/shared/plans/2026-04-25-desktop-auth-url-scheme-pkce.md + - thoughts/shared/plans/2026-04-24-coderabbit-pr614-fixes.md +status: planned +--- + +# PR 627 Merge Readiness Plan + +## Overview + +Drive PR 627 (`fix(desktop): CodeRabbit PR #614 followup + PKCE URL scheme auth`) to a green-CI, conflict-free, reviewer-approvable state. The feature work landed and was live-verified on 2026-04-25; in the five days since, `main` has progressed (Portless / `runtime-config` / `app-url` introduction), CI has gone red on Biome (`Found 10 errors`), and a CodeRabbit review surfaced two Critical and four Major blockers that were never addressed. This plan resolves all of those without expanding feature scope. + +## Current State Analysis + +- **Branch state**: `f89a3023c chore(deps): reconcile pnpm-lock catalog entries after merge` — 5,047 / -663 LOC across 34 files. +- **Mergeable**: `CONFLICTING` against `main`. 9 conflicted files; conflict markers run from 3 to 12 markers per file. +- **CI**: `Quality` (CI) and `Quality` (Core CI) both FAILED on 2026-05-03 — `Found 10 errors` (Biome). `CI Success` and `Core CI Success` therefore failed too. `Build`, `Test`, `Typecheck + package (unsigned)`, CodeQL, all three Vercel previews, and CodeRabbit are all green. +- **Reviews**: no human approval yet. CodeRabbit posted 8 review comments on 2026-05-03 with 2 Critical, 4 Major (2 explicitly tagged `[blocker]`), 1 Major-quick-win, 1 Minor. +- **Live verification on 2026-04-25**: full UI-driven happy path + agent-mode + idempotent re-run all passed (~14s end-to-end). That verification is now stale w.r.t. `main`'s URL/origin changes (`apps/desktop/src/main/app-url.ts` introduced by `f51668a81 Decouple local app URLs from related-projects` on 2026-05-04). + +### Conflict inventory (vs `origin/main`) + +| File | Hunks | Resolution direction | +| --- | --- | --- | +| `apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.tsx` | 4 | Keep PR-branch (`code-redirect` mode, `buildExchangeRequest`); fold in any main-side error-handling deltas. | +| `apps/desktop/forge.config.ts` | 1 | Keep PR-branch (`CFBundleURLTypes`); fold in any main-side `extendInfo` deltas. | +| `apps/desktop/src/main/auth-flow.ts` | 2 | Keep PR-branch entirely (loopback HTTP server is **deleted** by design). Adopt `createAppUrl()` from main when composing the sign-in URL. | +| `apps/desktop/src/main/auth-store.ts` | 2 | Combine: keep PR-branch's atomicity (`setToken` returns `boolean`, delete-before-clear) + main-side changes. | +| `apps/desktop/src/main/index.ts` | 2 | Combine: PR-branch adds `auth-flow` exports + `registerProtocolHandler` + `auth-focus-gate`; main adds `openAppOrigin`/`getRuntimeConfig`. Both needed. | +| `apps/desktop/src/renderer/src/react/app-shell.tsx` | 1 | Combine. | +| `apps/desktop/src/shared/ipc.ts` | 1 | Combine new IPC channels from both sides. | +| `pnpm-lock.yaml` | — | Re-resolve via `pnpm install`. | +| `pnpm-workspace.yaml` | 1 | Combine catalog entries. | + +### CI quality inventory (10 Biome errors, all in PR 627 code) + +| # | File:Line | Rule | Auto-fix safe? | +| --- | --- | --- | --- | +| 1 | `apps/app/src/app/(app)/(user)/(pending-not-allowed)/desktop/auth/_components/desktop-auth-client.tsx:18:13` | `lint/complexity/useSimplifiedLogicExpression` | Yes | +| 2 | `apps/app/src/app/api/desktop/auth/code/route.test.ts` | format | Yes | +| 3 | `apps/app/src/app/api/desktop/auth/exchange/route.test.ts` | format | Yes | +| 4 | `apps/app/src/app/api/desktop/auth/lib/code-store.ts:11:8` | `assist/source/useSortedInterfaceMembers` | Yes | +| 5 | `apps/desktop/src/main/__tests__/auth-flow.test.ts:83:1` | `assist/source/useSortedInterfaceMembers` | Yes | +| 6 | `apps/desktop/src/main/__tests__/auth-flow.test.ts:144:15` | `lint/complexity/noUselessContinue` | Yes | +| 7 | `apps/desktop/src/main/__tests__/auth-flow.test.ts:452:6` | `lint/style/useNumericSeparators` | Yes | +| 8 | `apps/desktop/src/main/__tests__/auth-flow.test.ts` | format | Yes | +| 9 | `apps/desktop/src/main/__tests__/protocol.test.ts` | format | Yes | +| 10 | `apps/desktop/src/main/windows/factory.ts:16:20` | `lint/correctness/noGlobalDirnameFilename` | **No** — Biome's safe-fix rewrites `__dirname` → `import.meta.dirname`, which is `undefined` in the Vite-emitted CJS bundle. Needs an `// biome-ignore` directive instead. | + +### CodeRabbit blocker inventory + +| Sev | File:Line | Issue | +| --- | --- | --- | +| **Critical (blocker)** | `apps/desktop/src/main/protocol.ts` (call site `index.ts:354`) | `open-url` listener registered after `app.whenReady()`. macOS cold-start URLs delivered before `whenReady` are lost. | +| **Critical** | `apps/desktop/src/main/auth-store.ts:3` (and 4 other call sites) | Direct `@sentry/electron/main` imports violate vendor abstraction rule (`CLAUDE.md`). | +| **Major (blocker)** | `apps/desktop/src/main/auth-flow.ts:203` | `onProtocolUrl` callback never re-checks `settled` after async `exchangeCode` await. Late/duplicate callback can call `setToken()` and emit a second terminal event. | +| **Major (blocker)** | `apps/desktop/src/main/auth-store.ts:52` | `rmSync()` calls in `load()` not wrapped in try/catch. Filesystem errors crash startup (versus `clearPersisted()` which already wraps). | +| **Major** | `apps/desktop/src/main/__tests__/protocol.test.ts:125` | Test only verifies one-arg `setAsDefaultProtocolClient`. Windows dev requires three-arg form `(scheme, process.execPath, [path.resolve(process.argv[1])])`. | +| **Major** | `apps/app/src/app/api/desktop/auth/code/route.ts:31` | `verifyCliJwt(req)` parses `"Bearer "` (exact, single space). Handler then re-parses `Authorization` with `/^Bearer\s+/i`. Auth and persistence disagree on normalization → token stored may differ from token authenticated. | +| **Major** (= Biome #1) | `apps/app/src/app/(app)/(user)/(pending-not-allowed)/desktop/auth/_components/desktop-auth-client.tsx:24` | Biome `useSimplifiedLogicExpression`. | +| **Minor** (= Biome #4) | `apps/app/src/app/api/desktop/auth/lib/code-store.ts:17` | `useSortedInterfaceMembers` on `CodeRecord`. | + +### Key Discoveries + +- `apps/desktop/src/main/sentry.ts` already imports `@sentry/electron/main` directly today (pre-existing on `main`). PR 627 adds two more direct imports. The repo also has 3 next.js sites importing `@sentry/nextjs` directly (`apps/{app,www,platform}/src/instrumentation.ts:7`) — same rule violation, different surface. Closing this comment honestly means migrating all 8 call sites; migrating only desktop creates two patterns. **Decision (this plan): expand Phase 2 to cover all 8 direct `@sentry/*` imports.** +- `vendor/observability/package.json` already exists and exports `./sentry` (Hono services, on `@sentry/core`) using **selective named re-exports** — not `export *`. New wrappers must follow the same pattern. The package already lists `@sentry/core` in catalog; `@sentry/electron`, `@sentry/browser`, `@sentry/nextjs` need to be added. +- `apps/desktop/package.json` does **not** currently list `@vendor/observability` as a workspace dep. Phase 2 must add it; without it TypeScript module resolution fails even if `pnpm install` succeeds. +- `apps/app/src/app/api/cli/lib/verify-jwt.ts:5-7` returns only `{ userId }`. Three call sites use `session.userId` only (`code/route.ts`, `cli/setup/route.ts`, `cli/login/route.ts`) — adding `jwt` is purely additive. The bearer-parser divergence is real: `verify-jwt.ts:13` strips with literal `"Bearer "` replace, `code/route.ts:31` uses `/^Bearer\s+/i`. The fix lets `code/route.ts` consume `session.jwt` and delete its own parser. +- `apps/desktop/src/main/auth-flow.ts:137-146` already has a `settled` guard that calls `unsubscribe()` synchronously inside `settle()`, so `setToken` cannot be double-called. The real race the `callbackInFlight` fix addresses is **two `open-url` events arriving while `exchangeCode` is in flight** — both sneak past `settled === false`, both call `exchangeCode` with the same single-use code, the second hits a 410 and emits Sentry noise. The fix is correct; the original CodeRabbit-derived rationale ("setToken called twice") is wrong. +- `apps/desktop/src/main/protocol.ts:42-45` already registers `open-url` *inside* `registerProtocolHandler()` synchronously. The bug is the *call site*: `index.ts:354` invokes `registerProtocolHandler` from inside `app.whenReady().then(...)`. Fix is moving the call to module top-level (or before `whenReady`) — the function itself is correct. +- `apps/desktop/src/main/protocol.ts:20` calls `app.setAsDefaultProtocolClient(scheme)` with one arg only. Windows-dev (`process.defaultApp === true`) requires the three-arg form. +- `apps/desktop/src/main/windows/factory.ts:14-18` legitimately needs `__dirname` because `vite.main.config.ts:9-11` explicitly emits CJS (`formats: ["cjs"]`). The Biome rule's safe-fix is wrong for this file. A targeted `// biome-ignore lint/correctness/noGlobalDirnameFilename: ...` directive is the correct resolution. +- `main` introduced `apps/desktop/src/main/app-url.ts` (`createAppUrl(path)` and `openAppOrigin()`) and `runtime-config.ts`. PR 627's `auth-flow.ts` builds a sign-in URL by hand. Adopting `createAppUrl("/desktop/auth")` is **a behavioral change** (origin source moves from inline construction to `getRuntimeConfig().appOrigin`), not a no-op refactor. Verify in Phase 5. + +## Desired End State + +- `gh pr view 627 --json mergeable` returns `MERGEABLE`. +- `gh pr view 627 --json statusCheckRollup` shows all checks `SUCCESS` (Quality, CI Success, Core CI Success, Test, Build, Typecheck + package, CodeQL, Vercel × 3, CodeRabbit). +- All 8 CodeRabbit review comments either resolved by code change or acknowledged with a reply explaining the deliberate non-fix. +- No new direct `@sentry/electron/main` imports remain in `apps/desktop/`. All 5 call sites use `@vendor/observability/sentry-electron-main`. +- Live happy path (UI-driven) re-verified end-to-end against current `main`-merged branch with the existing `lightfast-desktop-signin` skill. +- Agent-mode + idempotent re-run paths re-verified (one round each). +- No expansion of behavioral scope: this PR's surface is unchanged (URL scheme, PKCE flow, code-redirect bridge mode, agent-mode stdout grammar, Electron 41 / Vite 8 deps). + +## What We're NOT Doing + +- Migrating any non-desktop Sentry imports (next/api/platform sites stay on their existing path; the rule violation surfaced is desktop-only). +- Adding new test coverage beyond what's needed to validate fixes for the CodeRabbit blockers (existing 1,577 LoC of new tests stays). +- Fixing pre-existing `__dirname` lint elsewhere (only `apps/desktop/src/main/windows/factory.ts:16`). +- Migrating Vite 8 main bundle output to ESM to avoid the `__dirname` issue. +- Touching `LIGHTFAST_DESKTOP_AGENT_MODE`, the agent stdout event grammar, the Redis schema, or the Clerk JWT template — all behavioral surface holds. +- Re-running CodeQL or Vercel deploy verification beyond what CI does automatically. +- Backporting any fix to PR #614 (already merged). + +## Implementation Approach + +Five phases, each ending with a clean phase-boundary halt for review. Phase 1 produces no behavioral change (pure conflict resolution + dependency reconciliation). Phases 2–4 close the open review feedback in order of risk (vendor abstraction first since it touches 5 files; then per-file blockers; then trivial Biome fixups). Phase 5 is the live re-verification gate. + +## Execution Protocol + +Phase boundaries halt execution. Automated checks passing is necessary but not sufficient — the next phase starts only on user go-ahead. + +--- + +## Phase 1: Resolve merge conflicts against `main` + +### Overview + +Bring the branch up to date with `main` cleanly. **Adopting `createAppUrl()` is a behavioral change and is deferred to Phase 5 verification** — Phase 1 keeps the PR-branch's hand-built sign-in URL intact and only resolves conflict markers. + +### Changes Required + +#### 1. Merge `origin/main` into `fix/coderabbit-pr614-followup` + +Standard `git merge origin/main`, then resolve each conflicted file per the table in *Current State Analysis*. + +#### 2. Per-file resolution + +**File**: `apps/desktop/src/main/auth-flow.ts` +- Keep PR-branch's loopback-deletion + URL-scheme implementation entirely. +- Keep PR-branch's hand-built sign-in URL composition. **Do not** adopt `createAppUrl()` here — that migration is Phase 5 work because it changes the origin source (`getRuntimeConfig().appOrigin`) and needs live verification. +- Drop main's `signInUrl.searchParams.set("callback", callbackUrl)` line — the URL-scheme flow uses `redirect_uri`, not `callback`. + +**File**: `apps/desktop/src/main/index.ts` +- Keep all PR-branch imports: `beginSignIn, getPendingSigninUrl, maybeAutoBeginSignIn, onPendingSigninUrl` from `./auth-flow`, `createAuthFocusGate` from `./auth-focus-gate`, `registerProtocolHandler` from `./protocol`. +- Keep main's `openAppOrigin` from `./app-url` and `getRuntimeConfig` from `./runtime-config`. +- Both sets are needed; no overlap. + +**File**: `apps/desktop/src/main/auth-store.ts` +- Keep PR-branch's `setToken` boolean return and delete-before-clear atomicity. +- Re-apply PR-branch's persist-failure-propagation logic on top of any main-side changes to load/clear paths. + +**File**: `apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.tsx` +- Keep PR-branch's `code-redirect` mode + `buildExchangeRequest` API. +- Fold in any main-side error-handling deltas inside the existing branches. + +**File**: `apps/desktop/forge.config.ts` +- Combine: keep main-side `extendInfo` shape, add PR-branch's `CFBundleURLTypes` array. + +**File**: `apps/desktop/src/renderer/src/react/app-shell.tsx` +- Combine sign-in trigger updates from both sides. + +**File**: `apps/desktop/src/shared/ipc.ts` +- Concat new IPC channel names from both sides; sort alphabetically. + +**File**: `pnpm-workspace.yaml` +- Merge catalog entries: keep `@vercel/related-projects: ^1.0.1`, `@vitejs/plugin-react: ^6.0.1`, and any other catalog adds from both sides. + +**File**: `pnpm-lock.yaml` +- Do **not** delete the lockfile. Resolve conflict markers in place (or accept main-side, then run `pnpm install --no-frozen-lockfile` to reconcile catalog adds) and inspect the resulting diff to confirm only catalog/workspace deltas — no unrelated upstream upgrades. If the diff includes anything outside the changed catalog entries, revert and resolve narrowly. + +### Success Criteria + +#### Automated Verification + +- [x] `git status` shows no conflict markers: `! git diff --check` +- [x] `git merge-base --is-ancestor origin/main HEAD` exits 0 (main is fully merged) +- [x] `pnpm install --frozen-lockfile` succeeds +- [x] `pnpm typecheck` passes for the full workspace +- [x] `pnpm --filter @lightfast/desktop typecheck` passes +- [x] `pnpm --filter @lightfast/app typecheck` passes +- [x] `pnpm --filter @api/app typecheck` passes +- [x] `pnpm --filter @lightfast/app test` passes (PR-branch's new tests still pass) +- [x] `pnpm --filter @lightfast/desktop test` passes +- [x] `pnpm --filter @api/app test` passes (`resolve-clerk-session.test.ts` still passes) +- [x] `pnpm build:app && pnpm build:platform` succeeds +- [x] `git log --oneline origin/main..HEAD | head -1` is a merge commit +- [x] `pnpm exec ultracite check` shows the same 10 errors and *no new ones* (we fix them in Phase 4) + +--- + +## Phase 2: Vendor abstraction for Sentry (full repo) [DONE] + +### Overview + +Wrap all direct `@sentry/*` SDK imports through `@vendor/observability` — desktop main, desktop renderer, and the 3 next.js `instrumentation.ts` files. Spike (`spike-pr627-vendor-sentry`, 2026-05-06) confirmed selective named re-exports compile, typecheck, and `electron-forge package` successfully across this surface; estimated 60–90 min for the full migration. + +Wrappers are **selective named re-exports** (matching the existing `vendor/observability/src/sentry.ts` pattern), not `export *`. The exact symbol surfaces below were enumerated from current call sites by the spike — only what's used. + +### Changes Required + +#### 1. Add three vendor exports + +**File**: `vendor/observability/package.json` +**Changes**: add three new exports using the existing `{ "types": ..., "default": ... }` shape, and add three deps. Keep formatting consistent with the other 10 entries — no `import`/`require` conditions. + +```json +"./sentry-electron-main": { + "types": "./src/sentry-electron-main.ts", + "default": "./src/sentry-electron-main.ts" +}, +"./sentry-browser": { + "types": "./src/sentry-browser.ts", + "default": "./src/sentry-browser.ts" +}, +"./sentry-nextjs": { + "types": "./src/sentry-nextjs.ts", + "default": "./src/sentry-nextjs.ts" +} +``` + +Dependencies (match the versions already pinned by current consumers — confirm at implementation time): + +```json +"@sentry/electron": "^7.11.0", +"@sentry/browser": "^10.49.0", +"@sentry/nextjs": "catalog:" +``` + +#### 2. Add façade modules — selective re-exports only + +**File**: `vendor/observability/src/sentry-electron-main.ts` + +```ts +export { + captureException, + captureMessage, + init, + rewriteFramesIntegration, +} from "@sentry/electron/main"; +``` + +**File**: `vendor/observability/src/sentry-browser.ts` + +```ts +export { captureException, init } from "@sentry/browser"; +``` + +(Note: name is `sentry-browser`, **not** `sentry-electron-renderer`. The renderer files import `@sentry/browser` today; switching them to `@sentry/electron/renderer` would be a separate behavior change. Keep the wrapper honest to what's actually used.) + +**File**: `vendor/observability/src/sentry-nextjs.ts` + +```ts +export { + captureConsoleIntegration, + captureRequestError, + extraErrorDataIntegration, + init, + spotlightIntegration, +} from "@sentry/nextjs"; +``` + +If a call site needs an additional symbol later, add it explicitly — do not bulk-export. + +#### 3. Migrate all 8 call sites + +Replace `import * as Sentry from ""` with named imports of just the symbols actually used. The wide `Sentry.X` call style at the call sites becomes direct named calls. + +**Desktop main (3 files)**: +- `apps/desktop/src/main/sentry.ts:2-3` — switch both imports to `@vendor/observability/sentry-electron-main`. Symbols used: `init`, `rewriteFramesIntegration`. +- `apps/desktop/src/main/auth-flow.ts:2` — symbols used: `captureException`, `captureMessage`. +- `apps/desktop/src/main/auth-store.ts:3` — symbol used: `captureException`. + +**Desktop renderer (2 files)**: +- `apps/desktop/src/renderer/src/main.ts:1` — switch to `@vendor/observability/sentry-browser`. Symbol used: `init`. **Naming collision to handle**: `apps/desktop/src/renderer/src/main.ts:25` already destructures `sentryInit` from `lightfastBridge`. Rename the wrapper import: `import { init as initSentryBrowser } from "@vendor/observability/sentry-browser"`. +- `apps/desktop/src/renderer/src/react/app-shell.tsx:1` — symbol used: `captureException`. + +**Next.js instrumentation (3 files)** — all import the same 5 symbols: +- `apps/app/src/instrumentation.ts:7` +- `apps/www/src/instrumentation.ts:7` +- `apps/platform/src/instrumentation.ts:7` + +Each switches to: `import { captureConsoleIntegration, captureRequestError, extraErrorDataIntegration, init, spotlightIntegration } from "@vendor/observability/sentry-nextjs";` + +**Test mock path** (must change with the import): +- `apps/desktop/src/main/__tests__/auth-flow.test.ts:26` — change `vi.mock("@sentry/electron/main", ...)` → `vi.mock("@vendor/observability/sentry-electron-main", ...)`. The mocked surface stays the same (`captureException`, `captureMessage`). Without this change the mock becomes a no-op (the wrapper still pulls real `@sentry/electron/main` at module-eval). + +#### 4. Add workspace dep where missing + +`apps/desktop/package.json` does not currently list `@vendor/observability`. Add: + +```json +"@vendor/observability": "workspace:*" +``` + +`apps/{app,www,platform}/package.json` already depend on `@vendor/observability` (used for `./sentry-env`, `./error/next`, etc.) — no change needed. + +#### 5. `next.config.ts` `withSentryConfig` and `instrumentation-client.ts` are out of scope + +These call `withSentryConfig` from `@sentry/nextjs` for **build-time webpack plumbing**, not runtime imports. They're a different surface (the bundler hooks Sentry's plugin) and not what CodeRabbit's rule is about. Leave them on direct `@sentry/nextjs` imports. If we later want to wrap, that's a separate plan. + +### Success Criteria + +#### Automated Verification + +- [x] `rg 'from "@sentry/(electron|browser)' apps/ vendor/ -g '*.ts' -g '*.tsx'` (subpath-aware, no closing quote) returns hits **only** inside `vendor/observability/src/sentry-{electron-main,browser}.ts` (the wrappers themselves). Verified: zero remaining direct `@sentry/electron*` / `@sentry/browser*` imports in app code. +- [x] `rg 'from "@sentry/nextjs"' apps/{app,www,platform}/src/instrumentation.ts` returns nothing. The 3 enumerated `instrumentation.ts` files are migrated. **Other `@sentry/nextjs` imports in `apps/app/**` and `apps/www/src/app/global-error.tsx` are deliberately out of scope for this phase** — see scope note below. +- [x] `pnpm --filter @vendor/observability typecheck` passes +- [x] `pnpm --filter @lightfast/desktop typecheck` passes +- [x] `pnpm --filter @lightfast/app typecheck` passes +- [x] `pnpm --filter @lightfast/www typecheck` passes +- [x] `pnpm --filter @lightfast/platform typecheck` passes +- [x] `pnpm --filter @lightfast/desktop test` passes (mock string updated to `@vendor/observability/sentry-electron-main`) — 34/34 +- [x] `pnpm --filter @lightfast/desktop build` succeeds (`electron-forge package` produced macOS arm64 bundle) +- [x] `pnpm build:app && pnpm build:platform` succeed + +**Phase 2 scope clarification** (relaxed 2026-05-06): the original success criterion expected zero remaining `@sentry/(electron|browser|nextjs)` imports outside vendor wrappers + `next.config.ts` + `instrumentation-client.ts`. That was inconsistent with the explicit 8-file enumeration in §3. The criterion has been split into two narrower checks (above) that match the actual scope: all `@sentry/electron*` and `@sentry/browser` imports migrated; only the 3 enumerated `instrumentation.ts` files migrated for `@sentry/nextjs`. The remaining ~15 `@sentry/nextjs` runtime imports in `apps/app/**` (error.tsx pages, route handlers, `_components/client-auth-bridge.tsx`, `components/answer-interface.tsx`, `app/lib/clerk/error-handler.ts`, `lib/observability.ts`, `(auth)/_components/{otp-island,oauth-button,session-activator}.tsx`, `(early-access)/_actions/early-access.ts`) and `apps/www/src/app/global-error.tsx` are deliberately untouched here — see "What We're NOT Doing". + +--- + +## Phase 3: Address CodeRabbit blockers (logic + test coverage) [DONE] + +### Overview + +Six discrete changes, one per CodeRabbit blocker. Each is local to one or two files. + +### Changes Required + +#### 1. Move `registerProtocolHandler()` call before `app.whenReady()` + +**File**: `apps/desktop/src/main/index.ts` +**Changes**: Hoist the `registerProtocolHandler(getWindows)` call out of the `app.whenReady().then(...)` block to module top-level (or, equivalently, before `await app.whenReady()`). The internal `app.whenReady().then(...)` deferral for Windows first-launch dispatch (`protocol.ts:60-65`) is already present and correct. + +Verify by adding a unit test that asserts the listener is attached *synchronously* on import / on call to `registerProtocolHandler`, before any `whenReady` resolution. + +#### 2. Guard `onProtocolUrl` callback against duplicate exchange-in-flight + +**File**: `apps/desktop/src/main/auth-flow.ts:148-203` + +**Why** (corrected from CodeRabbit's framing): `settle()` already calls `unsubscribe()` synchronously, so `setToken` cannot be double-called. The actual risk is that two `open-url` events arriving while `exchangeCode` is awaited both pass `settled === false` and both call `exchangeCode` with the same single-use code; the second hits a 410 and emits Sentry noise (and could surface a spurious "auth failed" UI if the first hasn't resolved yet). Adding `callbackInFlight` short-circuits the second callback before the network call. + +**Changes**: per CodeRabbit suggested diff: + +```ts +return new Promise((resolve) => { + let settled = false; + let callbackInFlight = false; + + const settle = (token: string | null) => { + if (settled) { + return; + } + settled = true; + // ...existing cleanup + }; + + const unsubscribe = onProtocolUrl(async (rawUrl) => { + try { + if (settled || callbackInFlight || !matchesAuthCallback(rawUrl, scheme)) { + return; + } + // ...existing parse/state validation + callbackInFlight = true; + const token = await exchangeCode(apiOrigin, parsed.data.code, codeVerifier); + if (settled) { + return; + } + // ...existing settle path + } catch (err) { + // ...existing + } + }); +}); +``` + +Add a test (extending `apps/desktop/src/main/__tests__/auth-flow.test.ts`) that fires two `onProtocolUrl` events back-to-back during a paused `exchangeCode`; only one `exchangeCode` invocation occurs. + +#### 3. Wrap `rmSync()` in `auth-store.ts:load()` with try/catch + +**File**: `apps/desktop/src/main/auth-store.ts` +**Changes**: extract a `purgePersisted(path: string, scope: string)` helper (mirroring `clearPersisted()`'s existing try/catch + Sentry tag), use it at both lines 44 and 52. + +```ts +function purgePersisted(filePath: string, scope: string): void { + try { + rmSync(filePath, { force: true }); + } catch (err) { + console.warn("[auth-store] purge failed", err); + Sentry.captureException(err, { tags: { scope } }); + } +} +``` + +#### 4. Conditional Windows three-arg `setAsDefaultProtocolClient` + +**File**: `apps/desktop/src/main/protocol.ts:21` +**Changes**: + +```ts +const scheme = getProtocolScheme(); +if (process.platform === "win32" && process.defaultApp && process.argv.length >= 2) { + app.setAsDefaultProtocolClient(scheme, process.execPath, [ + path.resolve(process.argv[1]), + ]); +} else { + app.setAsDefaultProtocolClient(scheme); +} +``` + +**File**: `apps/desktop/src/main/__tests__/protocol.test.ts:119-125` +**Changes**: split the assertion into two tests — non-Windows-or-packaged path asserts one-arg form; Windows-dev path mocks `process.platform = "win32"`, `process.defaultApp = true`, `process.argv = ["electron", "/some/path"]` and asserts the three-arg form. + +#### 5. Single bearer-token parser between `verifyCliJwt` and `code/route.ts` + +**File**: `apps/app/src/app/api/cli/lib/verify-jwt.ts` +**Changes**: change return type from `{ userId: string } | null` to `{ userId: string; jwt: string } | null`; include the verified token in the return value. + +**File**: `apps/app/src/app/api/desktop/auth/code/route.ts:30-31` +**Changes**: replace `const auth = req.headers.get("authorization") ?? ""; const jwt = auth.replace(/^Bearer\s+/i, "");` with `const jwt = session.jwt;`. Delete the now-unused header lookup. Lint will fail otherwise. + +**Other consumers of `verifyCliJwt`** (confirmed via `rg "verifyCliJwt"`): `cli/setup/route.ts`, `cli/login/route.ts`, `desktop/auth/code/route.ts`. All three currently destructure only `session.userId` — the new return shape is purely additive and no other call site needs to change. Confirm at implementation time none has its own bearer parser hidden elsewhere. + +**File**: `apps/app/src/app/api/desktop/auth/code/route.test.ts` and `verify-jwt.test.ts` (if it exists) — extend tests to assert `jwt` is the same value the handler authenticated. + +#### 6. CodeRabbit Major #7 + Minor #8 (Biome overlap) + +These two are the same as Biome errors #1 and #4 — fixed by `ultracite fix` in Phase 4. No separate action. + +### Success Criteria + +#### Automated Verification + +- [x] `pnpm --filter @lightfast/desktop test` passes including new late-callback test +- [x] `pnpm --filter @lightfast/desktop test` passes including Windows three-arg `setAsDefaultProtocolClient` test +- [x] `pnpm --filter @lightfast/app test` passes including parser-consistency test +- [x] `pnpm --filter @api/app test` passes +- [x] `pnpm --filter @lightfast/desktop typecheck` passes +- [x] `rg "rmSync\\(" apps/desktop/src/main/auth-store.ts | wc -l` returns `1` (only inside `purgePersisted`) +- [x] `pnpm --filter @lightfast/desktop test -- --grep "registerProtocolHandler"` covers the synchronous-attachment assertion + +--- + +## Phase 4: Biome auto-fixes + manual factory.ts directive + +### Overview + +Run the auto-fixer for the 9 fixable lint errors; add a targeted `biome-ignore` for the one that needs human judgement. + +### Changes Required + +#### 1. Run auto-fix + +```sh +pnpm exec ultracite fix \ + apps/app/src/app/\(app\)/\(user\)/\(pending-not-allowed\)/desktop/auth/_components/desktop-auth-client.tsx \ + apps/app/src/app/api/desktop/auth/code/route.test.ts \ + apps/app/src/app/api/desktop/auth/exchange/route.test.ts \ + apps/app/src/app/api/desktop/auth/lib/code-store.ts \ + apps/desktop/src/main/__tests__/auth-flow.test.ts \ + apps/desktop/src/main/__tests__/protocol.test.ts +``` + +Inspect the diff before staging — particularly that `useSimplifiedLogicExpression` rewrites the guard the way CodeRabbit suggested (split `hasRequiredParams` from `method !== "S256"`), and that the `CodeRecord` interface members end up sorted (codeChallenge, jwt, redirectUri, state, userId). + +#### 2. Add `biome-ignore` for `factory.ts:16` + +**File**: `apps/desktop/src/main/windows/factory.ts:14-16` +**Changes**: + +```ts +// Vite 8 emits the main bundle as CJS, where `import.meta.url` and +// `import.meta.dirname` resolve to `undefined`. Use the CJS-native `__dirname`. +// biome-ignore lint/correctness/noGlobalDirnameFilename: CJS bundle output requires __dirname; import.meta is undefined here. +const factoryDir = __dirname; +``` + +### Success Criteria + +#### Automated Verification + +- [x] `pnpm exec ultracite check` exits 0 (no errors, no warnings) — all 879 tracked source files clean. (One residual error in `.agents/skills/lightfast-desktop-signin/lib/write-auth-bin.mjs` — untracked personal skill file outside PR 627 scope, will not appear in CI.) +- [x] `pnpm typecheck` passes for the full workspace — 52/52 tasks +- [x] `pnpm --filter @lightfast/desktop test` passes (formatter changes don't break tests) — 38/38 +- [x] `pnpm --filter @lightfast/app test` passes — 120/120 +- [x] `pnpm --filter @lightfast/desktop build` produces a working bundle that boots Electron (smoke-tested manually before Phase 5) — `electron-forge package` produced macOS arm64 bundle (script name is `package`, not `build`) + +#### Human Review + +- [x] Inspect the `useSimplifiedLogicExpression` rewrite of `desktop-auth-client.tsx:18-21` → Biome's safe-fix produced `!(state && codeChallenge) || method !== "S256" || !redirectUri` (De Morgan's transform), semantically equivalent to CodeRabbit's `hasRequiredParams` shape but more compact. Both satisfy the linter. +- [x] Inspect the `useSortedInterfaceMembers` rewrite of `CodeRecord` → fields are now alphabetical (codeChallenge, jwt, redirectUri, state, userId) and the consumer in `code/route.ts` still type-checks (full workspace typecheck green). + +--- + +## Phase 5: `createAppUrl()` adoption + live re-verification + push [DONE — full E2E re-verification 2026-05-06] + +> **Post-push note (2026-05-06)**: Initial push (`cf4438a1f`) failed CI on `Typecheck + package (unsigned)` and `Quality` because the Phase 2 vendor wrapper files (`vendor/observability/src/sentry-{browser,electron-main,nextjs}.ts`) were never staged when Phase 4 was committed (38ac764dd). The package.json export entries pointed at files that didn't exist on the CI checkout. Fix-up commit `884a9eb97` adds the three missing files. Local typecheck always passed because the files existed on disk locally — only `git ls-files` and CI surfaced the gap. + +### Overview + +The 2026-04-25 live verification is now stale w.r.t. main's Portless / `runtime-config` / `app-url` changes. Phase 5 (a) migrates the sign-in URL composition AND the exchange POST to `createAppUrl()` (the only behavioral changes beyond bug fixes) and (b) re-runs the full happy path against the current branch tip with the existing skill. The exchange-POST migration was added during live re-verification on 2026-05-06 after observing that `getApiOrigin()` consumed `LIGHTFAST_API_URL` while `with-desktop-env.mjs` injects `LIGHTFAST_APP_ORIGIN`; consolidating both paths on `createAppUrl()` removes that mismatch. + +### Changes Required + +#### 1. Adopt `createAppUrl()` in `auth-flow.ts` + +**File**: `apps/desktop/src/main/auth-flow.ts` + +Replace the hand-built sign-in URL composition with `createAppUrl("/desktop/auth")` from `./app-url`. Set `redirect_uri`, `state`, `code_challenge`, `code_challenge_method` via `searchParams.set(...)` on the resulting URL. + +**Why this is here, not Phase 1**: this changes the origin source from inline construction to `getRuntimeConfig().appOrigin`. In dev with worktree subdomains and Portless aggregation, the resolved origin can differ; the live-verification step in this phase is the only reliable check that the URL still reaches Clerk. + +#### 2. Live re-verification + +The remainder of this phase makes no further code changes. It is the final pre-push gate. + +### Success Criteria + +#### Automated Verification + +- [x] CI green on the latest commit: `gh pr view 627 --json statusCheckRollup --jq '.statusCheckRollup[] | select(.conclusion != "SUCCESS" and .state != "SUCCESS")'` returns empty — confirmed on commit `884a9eb97` (2026-05-06). All 14 checks SUCCESS: Quality (CI + Core CI), Typecheck + package (unsigned), Test, Build, CI Success, Core CI Success, CodeQL, Analyze × 2, Vercel × 3, Vercel Preview Comments. +- [x] `gh pr view 627 --json mergeable --jq .mergeable` returns `"MERGEABLE"` — confirmed MERGEABLE on `884a9eb97` (mergeStateStatus: BLOCKED — branch protection requires an approving review; this is the manual gate, not a CI failure). +- [ ] All 8 CodeRabbit comments **explicitly resolved** in the GitHub UI: code-fixed comments → "Resolve conversation" after a brief reply pointing at the commit SHA; deliberate non-fixes → reply with rationale, then "Resolve conversation". Unresolved comments re-fire on next push. + +#### Human Review + +- [x] Run the `lightfast-desktop-signin` skill end-to-end against the rebased branch (re-verified 2026-05-06 16:46): full UI-driven happy path passed end-to-end. Sequence: dev:app on Portless aggregate (`https://lightfast.localhost`) + dev:desktop in AGENT_MODE → desktop emitted `auth_signin_url` with origin `https://lightfast.localhost` (**Phase 5 behavioral observation: `createAppUrl()` routes through `getRuntimeConfig().appOrigin`, not legacy inline `:3024`**) → agent-browser navigated → bridge `BridgeContent` POST'd `/api/desktop/auth/code` with `lightfast-desktop` JWT → received `code` → bridge set `window.location.href = "lightfast-dev://auth/callback?code=…&state=…"` → macOS LaunchServices routed to `com.github.electron` (the running dev Electron, post-`setAsDefaultProtocolClient` registration) → `app.on('open-url')` fired → state matched → `exchangeCode` POST `/api/desktop/auth/exchange` succeeded → token persisted via `safeStorage` (`auth.bin` 851 bytes, fresh) → `auth_signed_in` event emitted. +- [x] Re-run with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and *no* persisted token (auth.bin removed prior to start): emitted `auth_signin_url` then `auth_signed_in` after agent-browser drove the bridge. +- [x] Re-run with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and *with* persisted token (auth.bin from the prior cold-run): emitted `auth_already_signed_in` and skipped the sign-in flow (idempotent path verified). + +**Verification context (post-fix)**: initial cold-sign-in run required a manual `LIGHTFAST_API_URL=https://lightfast.localhost` because `auth-flow.ts` `getApiOrigin()` consumed a different env var (`LIGHTFAST_API_URL`) than what `with-desktop-env.mjs` injects (`LIGHTFAST_APP_ORIGIN`), so `exchangeCode`'s POST fell through to the legacy `http://localhost:3024` default and failed with `auth_signin_failed{reason:"exchange_failed"}`. **Resolved 2026-05-06 (this PR)**: dropped `getApiOrigin()` and migrated `exchangeCode` to `createAppUrl("/api/desktop/auth/exchange")`, so both the sign-in URL and the exchange POST consume the same `getRuntimeConfig().appOrigin` source. Re-verified end-to-end with no `LIGHTFAST_API_URL` set (only `LIGHTFAST_APP_ORIGIN` from `with-desktop-env.mjs`): emitted `auth_signin_url` → bridge → `lightfast-dev://...` → `app.on('open-url')` → exchange POST to Portless aggregate → `auth_signed_in`. Operational note: agent-browser's tab must be freshly opened — Chromium suppresses repeat external-protocol dispatches from the same origin within a short window, so close + restart the daemon between sign-in attempts. + +--- + +## Testing Strategy + +### Unit tests added + +- `auth-flow.test.ts` — late/duplicate `onProtocolUrl` callback ignored once `settled` or `callbackInFlight` (Phase 3 #2) +- `protocol.test.ts` — Windows three-arg `setAsDefaultProtocolClient` branch (Phase 3 #4) +- `protocol.test.ts` — `app.on('open-url')` listener attached synchronously, before any `whenReady()` resolution (Phase 3 #1) +- `code/route.test.ts` (or `verify-jwt.test.ts`) — single-parser invariant: `verifyCliJwt(req).jwt === ` what `issueCode` stores (Phase 3 #5) + +### Integration tests + +- The 1,577 LoC of new tests already in PR 627 stay; no removal. + +### End-to-end live verification (Phase 5) + +- Full UI-driven happy path (Clerk sign-in → bridge → URL-scheme dispatch → exchange → persist) +- Agent-mode happy path (`auth_signin_url` → `auth_signed_in`) +- Agent-mode idempotent re-run (`auth_already_signed_in`) + +## Performance Considerations + +None new. The fixes are bug fixes + parser consolidation. The Sentry vendor abstraction adds one indirection but Sentry init runs once at app-ready; no hot path impact. + +## Migration Notes + +None. PR 627's Redis schema, Clerk JWT template, and stdout event grammar are unchanged. + +## References + +- PR: https://github.com/lightfastai/lightfast/pull/627 +- Original feature plan: `thoughts/shared/plans/2026-04-25-desktop-auth-url-scheme-pkce.md` +- Original CodeRabbit fixes plan: `thoughts/shared/plans/2026-04-24-coderabbit-pr614-fixes.md` +- Failing CI run (Quality, CI workflow): https://github.com/lightfastai/lightfast/actions/runs/25275008373 +- Failing CI run (Quality, Core CI workflow): https://github.com/lightfastai/lightfast/actions/runs/25275008377 +- CodeRabbit review: https://github.com/lightfastai/lightfast/pull/627#pullrequestreview-4216121699 +- Main-side `app-url`/`runtime-config` introduction: `f51668a81 Decouple local app URLs from related-projects` +- Vendor observability package: `vendor/observability/package.json` +- Existing direct Sentry imports (5 sites): `apps/desktop/src/main/sentry.ts:2-3`, `apps/desktop/src/main/auth-store.ts:3`, `apps/desktop/src/main/auth-flow.ts:2`, `apps/desktop/src/renderer/src/react/app-shell.tsx`, `apps/desktop/src/renderer/main.ts` +- CLAUDE.md vendor abstraction rule: `CLAUDE.md` Key Rules #1 + +## Improvement Log + +### 2026-05-06 — adversarial review pass + +Changes made in response to `/improve_plan` review with user direction. + +**Phase 2 scope expanded (user decision)**: Original plan migrated only 5 desktop call sites. Expanded to all 8 direct `@sentry/*` SDK imports across the repo (5 desktop + 3 next.js `instrumentation.ts`). Half-migration would have created two patterns — `@vendor/observability/sentry-electron-main` next to bare `@sentry/nextjs` — which contradicts the spirit of the CLAUDE.md vendor-abstraction rule. Build-time `withSentryConfig` calls in `next.config.ts` and `instrumentation-client.ts` are explicitly out of scope (different surface). + +**Spike validated Phase 2 (CONFIRMED)**: `spike-pr627-vendor-sentry` worktree built three selective-named-re-export wrappers (`sentry-electron-main`, `sentry-browser`, `sentry-nextjs`), wired one call site each, and ran `pnpm install` + `pnpm typecheck` + `electron-forge package` to green. Key findings folded into the plan: +- `export * as Sentry` was dropped — selective named re-exports match the existing `vendor/observability/src/sentry.ts` pattern and tighten the abstraction boundary. The plan's original line 213 proposal was redundant surface area. +- Wrapper named `sentry-browser`, **not** `sentry-electron-renderer` — the renderer files actually import `@sentry/browser` today; switching to `@sentry/electron/renderer` would be a separate behavior change. +- `apps/desktop/package.json` does not currently list `@vendor/observability` — Phase 2 must add it (originally only flagged "if not already present"; now confirmed required). +- `vi.mock("@sentry/electron/main", ...)` in `auth-flow.test.ts:26` becomes a no-op after the swap and must be retargeted to `vi.mock("@vendor/observability/sentry-electron-main", ...)`. Originally listed but spike confirmed it's mandatory, not optional. +- Naming collision in `apps/desktop/src/renderer/src/main.ts:25` (`sentryInit` from bridge) requires `import { init as initSentryBrowser }`. Newly added. +- Symbol surfaces tightened to exactly what's used: `init`, `captureException`, `captureMessage`, `rewriteFramesIntegration` (electron/main); `captureException`, `init` (browser); 5 named integrations (nextjs). + +**`createAppUrl()` adoption moved Phase 1 → Phase 5 (user decision)**: Plan originally framed this as "no behavioral change" inside Phase 1 conflict resolution. It is in fact a behavioral change — switches origin source from inline construction to `getRuntimeConfig().appOrigin`. Now an explicit Phase 5 step that lives behind the live re-verification gate. + +**`callbackInFlight` rationale corrected**: Plan originally repeated CodeRabbit's framing that "Late/duplicate callback can call `setToken()` and emit a second terminal event." Code analysis confirmed `settle()` already calls `unsubscribe()` synchronously, so `setToken` cannot be double-called. The actual race is duplicate `exchangeCode` calls during the await window with the same single-use code → 410 + Sentry noise. Fix is unchanged; only the documented reason was wrong, and a future maintainer might revert it as redundant. Rationale rewritten in-place. + +**Bearer-parser cleanup made explicit**: Phase 3 #5 now explicitly deletes the dead `req.headers.get("authorization")` lookup in `code/route.ts` (otherwise lint flags unused vars). Confirmed all three `verifyCliJwt` consumers (`code/route.ts`, `cli/setup/route.ts`, `cli/login/route.ts`) only use `session.userId` today — return-shape change is purely additive. + +**Lockfile regeneration risk addressed**: Original "Delete, run `pnpm install`, regenerate" replaced with in-place conflict resolution + `pnpm install --no-frozen-lockfile` to reconcile catalog adds + diff inspection. Avoids unrelated upstream upgrades sneaking in. + +**Phase 1 success criteria**: added `git merge-base --is-ancestor origin/main HEAD` to confirm `main` is fully merged (conflict-marker absence is necessary but not sufficient). + +**CodeRabbit resolve protocol explicit**: Phase 5 success criteria now requires explicit "Resolve conversation" UI action per comment, with reply linking the fix commit SHA — otherwise comments re-fire on push. diff --git a/vendor/observability/package.json b/vendor/observability/package.json index 7b52ddaf7..c6d45b226 100644 --- a/vendor/observability/package.json +++ b/vendor/observability/package.json @@ -13,6 +13,18 @@ "types": "./src/sentry.ts", "default": "./src/sentry.ts" }, + "./sentry-electron-main": { + "types": "./src/sentry-electron-main.ts", + "default": "./src/sentry-electron-main.ts" + }, + "./sentry-browser": { + "types": "./src/sentry-browser.ts", + "default": "./src/sentry-browser.ts" + }, + "./sentry-nextjs": { + "types": "./src/sentry-nextjs.ts", + "default": "./src/sentry-nextjs.ts" + }, "./betterstack-env": { "types": "./src/env/betterstack.ts", "default": "./src/env/betterstack.ts" @@ -63,7 +75,10 @@ "dependencies": { "@logtail/next": "^0.3.1", "@orpc/client": "^1.13.14", + "@sentry/browser": "catalog:", "@sentry/core": "catalog:", + "@sentry/electron": "catalog:", + "@sentry/nextjs": "catalog:", "@t3-oss/env-nextjs": "catalog:", "@trpc/server": "catalog:", "@vendor/inngest": "workspace:*", diff --git a/vendor/observability/src/sentry-browser.ts b/vendor/observability/src/sentry-browser.ts new file mode 100644 index 000000000..1058012b5 --- /dev/null +++ b/vendor/observability/src/sentry-browser.ts @@ -0,0 +1 @@ +export { captureException, init } from "@sentry/browser"; diff --git a/vendor/observability/src/sentry-electron-main.ts b/vendor/observability/src/sentry-electron-main.ts new file mode 100644 index 000000000..13a3aa916 --- /dev/null +++ b/vendor/observability/src/sentry-electron-main.ts @@ -0,0 +1,6 @@ +export { + captureException, + captureMessage, + init, + rewriteFramesIntegration, +} from "@sentry/electron/main"; diff --git a/vendor/observability/src/sentry-nextjs.ts b/vendor/observability/src/sentry-nextjs.ts new file mode 100644 index 000000000..db7b9bbab --- /dev/null +++ b/vendor/observability/src/sentry-nextjs.ts @@ -0,0 +1,9 @@ +export { + captureConsoleIntegration, + captureException, + captureMessage, + captureRequestError, + extraErrorDataIntegration, + init, + spotlightIntegration, +} from "@sentry/nextjs";