From e5c36f7bc97f91d846ad2eab8515c74b129d3ccc Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:53:56 +1000 Subject: [PATCH 01/18] fix(desktop/auth): resolve CodeRabbit PR #614 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses four findings from CodeRabbit's review of the Clerk loopback sign-in PR: sign-out atomicity (#9), silent persist-failure (#8), JWT-in-URL leak (#7), and non-deterministic Clerk-signed-out bridge (#4). Phase 1 — auth-store correctness - persist/clear return boolean; setToken/signOut only emit on success - clearPersisted deletes disk BEFORE clearing memory so a failed unlink can't leave an out-of-sync session - load() auto-purges auth.bin on decrypt/schema failure with Sentry scopes auth-store.load / auth-store.load.schema - IPC signOut contract becomes Promise; renderer surfaces a toast on user-click failure and a single Sentry event on the UNAUTHORIZED auto-sign-out path (idempotent latch) Phase 2 — POST-to-loopback handoff - Replace GET ?token= with POST JSON body; the browser URL/history and Referer chain never see the JWT - Allow-listed Origin (403 otherwise), OPTIONS preflight + Chrome Private Network Access header for public→loopback fetches, 16 KiB body cap, 400 on state mismatch, 500 + Sentry on persist failure - Serialize concurrent beginSignIn via module-scope inflight promise so rapid clicks don't spawn duplicate tabs/ports - ClientAuthBridge: discriminated union on mode ("post" | "redirect"), useRef didStart latch survives StrictMode double-invoke without double-POSTing, deterministic "error" when Clerk reports signed-out, new success panel replaces the HTML the loopback used to serve - Window focus on signed-out → signed-in transition only (gated by prev snapshot seeded from getAuthSnapshot, so token refresh doesn't yank focus) - Sentry observability across auth-flow.{bind,state_mismatch, forbidden_origin,timeout,persist_failed,handler_error,server_error, open_external} and auth-bridge.{invalid_callback,fetch_network_error, fetch_non_ok,unexpected_error} CLI loopback (cli-auth-client) retains the redirect mode explicitly for now — it can migrate to POST when its loopback gains POST support. Entire-Checkpoint: d07da2e69249 --- .../_components/client-auth-bridge.tsx | 102 +- .../cli/auth/_components/cli-auth-client.tsx | 4 + .../auth/_components/desktop-auth-client.tsx | 8 +- apps/desktop/src/main/auth-flow.ts | 173 +++- apps/desktop/src/main/auth-focus-gate.ts | 32 + apps/desktop/src/main/auth-store.ts | 41 +- apps/desktop/src/main/index.ts | 10 +- .../src/renderer/src/react/app-shell.tsx | 45 +- apps/desktop/src/shared/ipc.ts | 2 +- .../2026-04-24-coderabbit-pr614-fixes.md | 961 ++++++++++++++++++ 10 files changed, 1299 insertions(+), 79 deletions(-) create mode 100644 apps/desktop/src/main/auth-focus-gate.ts create mode 100644 thoughts/shared/plans/2026-04-24-coderabbit-pr614-fixes.md 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 ca674f07a..466fbbd0a 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,31 +1,54 @@ "use client"; +import { captureException, captureMessage } from "@sentry/nextjs"; import { useAuth } from "@vendor/clerk/client"; import { useSearchParams } from "next/navigation"; -import { type ReactNode, Suspense, useEffect, useState } from "react"; +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"; +} + +export type ClientAuthBridgeProps = ClientAuthBridgeBaseProps & + (PostCallbackProps | RedirectProps); + +type BridgeStatus = "loading" | "redirecting" | "success" | "error"; + function BridgeContent(props: ClientAuthBridgeProps) { const { getToken, isSignedIn, isLoaded } = useAuth(); const searchParams = useSearchParams(); - const [status, setStatus] = useState<"loading" | "redirecting" | "error">( - "loading" - ); + 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 && isSignedIn)) { + if (!isLoaded || didStart.current) { + return; + } + if (!isSignedIn) { + didStart.current = true; + setStatus("error"); return; } + didStart.current = true; void (async () => { try { const token = await getToken( @@ -35,6 +58,45 @@ function BridgeContent(props: ClientAuthBridgeProps) { 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; + } const url = props.buildRedirectUrl({ token, searchParams }); if (!url) { setStatus("error"); @@ -42,11 +104,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, isSignedIn, getToken, props, searchParams]); + }, [isLoaded, isSignedIn]); if (status === "error") { return ( @@ -61,6 +126,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..c8b4eebd7 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 @@ -28,17 +28,17 @@ function validateLoopbackCallback(raw: string | null): URL | null { export function DesktopAuthClient() { return ( { + buildPostCallback={({ searchParams }) => { const state = searchParams.get("state"); const callback = validateLoopbackCallback(searchParams.get("callback")); if (!(state && callback)) { return null; } - callback.searchParams.set("token", token); - callback.searchParams.set("state", state); - return callback.toString(); + callback.search = ""; + return { url: callback.toString(), state }; }} jwtTemplate="lightfast-desktop" + mode="post" subtitle="You'll be redirected back to the Lightfast desktop app shortly." title="Authenticating…" /> diff --git a/apps/desktop/src/main/auth-flow.ts b/apps/desktop/src/main/auth-flow.ts index ba395a7a1..ad96dfe9f 100644 --- a/apps/desktop/src/main/auth-flow.ts +++ b/apps/desktop/src/main/auth-flow.ts @@ -1,11 +1,24 @@ import { randomBytes } from "node:crypto"; -import { createServer, type Server } from "node:http"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import * as Sentry from "@sentry/electron/main"; import { shell } from "electron"; +import { z } from "zod"; import { setToken } from "./auth-store"; const SIGNIN_TIMEOUT_MS = 5 * 60_000; const LOOPBACK_HOST = "127.0.0.1"; const CALLBACK_PATH = "/callback"; +const MAX_BODY_BYTES = 16 * 1024; + +const callbackBodySchema = z.object({ + token: z.string().min(1), + state: z.string().min(1), +}); function getApiOrigin(): string { return ( @@ -16,32 +29,32 @@ function getApiOrigin(): string { ); } -function responsePage(message: string): string { - return ` - - - - Lightfast - - - - -
-

${message}

-

You can close this tab and return to Lightfast.

-
- -`; +const ALLOWED_ORIGIN = getApiOrigin(); + +console.log("[auth-flow] ALLOWED_ORIGIN =", ALLOWED_ORIGIN); + +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"); + res.setHeader("Access-Control-Allow-Private-Network", "true"); +} + +async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buf.length; + if (total > MAX_BODY_BYTES) { + req.destroy(); + throw new Error("payload too large"); + } + chunks.push(buf); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; } async function startLoopbackServer(): Promise<{ @@ -70,7 +83,23 @@ async function startLoopbackServer(): Promise<{ return { server, port: address.port }; } -export async function beginSignIn(): Promise { +let inflight: Promise | null = null; + +export function beginSignIn(): Promise { + if (inflight) { + return inflight; + } + inflight = (async () => { + try { + return await runSignIn(); + } finally { + inflight = null; + } + })(); + return inflight; +} + +async function runSignIn(): Promise { const state = randomBytes(32).toString("hex"); let bound: { server: Server; port: number }; @@ -78,6 +107,7 @@ export async function beginSignIn(): Promise { bound = await startLoopbackServer(); } catch (error) { console.error("[auth-flow] loopback bind failed", error); + Sentry.captureException(error, { tags: { scope: "auth-flow.bind" } }); return null; } const { server, port } = bound; @@ -92,30 +122,87 @@ export async function beginSignIn(): Promise { settled = true; clearTimeout(timer); server.close(); - if (token) { - setToken(token); - } resolve(token); }; - const timer = setTimeout(() => settle(null), SIGNIN_TIMEOUT_MS); + const timer = setTimeout(() => { + Sentry.captureMessage("auth-flow: sign-in timeout", { + level: "warning", + tags: { scope: "auth-flow.timeout" }, + }); + settle(null); + }, SIGNIN_TIMEOUT_MS); - server.on("request", (req, res) => { + server.on("request", async (req, res) => { try { + const origin = req.headers.origin ?? ""; + if (origin !== ALLOWED_ORIGIN) { + Sentry.captureMessage("auth-flow: forbidden origin", { + level: "warning", + tags: { scope: "auth-flow.forbidden_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; } - 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); + 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) { + Sentry.captureMessage("auth-flow: state mismatch", { + level: "warning", + tags: { scope: "auth-flow.state_mismatch" }, + }); + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, reason: "state_mismatch" })); + settle(null); + return; + } + const persisted = setToken(token); + if (!persisted) { + Sentry.captureException(new Error("auth-flow: persist failed"), { + tags: { scope: "auth-flow.persist_failed" }, + }); + } + 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); + Sentry.captureException(error, { + tags: { scope: "auth-flow.handler_error" }, + }); res.writeHead(500, { "Content-Type": "text/plain" }); res.end("Internal Server Error"); settle(null); @@ -124,10 +211,13 @@ export async function beginSignIn(): Promise { server.on("error", (error) => { console.error("[auth-flow] loopback server error", error); + Sentry.captureException(error, { + tags: { scope: "auth-flow.server_error" }, + }); settle(null); }); - const signInUrl = new URL("/desktop/auth", getApiOrigin()); + const signInUrl = new URL("/desktop/auth", ALLOWED_ORIGIN); signInUrl.searchParams.set("state", state); signInUrl.searchParams.set("callback", callbackUrl); @@ -137,6 +227,9 @@ export async function beginSignIn(): Promise { 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); }); }); 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 cb85e0ec4..5ff49f49e 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 * as Sentry from "@sentry/electron/main"; import { app, safeStorage } from "electron"; import { z } from "zod"; @@ -37,39 +38,51 @@ function load(): string | null { 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", err); + console.error("[auth-store] failed to load; purging", err); + Sentry.captureException(err, { tags: { scope: "auth-store.load" } }); + rmSync(path, { force: true }); return null; } } -function persist(token: string): void { +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); + Sentry.captureException(err, { tags: { scope: "auth-store.persist" } }); + return false; } } -function clearPersisted(): void { - memory = null; +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; } } @@ -94,14 +107,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 3c9db34cc..2992437fe 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -10,6 +10,7 @@ import { import contextMenu from "electron-context-menu"; import { IpcChannels, type SystemThemeVariant } from "../shared/ipc"; import { beginSignIn } from "./auth-flow"; +import { createAuthFocusGate } from "./auth-focus-gate"; import { getAuthSnapshot, getToken as getAuthToken, @@ -214,9 +215,7 @@ function registerIpcHandlers(): void { }); ipcMain.handle(IpcChannels.authGetToken, () => getAuthToken()); ipcMain.handle(IpcChannels.authSignIn, () => beginSignIn()); - ipcMain.handle(IpcChannels.authSignOut, () => { - signOutAuth(); - }); + ipcMain.handle(IpcChannels.authSignOut, () => signOutAuth()); } function broadcastThemeUpdates(): void { @@ -364,10 +363,15 @@ 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); }); app.on("activate", () => { diff --git a/apps/desktop/src/renderer/src/react/app-shell.tsx b/apps/desktop/src/renderer/src/react/app-shell.tsx index 725f5712b..fa81a159a 100644 --- a/apps/desktop/src/renderer/src/react/app-shell.tsx +++ b/apps/desktop/src/renderer/src/react/app-shell.tsx @@ -1,9 +1,13 @@ +import * as Sentry from "@sentry/browser"; import { useQueryClient } from "@tanstack/react-query"; 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 @@ -23,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; + Sentry.captureException(new Error("auto-sign-out failed"), { + tags: { scope: "app-shell.auto-sign-out" }, + }); + } + }); } }); return unsub; @@ -31,20 +42,38 @@ export function AppShell() { if (!auth.isSignedIn) { return ( - - void window.lightfastBridge.openExternal("https://lightfast.ai") - } - onSignIn={() => void window.lightfastBridge.auth.signIn()} - /> + <> + + + void window.lightfastBridge.openExternal("https://lightfast.ai") + } + onSignIn={() => { + void window.lightfastBridge.auth.signIn().then((token) => { + if (token) { + signoutFailureReported = false; + return; + } + toast.error("Sign-in didn't complete — please try again"); + }); + }} + /> + ); } return (
+ + ``` + +- **`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 + +### 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: + +- [ ] `pnpm --filter @lightfast/app typecheck` passes. +- [ ] `pnpm biome check apps/app/src/app/\\(app\\)/\\(user\\)/\\(pending-not-allowed\\)/_components/client-auth-bridge.tsx` is clean. + +#### Manual Verification: + +- [ ] In DevTools, sign out in another tab while `/desktop/auth` is open → the bridge flips from "Authenticating…" to "Authentication Failed" within one tick (not stuck in "loading"). +- [ ] Open `/desktop/auth` in an incognito window with no Clerk session → renders "Authentication Failed" deterministically. +- [ ] Normal signed-in flow still works (happy path from Phase 2's manual checks). + +--- + +## Phase 4: Missing `resolveClerkSession` auth-boundary test + +### 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: + +- [ ] `pnpm --filter @api/app vitest run src/__tests__/resolve-clerk-session.test.ts` passes with 6 tests. +- [ ] `pnpm --filter @api/app typecheck` passes. +- [ ] `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. From 9e1c07d3c450c44c8291bda1155b2289c6338363 Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:55:05 +1000 Subject: [PATCH 02/18] test(desktop/auth): automate Phase 2 manual verification as vitest coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 10 manual verification scenarios in the PR #614 follow-up plan with 33 automated tests. Covers the full behavioral surface of the POST handoff, the window-focus transition gate, and the ClientAuthBridge handshake — including the paths that were previously hand-verified against a running desktop + browser. apps/desktop — new vitest suite (23 tests, 2 files) - auth-flow.test.ts boots the real HTTP loopback server and exercises ALLOWED_ORIGIN resolution (dev fallback / NODE_ENV=production / LIGHTFAST_API_URL override), forbidden origin → 403, missing Origin header → 403, unknown path → 404, OPTIONS preflight → 204 with every CORS + PNA header asserted, GET → 405 Allow: POST, invalid body shape → 400 bad_request, state mismatch → 400 + Sentry warning, happy path → 204 + setToken called + promise resolves with token, persist failure → 500 + Sentry exception, 16 KiB body cap, concurrent beginSignIn returns same promise, inflight cleared after settle, 5-min timeout fires auth-flow.timeout with vi.useFakeTimers() - auth-focus-gate.test.ts covers the false→true focus (first sign-in), true→true no-op (token refresh), true→false no-op (sign-out), re-sign-in, multi-window fan-out, lazy getWindows resolution Testability refactor: extracted createAuthFocusGate from index.ts as a pure function so it's coverable without Electron BrowserWindow. apps/app — new vitest suite (10 tests, 1 file) - client-auth-bridge.test.tsx uses React Testing Library + happy-dom to assert: POST with correct JSON body + credentials:"omit" + headers, StrictMode double-invoke fires exactly one POST (didStart latch), null buildPostCallback / fetch rejection / 4xx response all render the error panel and emit the right Sentry tag scope, null getToken → error, Clerk signed-out → deterministic error (fix for #4), Clerk not-loaded → loading panel, redirect mode sets window.location.href via vi.spyOn setter, null buildRedirectUrl → error Infra - apps/desktop: new "test" script, devDeps add vitest (catalog) + @repo/vitest-config + happy-dom; vite devDep bumped to ^7.1.10 so vitest 4's peer is satisfied (electron-forge/plugin-vite keeps its own vite 5 via direct dep, no conflict) - apps/app: @testing-library/react devDep Entire-Checkpoint: c1f2cfc2cfb4 --- apps/app/package.json | 1 + .../_components/client-auth-bridge.test.tsx | 331 +++++++++ apps/desktop/package.json | 8 +- .../src/main/__tests__/auth-flow.test.ts | 626 ++++++++++++++++++ .../main/__tests__/auth-focus-gate.test.ts | 99 +++ apps/desktop/vitest.config.ts | 13 + pnpm-lock.yaml | 386 +++-------- 7 files changed, 1177 insertions(+), 287 deletions(-) create mode 100644 apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.test.tsx create mode 100644 apps/desktop/src/main/__tests__/auth-flow.test.ts create mode 100644 apps/desktop/src/main/__tests__/auth-focus-gate.test.ts create mode 100644 apps/desktop/vitest.config.ts diff --git a/apps/app/package.json b/apps/app/package.json index e1cc97f2f..c43fdfeef 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -86,6 +86,7 @@ "@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", 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..248e77ec8 --- /dev/null +++ b/apps/app/src/app/(app)/(user)/(pending-not-allowed)/_components/client-auth-bridge.test.tsx @@ -0,0 +1,331 @@ +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("@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 — 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/desktop/package.json b/apps/desktop/package.json index b5bb3ad40..b3f4c765c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,8 @@ "package": "electron-forge package", "make": "electron-forge make", "publish": "electron-forge publish", - "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json" + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", + "test": "vitest run --passWithNoTests" }, "devDependencies": { "@electron-forge/cli": "^7.11.1", @@ -25,14 +26,17 @@ "@electron/notarize": "^3.1.1", "@electron/osx-sign": "^1.3.3", "@repo/typescript-config": "workspace:*", + "@repo/vitest-config": "workspace:*", "@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", "electron": "^39.8.5", + "happy-dom": "^20.9.0", "typescript": "catalog:", - "vite": "^5.4.11" + "vite": "^7.1.10", + "vitest": "catalog:" }, "dependencies": { "@repo/app-trpc": "workspace:*", 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..25ed8dba1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/auth-flow.test.ts @@ -0,0 +1,626 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const shellOpenExternalMock = vi.fn<(...args: unknown[]) => Promise>(() => + Promise.resolve() +); +const setTokenMock = vi.fn<(token: string) => boolean>(() => true); +const sentryCaptureExceptionMock = vi.fn<(...args: unknown[]) => void>(); +const sentryCaptureMessageMock = vi.fn<(...args: unknown[]) => void>(); + +vi.mock("electron", () => ({ + shell: { + openExternal: (url: string) => shellOpenExternalMock(url), + }, +})); + +vi.mock("@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), +})); + +// Imported dynamically inside tests so we can reset modules between cases +// (the `ALLOWED_ORIGIN` constant + `inflight` module-scope state are captured +// at import time). +async function loadAuthFlow(env?: Record) { + vi.resetModules(); + const prev = { ...process.env }; + if (env) { + for (const [k, v] of Object.entries(env)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + } + const mod = await import("../auth-flow"); + return { mod, restore: () => Object.assign(process.env, prev) }; +} + +interface CallbackInfo { + origin: string; + port: number; + url: string; +} + +async function startFlowAndCaptureCallback( + mod: typeof import("../auth-flow") +): Promise<{ callback: CallbackInfo; signIn: Promise }> { + const signIn = mod.beginSignIn(); + + // Wait for shell.openExternal to be called so we can extract the callback URL. + for (let i = 0; i < 200; i++) { + if (shellOpenExternalMock.mock.calls.length > 0) { + break; + } + await new Promise((r) => setTimeout(r, 10)); + } + const lastCall = shellOpenExternalMock.mock.calls.at(-1); + if (!lastCall) { + throw new Error("shell.openExternal was not called"); + } + const signInUrl = new URL(lastCall[0] as string); + const callbackRaw = signInUrl.searchParams.get("callback"); + if (!callbackRaw) { + throw new Error("no callback param"); + } + const callback = new URL(callbackRaw); + return { + callback: { + url: callback.toString(), + port: Number(callback.port), + origin: `http://127.0.0.1:${callback.port}`, + }, + signIn, + }; +} + +function extractState(): string | null { + const lastCall = shellOpenExternalMock.mock.calls.at(-1); + if (!lastCall) { + return null; + } + return new URL(lastCall[0] as string).searchParams.get("state"); +} + +// Send a settling POST to end a flow that was left hanging by early-return +// paths (403/404/405 all skip settle()). Uses state-mismatch to force the +// server into a terminal state so the awaited signIn promise resolves to null. +async function forceSettle(origin: string): Promise { + try { + await fetch(`${origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "settle", state: "mismatch" }), + }); + } catch { + // ignore — server may have already closed + } +} + +describe("auth-flow loopback server", () => { + beforeEach(() => { + shellOpenExternalMock.mockClear(); + setTokenMock.mockClear(); + sentryCaptureExceptionMock.mockClear(); + sentryCaptureMessageMock.mockClear(); + setTokenMock.mockImplementation(() => true); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("ALLOWED_ORIGIN resolution", () => { + it("falls back to http://localhost:3024 when NODE_ENV!=production and LIGHTFAST_API_URL unset", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const res = await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "x", state: "y" }), + }); + // Origin check passes (not 403), so we land in state_mismatch. + expect(res.status).toBe(400); + await signIn; + } finally { + restore(); + } + }); + + it("uses https://lightfast.ai when NODE_ENV=production", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "production", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const res = await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "x", state: "y" }), + }); + expect(res.status).toBe(403); // wrong origin for prod + // Settle with a matching-origin state-mismatch to close the flow. + await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://lightfast.ai", + }, + body: JSON.stringify({ token: "x", state: "bad" }), + }); + await signIn; + } finally { + restore(); + } + }); + + it("honors LIGHTFAST_API_URL override", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: "https://staging.lightfast.ai", + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const res = await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://staging.lightfast.ai", + }, + body: JSON.stringify({ token: "x", state: "y" }), + }); + // Correct origin → passes origin check, fails state mismatch + expect(res.status).toBe(400); + await signIn; + } finally { + restore(); + } + }); + }); + + describe("request handler", () => { + it("returns 403 when Origin header is foreign", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const res = await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://evil.com", + }, + body: JSON.stringify({ token: "x", state: "x" }), + }); + expect(res.status).toBe(403); + expect(sentryCaptureMessageMock).toHaveBeenCalledWith( + expect.stringContaining("forbidden origin"), + expect.objectContaining({ + level: "warning", + tags: { scope: "auth-flow.forbidden_origin" }, + }) + ); + await forceSettle(callback.origin); + await signIn; + } finally { + restore(); + } + }); + + it("returns 403 when Origin header is missing entirely", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const res = await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: "x", state: "x" }), + }); + expect(res.status).toBe(403); + await forceSettle(callback.origin); + await signIn; + } finally { + restore(); + } + }); + + it("returns 404 for unknown path with allowed origin", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const res = await fetch(`${callback.origin}/does-not-exist`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "x", state: "x" }), + }); + expect(res.status).toBe(404); + await forceSettle(callback.origin); + await signIn; + } finally { + restore(); + } + }); + + it("handles OPTIONS preflight with CORS + PNA headers", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const res = await fetch(`${callback.origin}/callback`, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:3024", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Private-Network": "true", + }, + }); + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe( + "http://localhost:3024" + ); + expect(res.headers.get("access-control-allow-methods")).toBe( + "POST, OPTIONS" + ); + expect(res.headers.get("access-control-allow-headers")).toBe( + "content-type" + ); + expect(res.headers.get("access-control-allow-private-network")).toBe( + "true" + ); + expect(res.headers.get("vary")).toBe("Origin"); + // OPTIONS does NOT settle the flow — connection still open. + // Settle it with a forbidden-origin POST so the server closes. + await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + // Server requires a real settle — send a state-mismatch POST. + await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "x", state: "bad" }), + }); + await signIn; + } finally { + restore(); + } + }); + + it("returns 405 for GET requests from the allowed origin", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const res = await fetch(`${callback.origin}/callback`, { + method: "GET", + headers: { Origin: "http://localhost:3024" }, + }); + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("POST"); + // Close the flow. + await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "x", state: "bad" }), + }); + await signIn; + } finally { + restore(); + } + }); + + it("returns 400 bad_request when body fails schema", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const res = await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "", state: "" }), + }); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json).toEqual({ ok: false, reason: "bad_request" }); + const result = await signIn; + expect(result).toBeNull(); + } finally { + restore(); + } + }); + + it("returns 400 state_mismatch when state doesn't match", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const res = await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "jwt-token", state: "wrong-state" }), + }); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json).toEqual({ ok: false, reason: "state_mismatch" }); + expect(sentryCaptureMessageMock).toHaveBeenCalledWith( + expect.stringContaining("state mismatch"), + expect.objectContaining({ + level: "warning", + tags: { scope: "auth-flow.state_mismatch" }, + }) + ); + expect(setTokenMock).not.toHaveBeenCalled(); + const result = await signIn; + expect(result).toBeNull(); + } finally { + restore(); + } + }); + + it("accepts a valid POST, persists the token, and resolves with the JWT", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const state = extractState(); + if (!state) { + throw new Error("no state"); + } + const res = await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "real-jwt-token", state }), + }); + expect(res.status).toBe(204); + expect(setTokenMock).toHaveBeenCalledWith("real-jwt-token"); + const result = await signIn; + expect(result).toBe("real-jwt-token"); + } finally { + restore(); + } + }); + + it("returns 500 and captures Sentry when setToken fails to persist", async () => { + setTokenMock.mockImplementationOnce(() => false); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const state = extractState(); + if (!state) { + throw new Error("no state"); + } + const res = await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "jwt", state }), + }); + expect(res.status).toBe(500); + const json = await res.json(); + expect(json).toEqual({ ok: false, reason: "persist_failed" }); + expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { scope: "auth-flow.persist_failed" }, + }) + ); + const result = await signIn; + expect(result).toBeNull(); + } finally { + restore(); + } + }); + + it("rejects bodies larger than MAX_BODY_BYTES (16 KiB)", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const { callback, signIn } = await startFlowAndCaptureCallback(mod); + const huge = "a".repeat(32 * 1024); + // Server destroys the socket mid-stream — the fetch either rejects + // or returns a 500. Both are acceptable; what matters is we don't + // happily accept 32 KiB of token. + try { + const res = await fetch(`${callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: huge, state: "any" }), + }); + expect(res.status).toBeGreaterThanOrEqual(400); + } catch { + // Socket destruction → fetch rejects. Acceptable. + } + expect(setTokenMock).not.toHaveBeenCalled(); + const result = await signIn; + expect(result).toBeNull(); + } finally { + restore(); + } + }); + }); + + describe("concurrency", () => { + it("serializes concurrent beginSignIn calls — second caller gets the same promise", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const first = mod.beginSignIn(); + const second = mod.beginSignIn(); + expect(first).toBe(second); + + // Only one call to openExternal was queued + for (let i = 0; i < 100; i++) { + if (shellOpenExternalMock.mock.calls.length > 0) { + break; + } + await new Promise((r) => setTimeout(r, 10)); + } + expect(shellOpenExternalMock).toHaveBeenCalledTimes(1); + + // Settle — both callers should see the same result. + const state = extractState(); + const firstCall = shellOpenExternalMock.mock.calls[0]; + if (!firstCall) { + throw new Error("no openExternal call"); + } + const callbackUrl = new URL(firstCall[0] as string).searchParams.get( + "callback" + ); + if (!(state && callbackUrl)) { + throw new Error("missing params"); + } + await fetch(callbackUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "shared-token", state }), + }); + const [r1, r2] = await Promise.all([first, second]); + expect(r1).toBe("shared-token"); + expect(r2).toBe("shared-token"); + } finally { + restore(); + } + }); + + it("clears inflight after settle so the next sign-in starts a fresh flow", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + // First flow — let it fail via state mismatch. + const first = await startFlowAndCaptureCallback(mod); + await fetch(`${first.callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "a", state: "bad" }), + }); + await first.signIn; + shellOpenExternalMock.mockClear(); + + // Second flow — should open a new browser tab. + const second = await startFlowAndCaptureCallback(mod); + expect(shellOpenExternalMock).toHaveBeenCalledTimes(1); + expect(second.callback.port).not.toBe(first.callback.port); + + // Close it. + await fetch(`${second.callback.origin}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:3024", + }, + body: JSON.stringify({ token: "b", state: "bad" }), + }); + await second.signIn; + } finally { + restore(); + } + }); + }); + + describe("timeout", () => { + it("resolves null and fires auth-flow.timeout Sentry message after 5 minutes", async () => { + vi.useFakeTimers(); + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + }); + try { + const signIn = mod.beginSignIn(); + // Advance microtasks so the server binds + await vi.advanceTimersByTimeAsync(100); + // Advance past the 5-minute timeout + await vi.advanceTimersByTimeAsync(5 * 60_000 + 1000); + const result = await signIn; + expect(result).toBeNull(); + expect(sentryCaptureMessageMock).toHaveBeenCalledWith( + expect.stringContaining("timeout"), + expect.objectContaining({ + level: "warning", + tags: { scope: "auth-flow.timeout" }, + }) + ); + } finally { + vi.useRealTimers(); + restore(); + } + }, 10_000); + }); +}); 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/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/pnpm-lock.yaml b/pnpm-lock.yaml index d53ebbed7..ac4b1d487 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -563,6 +563,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 @@ -717,6 +720,9 @@ importers: '@repo/typescript-config': specifier: workspace:* version: link:../../internal/typescript + '@repo/vitest-config': + specifier: workspace:* + version: link:../../internal/vitest-config '@types/electron-squirrel-startup': specifier: ^1.0.2 version: 1.0.2 @@ -731,16 +737,22 @@ importers: 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)) + version: 4.7.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)) electron: specifier: ^39.8.5 version: 39.8.5 + happy-dom: + specifier: ^20.9.0 + 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: ^7.1.10 + version: 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: + 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)) apps/platform: dependencies: @@ -3304,12 +3316,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'} @@ -3340,12 +3346,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'} @@ -3376,12 +3376,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'} @@ -3412,12 +3406,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'} @@ -3448,12 +3436,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'} @@ -3484,12 +3466,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'} @@ -3520,12 +3496,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'} @@ -3556,12 +3526,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'} @@ -3592,12 +3556,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'} @@ -3628,12 +3586,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'} @@ -3664,12 +3616,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'} @@ -3700,12 +3646,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'} @@ -3736,12 +3676,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'} @@ -3772,12 +3706,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'} @@ -3808,12 +3736,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'} @@ -3844,12 +3766,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'} @@ -3880,12 +3796,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'} @@ -3940,12 +3850,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'} @@ -4000,12 +3904,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'} @@ -4054,12 +3952,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'} @@ -4090,12 +3982,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'} @@ -4126,12 +4012,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'} @@ -4162,12 +4042,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'} @@ -8328,10 +8202,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'} @@ -8473,6 +8366,9 @@ 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==} @@ -9247,6 +9143,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'} @@ -9281,6 +9181,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'} @@ -10224,6 +10127,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==} @@ -10551,11 +10457,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'} @@ -12193,6 +12094,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] @@ -13284,6 +13189,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'} @@ -13425,6 +13334,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==} @@ -14725,37 +14637,6 @@ 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} - 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 - peerDependenciesMeta: - '@types/node': - optional: true - less: - 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} @@ -16601,9 +16482,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 @@ -16619,9 +16497,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 @@ -16637,9 +16512,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 @@ -16655,9 +16527,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 @@ -16673,9 +16542,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 @@ -16691,9 +16557,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 @@ -16709,9 +16572,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 @@ -16727,9 +16587,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 @@ -16745,9 +16602,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 @@ -16763,9 +16617,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 @@ -16781,9 +16632,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 @@ -16799,9 +16647,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 @@ -16817,9 +16662,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 @@ -16835,9 +16677,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 @@ -16853,9 +16692,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 @@ -16871,9 +16707,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 @@ -16889,9 +16722,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 @@ -16919,9 +16749,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 @@ -16949,9 +16776,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 @@ -16976,9 +16800,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 @@ -16994,9 +16815,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 @@ -17012,9 +16830,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 @@ -17030,9 +16845,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 @@ -21570,6 +21382,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 @@ -21579,6 +21402,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 @@ -21723,6 +21556,8 @@ snapshots: '@types/node': 25.3.3 optional: true + '@types/aria-query@5.0.4': {} + '@types/aws-lambda@8.10.156': {} '@types/babel__core@7.20.5': @@ -22447,7 +22282,7 @@ 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@4.7.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))': dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) @@ -22455,7 +22290,7 @@ snapshots: '@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) + 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) transitivePeerDependencies: - supports-color @@ -22741,6 +22576,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -22787,6 +22624,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: {} @@ -23707,6 +23548,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: @@ -24002,32 +23845,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 @@ -25940,6 +25757,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + macos-alias@0.2.12: dependencies: nan: 2.26.2 @@ -27345,6 +27164,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 @@ -27496,6 +27321,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): @@ -29068,17 +28895,6 @@ 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): - 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 From 508190616721c2b4b5b3b3dbcb10eb662bc109de Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:16:25 +1000 Subject: [PATCH 03/18] test(api/app): add expired-Bearer-no-cookie resolveClerkSession case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the CodeRabbit PR #614 follow-up plan. Adds the missing auth-boundary case that matches the canonical desktop unhappy path (expired 24h JWT + no cookie because the desktop has never been to lightfast.ai) — previously unverified. Also flips the Phase 3 and Phase 4 plan checkboxes to [DONE] and documents that Phase 3's scope landed inside Phase 2's ClientAuthBridge rewrite and is covered by the automated bridge tests from commit 9e1c07d3c. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: 1d489bf5d4b2 --- .../__tests__/resolve-clerk-session.test.ts | 13 +++++++++ .../2026-04-24-coderabbit-pr614-fixes.md | 28 ++++++++++++------- 2 files changed, 31 insertions(+), 10 deletions(-) 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/thoughts/shared/plans/2026-04-24-coderabbit-pr614-fixes.md b/thoughts/shared/plans/2026-04-24-coderabbit-pr614-fixes.md index 318599a1f..948b6dbb5 100644 --- a/thoughts/shared/plans/2026-04-24-coderabbit-pr614-fixes.md +++ b/thoughts/shared/plans/2026-04-24-coderabbit-pr614-fixes.md @@ -688,7 +688,7 @@ fetch-POST clears every URL-surface leak, which is the bar CodeRabbit #7 asked u --- -## Phase 3: ClientAuthBridge state machine + useEffect dep array +## Phase 3: ClientAuthBridge state machine + useEffect dep array [DONE] ### Overview @@ -745,18 +745,26 @@ Notes: #### Automated Verification: -- [ ] `pnpm --filter @lightfast/app typecheck` passes. -- [ ] `pnpm biome check apps/app/src/app/\\(app\\)/\\(user\\)/\\(pending-not-allowed\\)/_components/client-auth-bridge.tsx` is clean. +- [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: -- [ ] In DevTools, sign out in another tab while `/desktop/auth` is open → the bridge flips from "Authenticating…" to "Authentication Failed" within one tick (not stuck in "loading"). -- [ ] Open `/desktop/auth` in an incognito window with no Clerk session → renders "Authentication Failed" deterministically. -- [ ] Normal signed-in flow still works (happy path from Phase 2's manual checks). +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 +## Phase 4: Missing `resolveClerkSession` auth-boundary test [DONE] ### Overview @@ -789,9 +797,9 @@ Note: The existing `"returns null when neither Bearer nor cookie produce a sessi #### Automated Verification: -- [ ] `pnpm --filter @api/app vitest run src/__tests__/resolve-clerk-session.test.ts` passes with 6 tests. -- [ ] `pnpm --filter @api/app typecheck` passes. -- [ ] `pnpm biome check api/app/src/__tests__/resolve-clerk-session.test.ts` is clean. +- [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: From 6e83296212d7ce906bae910184cddec19f12cd59 Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Fri, 24 Apr 2026 20:16:34 +1000 Subject: [PATCH 04/18] chore(desktop): upgrade electron 41, vite 8, plugin-react 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop: - electron 39 → 41 (chromium 140+) - vite 7 → 8, @vitejs/plugin-react 4 → 6 - Drop dead direct deps @electron/osx-sign + @electron/notarize (forge's @electron/packager@18.4.4 provides them transitively; our forge.config.ts only passes osxSign/osxNotarize as option objects) Root: - engines.node >=22.0.0 → >=22.12.0 (vite 8 minimum) App: - Add @vitejs/plugin-react to apps/app/vitest.config.ts — Vite 8 moved import-analysis ahead of the esbuild transform, so the existing esbuild.jsx: "automatic" override no longer catches .tsx before import-analysis rejects the jsx: preserve in tsconfig. The react plugin registers a pre-import-analysis JSX transform hook. Hold: @electron/fuses stays on ^1.8.0 (forge 7.x peer pins ^1.0.0; revisit when forge 8 stabilizes). Refs: thoughts/shared/plans/2026-04-24-desktop-deps-major-upgrade.md Entire-Checkpoint: 2bf87131bcb5 --- apps/app/package.json | 1 + apps/app/vitest.config.ts | 2 + apps/desktop/package.json | 8 +- package.json | 2 +- pnpm-lock.yaml | 636 +++++++++++++++++++++++++------------- 5 files changed, 434 insertions(+), 215 deletions(-) diff --git a/apps/app/package.json b/apps/app/package.json index c43fdfeef..204791da0 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -91,6 +91,7 @@ "@types/node": "catalog:", "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", + "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "catalog:", "@vitest/expect": "catalog:", "babel-plugin-react-compiler": "catalog:", diff --git a/apps/app/vitest.config.ts b/apps/app/vitest.config.ts index 9c5fabcfb..ffda1b2fe 100644 --- a/apps/app/vitest.config.ts +++ b/apps/app/vitest.config.ts @@ -1,10 +1,12 @@ import { resolve } from "node:path"; +import react from "@vitejs/plugin-react"; import sharedConfig from "@repo/vitest-config"; import { defineConfig, mergeConfig } from "vitest/config"; export default mergeConfig( sharedConfig, defineConfig({ + plugins: [react()], esbuild: { jsx: "automatic", }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b3f4c765c..bf06825d9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,19 +23,17 @@ "@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:*", "@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", - "electron": "^39.8.5", + "@vitejs/plugin-react": "^6.0.1", + "electron": "^41.3.0", "happy-dom": "^20.9.0", "typescript": "catalog:", - "vite": "^7.1.10", + "vite": "^8.0.10", "vitest": "catalog:" }, "dependencies": { diff --git a/package.json b/package.json index d5f7b3f81..3add4ffe4 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 ac4b1d487..a1cf7a2ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,7 +272,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: @@ -363,7 +363,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: @@ -456,7 +456,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/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/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) @@ -501,13 +501,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.0.1 '@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) @@ -525,10 +525,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) react: specifier: ^19.2.5 version: 19.2.5 @@ -578,6 +578,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + 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) @@ -610,7 +613,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: @@ -683,7 +686,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 @@ -711,12 +714,6 @@ 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 @@ -736,11 +733,11 @@ 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@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)) + specifier: ^6.0.1 + 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)) electron: - specifier: ^39.8.5 - version: 39.8.5 + specifier: ^41.3.0 + version: 41.3.0 happy-dom: specifier: ^20.9.0 version: 20.9.0(bufferutil@4.1.0) @@ -748,11 +745,11 @@ importers: specifier: 'catalog:' version: 5.9.3 vite: - specifier: ^7.1.10 - version: 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) + 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@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/platform: dependencies: @@ -770,7 +767,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/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/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) @@ -800,13 +797,13 @@ importers: version: 1.0.1 '@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 @@ -870,7 +867,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/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/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) @@ -900,13 +897,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.0.1 '@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 @@ -918,7 +915,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) @@ -945,7 +942,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 @@ -1024,7 +1021,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/ai-sdk: dependencies: @@ -1076,7 +1073,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/cli: dependencies: @@ -1155,7 +1152,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: @@ -1237,7 +1234,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: @@ -1368,7 +1365,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: @@ -1458,7 +1455,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: @@ -1602,7 +1599,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 @@ -1702,7 +1699,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: @@ -1742,7 +1739,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 @@ -1915,7 +1912,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) @@ -2049,7 +2046,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 @@ -2098,7 +2095,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 @@ -2264,7 +2261,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: @@ -2308,7 +2305,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 @@ -2463,7 +2460,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 @@ -2818,10 +2815,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'} @@ -2848,18 +2841,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'} @@ -3271,10 +3252,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'} @@ -3299,15 +3276,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' @@ -4721,6 +4707,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==} @@ -5920,6 +5912,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] @@ -7044,30 +7039,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} @@ -7075,6 +7100,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} @@ -7082,6 +7114,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} @@ -7089,6 +7142,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} @@ -7096,35 +7156,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'} @@ -8372,18 +8465,6 @@ packages: '@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==} @@ -8906,11 +8987,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==} @@ -10318,8 +10406,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.3.0: + resolution: {integrity: sha512-2Q5aeocmFdeheZGDUTrAvSR3t+n0c3d104AJWWEnt7syJU0tE4VdibMYaPtQ47QuXSoUf0/xSsfUUvu/uSXIfg==} engines: {node: '>= 12.20.55'} hasBin: true @@ -13355,10 +13443,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'} @@ -13661,6 +13745,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'} @@ -14637,15 +14726,16 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - vite@7.1.10: - resolution: {integrity: sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==} + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@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 - lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' @@ -14656,12 +14746,14 @@ packages: peerDependenciesMeta: '@types/node': optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -15119,7 +15211,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: @@ -15656,8 +15748,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': {} @@ -15677,16 +15767,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': @@ -15952,7 +16032,7 @@ snapshots: '@clerk/backend': 3.2.13(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@clerk/react': 6.4.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@clerk/shared': 4.8.2(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 @@ -16049,9 +16129,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 @@ -16089,7 +16169,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 @@ -16100,7 +16180,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 @@ -16278,13 +16358,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 @@ -16370,13 +16450,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 @@ -16456,12 +16529,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 @@ -16472,6 +16556,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 @@ -17425,7 +17514,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 @@ -17593,6 +17682,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 @@ -17655,7 +17751,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': @@ -19123,6 +19219,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 @@ -20244,54 +20342,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) @@ -20674,6 +20823,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/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/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 @@ -20687,7 +20861,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: @@ -20835,6 +21009,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) @@ -21560,27 +21742,6 @@ snapshots: '@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/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/bunyan@1.8.11': dependencies: '@types/node': 25.3.3 @@ -21884,7 +22045,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)': @@ -21976,7 +22137,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' @@ -22061,7 +22222,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 @@ -22078,14 +22239,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 @@ -22102,10 +22263,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 @@ -22244,7 +22405,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': @@ -22260,10 +22421,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 @@ -22272,9 +22433,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' @@ -22282,17 +22443,12 @@ snapshots: - debug - react-dom - '@vitejs/plugin-react@4.7.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))': + '@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: 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) - 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: @@ -22306,7 +22462,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: @@ -22317,21 +22473,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: @@ -22360,7 +22516,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)) '@vitest/utils@4.1.4': dependencies: @@ -23695,10 +23851,10 @@ snapshots: - supports-color optional: true - electron@39.8.5: + electron@41.3.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 @@ -24314,7 +24470,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) @@ -24486,14 +24642,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 @@ -24517,9 +24673,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 @@ -24585,7 +24741,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' @@ -24619,7 +24775,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: @@ -25222,7 +25378,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' @@ -26510,7 +26666,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 @@ -26519,7 +26675,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 @@ -26611,13 +26767,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: @@ -27352,8 +27508,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): @@ -27755,6 +27909,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 @@ -27816,6 +27991,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: {} @@ -28330,10 +28506,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: {} @@ -28433,6 +28611,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 @@ -28895,44 +29083,42 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.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): + 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.25.12 - fdir: 6.5.0(picomatch@4.0.4) + lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.10 - 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.10 - 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 @@ -28949,7 +29135,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 @@ -28962,10 +29148,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 @@ -28982,7 +29168,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 @@ -29128,6 +29314,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 From bbfaecb3110b109cc90e455311f4330683687a31 Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:14:29 +1000 Subject: [PATCH 05/18] chore(deps): address CodeRabbit PR #622 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move @vitejs/plugin-react and happy-dom to pnpm-workspace.yaml catalog (both used identically in apps/app + apps/desktop; per CLAUDE.md "Use catalog: for shared externals") - Replace literal versions with "catalog:" refs in apps/app/package.json and apps/desktop/package.json - Drop --passWithNoTests from apps/desktop test script — desktop now has 2 real test files, flag would silently mask future discovery regressions - Re-order imports in apps/app/vitest.config.ts per Biome organizeImports Entire-Checkpoint: 7b2ff62f014f --- apps/app/package.json | 4 ++-- apps/app/vitest.config.ts | 2 +- apps/desktop/package.json | 6 +++--- pnpm-lock.yaml | 14 ++++++++++---- pnpm-workspace.yaml | 2 ++ 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/app/package.json b/apps/app/package.json index 204791da0..986feeac0 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -91,12 +91,12 @@ "@types/node": "catalog:", "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", - "@vitejs/plugin-react": "^6.0.1", + "@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/vitest.config.ts b/apps/app/vitest.config.ts index ffda1b2fe..126bdab4f 100644 --- a/apps/app/vitest.config.ts +++ b/apps/app/vitest.config.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; -import react from "@vitejs/plugin-react"; import sharedConfig from "@repo/vitest-config"; +import react from "@vitejs/plugin-react"; import { defineConfig, mergeConfig } from "vitest/config"; export default mergeConfig( diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bf06825d9..96d70903c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,7 +10,7 @@ "make": "electron-forge make", "publish": "electron-forge publish", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", - "test": "vitest run --passWithNoTests" + "test": "vitest run" }, "devDependencies": { "@electron-forge/cli": "^7.11.1", @@ -29,9 +29,9 @@ "@types/node": "catalog:", "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "catalog:", "electron": "^41.3.0", - "happy-dom": "^20.9.0", + "happy-dom": "catalog:", "typescript": "catalog:", "vite": "^8.0.10", "vitest": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1cf7a2ce..044b058d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ catalogs: '@vercel/related-projects': specifier: ^1.0.1 version: 1.0.1 + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1 '@vitest/coverage-v8': specifier: ^4.1.4 version: 4.1.4 @@ -93,6 +96,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 @@ -579,7 +585,7 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': - specifier: ^6.0.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)) '@vitest/coverage-v8': specifier: 'catalog:' @@ -594,7 +600,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:' @@ -733,13 +739,13 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': - specifier: ^6.0.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)) electron: specifier: ^41.3.0 version: 41.3.0 happy-dom: - specifier: ^20.9.0 + specifier: 'catalog:' version: 20.9.0(bufferutil@4.1.0) typescript: specifier: 'catalog:' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f2afdcca0..bf9c243eb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,6 +28,7 @@ catalog: '@types/node': ^24.9.1 '@upstash/redis': ^1.37.0 '@vercel/related-projects': ^1.0.1 + '@vitejs/plugin-react': ^6.0.1 '@vitest/coverage-v8': ^4.1.4 '@vitest/expect': ^4.1.4 ai: 5.0.52 @@ -37,6 +38,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.52.6 From 703a205fa04d87fd5308667fc33791204b620e2d Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:28:22 +1000 Subject: [PATCH 06/18] fix(desktop/windows): replace import.meta.url with __dirname for CJS bundle Vite 8 bundles the main process as CJS, where `import.meta` resolves to an empty object literal and `import.meta.url` becomes `undefined`. This crashed Electron boot with `ERR_INVALID_ARG_TYPE` from `fileURLToPath` before any window code ran. Use the CJS-native `__dirname` directly. Semantically identical to the ESM form; survives both bundle formats. Entire-Checkpoint: dadf5e6571a7 --- apps/desktop/src/main/windows/factory.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/windows/factory.ts b/apps/desktop/src/main/windows/factory.ts index 3937aebbd..0637ab239 100644 --- a/apps/desktop/src/main/windows/factory.ts +++ b/apps/desktop/src/main/windows/factory.ts @@ -1,5 +1,4 @@ -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; import { BrowserWindow, type BrowserWindowConstructorOptions, @@ -12,7 +11,9 @@ import { loadWindowState, trackWindowState } from "../window-state"; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; declare const MAIN_WINDOW_VITE_NAME: string; -const factoryDir = dirname(fileURLToPath(import.meta.url)); +// Vite 8 emits the main bundle as CJS, where `import.meta.url` resolves to +// `undefined`. Use the CJS-native `__dirname` instead. +const factoryDir = __dirname; const PRELOAD_PATH = join(factoryDir, "preload.js"); const RENDERER_DIST = join(factoryDir, `../renderer/${MAIN_WINDOW_VITE_NAME}`); From 016f9ad45fbfdab30eb067c321b8722400e6260d Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:28:40 +1000 Subject: [PATCH 07/18] fix(app/proxy): allow /api/desktop/* through Clerk middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new desktop auth routes (`/api/desktop/auth/code`, `/api/desktop/auth/exchange`) handle their own auth — `code` verifies a Clerk Bearer JWT at the route level, `exchange` is unauthed because the short-lived code itself proves possession. Without an entry in the `isApiRoute` matcher, Clerk middleware 307-redirected both to `/sign-in`. Mirror the existing `/api/cli/(.*)` entry. Entire-Checkpoint: 06b998039e1f --- apps/app/src/proxy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/app/src/proxy.ts b/apps/app/src/proxy.ts index 601bb0c95..ba2f3f0cb 100644 --- a/apps/app/src/proxy.ts +++ b/apps/app/src/proxy.ts @@ -59,6 +59,7 @@ const isPublicRoute = createRouteMatcher([ const isApiRoute = createRouteMatcher([ "/v1/(.*)", "/api/cli/(.*)", + "/api/desktop/(.*)", "/api/inngest(.*)", "/api/trpc/(.*)", ]); From b30d99975a13af2f037b9bc4196a86876bae5c0a Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:29:32 +1000 Subject: [PATCH 08/18] feat(desktop): custom URL scheme + PKCE sign-in flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the desktop's loopback HTTP server with an OAuth 2.0 Authorization Code + PKCE flow over a custom URL scheme (`lightfast://` packaged, `lightfast-dev://` unpackaged). Brings the desktop in line with VS Code / GitHub Desktop / Linear / Slack and removes ~150 LoC of HTTP server / CORS / ephemeral-port plumbing. Server (apps/app): - `POST /api/desktop/auth/code` (Clerk JWT in Authorization header) issues a short-lived code (32-byte base64url, 30s TTL in Upstash Redis) bound to state + S256 code_challenge + redirect_uri (allowlist: `lightfast://` and `lightfast-dev://`). - `POST /api/desktop/auth/exchange` (no auth — code is the proof) atomically consumes the code via GETDEL, verifies SHA256(verifier) == challenge, and returns the JWT. Web bridge: - New `code-redirect` mode on `ClientAuthBridge` exchanges the JWT for a code and assigns `window.location.href` to `?code=…&state=…`, then best-effort `window.close()` after 250ms. Desktop: - `protocol.ts` registers the URL scheme via `app.setAsDefaultProtocolClient` and dispatches `app.on('open-url')` (macOS) / `second-instance` argv (Windows/Linux) to listeners. Forge `CFBundleURLTypes` mirrors for packaged builds. - `auth-flow.ts` rewritten around PKCE: composes the signin URL with state + S256 challenge + scheme-derived redirect_uri, listens for the protocol callback, exchanges code+verifier for the JWT, persists via `safeStorage`. - Renames `LIGHTFAST_DESKTOP_AUTH_NO_OPEN` → `LIGHTFAST_DESKTOP_AGENT_MODE` with broadened semantics: skip `shell.openExternal` AND emit a structured stdout JSON event grammar (`auth_signin_url`, `auth_signed_in`, `auth_already_signed_in`, `auth_signin_failed{reason}`) so agent harnesses drive sign-in deterministically without log-grepping or CDP attach. - `maybeAutoBeginSignIn()` fires on app-ready in agent mode: emits `auth_already_signed_in` if a token is persisted, otherwise begins a fresh sign-in. Single agent-browser session, no renderer click. - New `LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS` (default 5min for humans, ~30s for CI/agent runs). - IPC: `authPendingSigninUrl` getter + `authPendingSigninUrlChanged` broadcast for renderer status surfaces. Tests (122 total passing): - 13 unit tests for code/exchange routes (auth, schema, allowlist, GETDEL one-shot). - 5 new bridge tests for code-redirect mode. - 12 protocol tests (scheme detection, dispatch, listener detach, argv first-launch on Windows/Linux, foreign-scheme rejection). - 15 auth-flow tests (PKCE composition, callback matching, exchange failure / persist failure / handler error / timeout, agent vs non-agent mode, maybeAutoBeginSignIn 3 states, event grammar). Live verification (2026-04-25): - Server round-trip: real Clerk JWT issued via `lightfast-clerk` → POST /code → POST /exchange returned same JWT → second POST returned invalid_code (Redis GETDEL atomicity). - Real Electron 41: agent-mode boot emitted auth_signin_url; macOS LaunchServices warm-dispatched `lightfast-dev://auth/callback?...` to the running desktop; PKCE state matched; exchange call fired. - Full UI-driven happy path with agent-browser headed: Clerk OTP sign-in → navigate to signin URL → bridge redirected via `lightfast-dev://` → desktop received → exchange → token persisted (851b auth.bin) → auth_signed_in emitted ~14s end-to-end. Idempotent restart emitted auth_already_signed_in. Risks documented in plan: agent-browser headless silently drops custom-scheme navigations (mandate `AGENT_BROWSER_HEADED=true`); cold-launch URL routing unreliable in unpackaged dev (precondition: app must be running before redirect fires). Runbook: `.agents/skills/lightfast-desktop-signin/SKILL.md`. Plan: `thoughts/shared/plans/2026-04-25-desktop-auth-url-scheme-pkce.md`. Entire-Checkpoint: d6793f336d6d --- .../skills/lightfast-desktop-signin/SKILL.md | 115 ++ .../_components/client-auth-bridge.test.tsx | 182 +++ .../_components/client-auth-bridge.tsx | 85 +- .../auth/_components/desktop-auth-client.tsx | 44 +- .../app/api/desktop/auth/code/route.test.ts | 135 +++ .../src/app/api/desktop/auth/code/route.ts | 41 + .../api/desktop/auth/exchange/route.test.ts | 97 ++ .../app/api/desktop/auth/exchange/route.ts | 32 + .../app/api/desktop/auth/lib/code-store.ts | 28 + apps/desktop/forge.config.ts | 8 + .../src/main/__tests__/auth-flow.test.ts | 1079 +++++++++-------- .../src/main/__tests__/protocol.test.ts | 330 +++++ apps/desktop/src/main/auth-flow.ts | 309 ++--- apps/desktop/src/main/index.ts | 19 +- apps/desktop/src/main/protocol.ts | 63 + apps/desktop/src/preload/preload.ts | 9 + apps/desktop/src/shared/ipc.ts | 6 + ...2026-04-25-desktop-auth-url-scheme-pkce.md | 844 +++++++++++++ 18 files changed, 2721 insertions(+), 705 deletions(-) create mode 100644 .agents/skills/lightfast-desktop-signin/SKILL.md create mode 100644 apps/app/src/app/api/desktop/auth/code/route.test.ts create mode 100644 apps/app/src/app/api/desktop/auth/code/route.ts create mode 100644 apps/app/src/app/api/desktop/auth/exchange/route.test.ts create mode 100644 apps/app/src/app/api/desktop/auth/exchange/route.ts create mode 100644 apps/app/src/app/api/desktop/auth/lib/code-store.ts create mode 100644 apps/desktop/src/main/__tests__/protocol.test.ts create mode 100644 apps/desktop/src/main/protocol.ts create mode 100644 thoughts/shared/plans/2026-04-25-desktop-auth-url-scheme-pkce.md 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/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 index 248e77ec8..e4ce7b14a 100644 --- 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 @@ -271,6 +271,188 @@ describe("ClientAuthBridge — POST mode", () => { }); }); +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; 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 466fbbd0a..181fd8dad 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 @@ -27,11 +27,21 @@ interface RedirectProps { mode: "redirect"; } +interface CodeRedirectProps { + buildExchangeRequest: (args: { + searchParams: URLSearchParams; + }) => { state: string; codeChallenge: string; redirectUri: string } | null; + mode: "code-redirect"; +} + export type ClientAuthBridgeProps = ClientAuthBridgeBaseProps & - (PostCallbackProps | RedirectProps); + (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 { getToken, isSignedIn, isLoaded } = useAuth(); const searchParams = useSearchParams(); @@ -97,6 +107,79 @@ function BridgeContent(props: ClientAuthBridgeProps) { 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"); 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 c8b4eebd7..ee7458613 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.search = ""; - return { url: callback.toString(), state }; + return { state, codeChallenge, redirectUri }; }} jwtTemplate="lightfast-desktop" - mode="post" - 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/desktop/auth/code/route.test.ts b/apps/app/src/app/api/desktop/auth/code/route.test.ts new file mode 100644 index 000000000..6821ae083 --- /dev/null +++ b/apps/app/src/app/api/desktop/auth/code/route.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const verifyCliJwtMock = vi.fn<(req: Request) => Promise<{ userId: 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" }); + + 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" }); + + 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" }); + + 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" }); + + 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" }); + + 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("strips Bearer prefix case-insensitively when storing the JWT", async () => { + verifyCliJwtMock.mockResolvedValue({ userId: "user_456" }); + + await POST( + new Request("http://localhost/api/desktop/auth/code", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "bearer alt-jwt", + }, + body: JSON.stringify(VALID_BODY), + }) + ); + + expect(issueCodeMock).toHaveBeenCalledWith( + expect.objectContaining({ jwt: "alt-jwt" }) + ); + }); +}); 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..24b9da23f --- /dev/null +++ b/apps/app/src/app/api/desktop/auth/code/route.ts @@ -0,0 +1,41 @@ +// 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 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 }); +} 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..a62fe056f --- /dev/null +++ b/apps/app/src/app/api/desktop/auth/exchange/route.test.ts @@ -0,0 +1,97 @@ +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..d83c57497 --- /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 { + 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; +} diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 5ae7923eb..0170b75ae 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -10,6 +10,7 @@ import { PublisherGithub } from "@electron-forge/publisher-github"; import type { ForgeConfig } from "@electron-forge/shared-types"; const BUNDLE_ID = "ai.lightfast.desktop"; +const URL_SCHEME = "lightfast"; const osxSign = process.env.APPLE_SIGNING_IDENTITY && process.env.APPLE_TEAM_ID @@ -75,7 +76,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/src/main/__tests__/auth-flow.test.ts b/apps/desktop/src/main/__tests__/auth-flow.test.ts index 25ed8dba1..a68c3599c 100644 --- a/apps/desktop/src/main/__tests__/auth-flow.test.ts +++ b/apps/desktop/src/main/__tests__/auth-flow.test.ts @@ -1,16 +1,26 @@ +import { createHash } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const shellOpenExternalMock = vi.fn<(...args: unknown[]) => Promise>(() => +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; + vi.mock("electron", () => ({ shell: { openExternal: (url: string) => shellOpenExternalMock(url), }, + app: { + get isPackaged() { + return isPackagedFlag; + }, + }, })); vi.mock("@sentry/electron/main", () => ({ @@ -22,14 +32,29 @@ vi.mock("@sentry/electron/main", () => ({ 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); + }; + }, })); -// Imported dynamically inside tests so we can reset modules between cases -// (the `ALLOWED_ORIGIN` constant + `inflight` module-scope state are captured -// at import time). async function loadAuthFlow(env?: Record) { vi.resetModules(); - const prev = { ...process.env }; + 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]; + } if (env) { for (const [k, v] of Object.entries(env)) { if (v === undefined) { @@ -40,587 +65,571 @@ async function loadAuthFlow(env?: Record) { } } const mod = await import("../auth-flow"); - return { mod, restore: () => Object.assign(process.env, prev) }; + return { + mod, + restore: () => { + for (const k of touchedKeys) { + const original = prev[k]; + if (original === undefined) { + delete process.env[k]; + } else { + process.env[k] = original; + } + } + }, + }; } -interface CallbackInfo { - origin: string; - port: number; - url: string; +interface CapturedSignin { + url: URL; + state: string; + codeChallenge: string; + redirectUri: string; } -async function startFlowAndCaptureCallback( - mod: typeof import("../auth-flow") -): Promise<{ callback: CallbackInfo; signIn: Promise }> { - const signIn = mod.beginSignIn(); - - // Wait for shell.openExternal to be called so we can extract the callback URL. +async function captureSigninUrl( + fromOpenExternal: boolean, + emittedEvents: AuthLine[] +): Promise { for (let i = 0; i < 200; i++) { - if (shellOpenExternalMock.mock.calls.length > 0) { + 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 lastCall = shellOpenExternalMock.mock.calls.at(-1); - if (!lastCall) { - throw new Error("shell.openExternal was not called"); + 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 signInUrl = new URL(lastCall[0] as string); - const callbackRaw = signInUrl.searchParams.get("callback"); - if (!callbackRaw) { - throw new Error("no callback param"); - } - const callback = new URL(callbackRaw); + 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); + continue; + } + } catch { + // not JSON — fall through to original + } + } + } + return true; + }); return { - callback: { - url: callback.toString(), - port: Number(callback.port), - origin: `http://127.0.0.1:${callback.port}`, + events, + restore: () => { + spy.mockRestore(); + process.stdout.write = original; }, - signIn, }; } -function extractState(): string | null { - const lastCall = shellOpenExternalMock.mock.calls.at(-1); - if (!lastCall) { - return null; - } - return new URL(lastCall[0] as string).searchParams.get("state"); -} +beforeEach(() => { + shellOpenExternalMock.mockClear(); + setTokenMock.mockClear(); + setTokenMock.mockImplementation(() => true); + getTokenMock.mockClear(); + getTokenMock.mockImplementation(() => null); + sentryCaptureExceptionMock.mockClear(); + sentryCaptureMessageMock.mockClear(); + isPackagedFlag = false; +}); -// Send a settling POST to end a flow that was left hanging by early-return -// paths (403/404/405 all skip settle()). Uses state-mismatch to force the -// server into a terminal state so the awaited signIn promise resolves to null. -async function forceSettle(origin: string): Promise { - try { - await fetch(`${origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "settle", state: "mismatch" }), - }); - } catch { - // ignore — server may have already closed - } -} +afterEach(() => { + vi.useRealTimers(); +}); -describe("auth-flow loopback server", () => { - beforeEach(() => { - shellOpenExternalMock.mockClear(); - setTokenMock.mockClear(); - sentryCaptureExceptionMock.mockClear(); - sentryCaptureMessageMock.mockClear(); - setTokenMock.mockImplementation(() => true); +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_API_URL: undefined, + 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(); + } }); - afterEach(() => { - vi.useRealTimers(); + it("composes redirect_uri with the lightfast scheme when packaged", async () => { + isPackagedFlag = true; + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "production", + LIGHTFAST_API_URL: undefined, + 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(); + } }); - describe("ALLOWED_ORIGIN resolution", () => { - it("falls back to http://localhost:3024 when NODE_ENV!=production and LIGHTFAST_API_URL unset", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const res = await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "x", state: "y" }), - }); - // Origin check passes (not 403), so we land in state_mismatch. - expect(res.status).toBe(400); - 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", + LIGHTFAST_API_URL: undefined, }); - - it("uses https://lightfast.ai when NODE_ENV=production", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "production", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const res = await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "x", state: "y" }), - }); - expect(res.status).toBe(403); // wrong origin for prod - // Settle with a matching-origin state-mismatch to close the flow. - await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "https://lightfast.ai", - }, - body: JSON.stringify({ token: "x", state: "bad" }), - }); - await signIn; - } finally { - restore(); + try { + const signIn = mod.beginSignIn(); + const captured = await captureSigninUrl(true, []); + const cb = protocolListeners[0]; + if (!cb) { + throw new Error("no protocol listener"); } - }); - it("honors LIGHTFAST_API_URL override", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: "https://staging.lightfast.ai", - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const res = await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "https://staging.lightfast.ai", - }, - body: JSON.stringify({ token: "x", state: "y" }), - }); - // Correct origin → passes origin check, fails state mismatch - expect(res.status).toBe(400); - await signIn; - } finally { - restore(); - } - }); + 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(); + } }); - describe("request handler", () => { - it("returns 403 when Origin header is foreign", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const res = await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://evil.com", - }, - body: JSON.stringify({ token: "x", state: "x" }), - }); - expect(res.status).toBe(403); - expect(sentryCaptureMessageMock).toHaveBeenCalledWith( - expect.stringContaining("forbidden origin"), - expect.objectContaining({ - level: "warning", - tags: { scope: "auth-flow.forbidden_origin" }, - }) - ); - await forceSettle(callback.origin); - await signIn; - } finally { - restore(); - } + 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", + LIGHTFAST_API_URL: undefined, }); - - it("returns 403 when Origin header is missing entirely", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const res = await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: "x", state: "x" }), - }); - expect(res.status).toBe(403); - await forceSettle(callback.origin); - await signIn; - } finally { - restore(); + 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; + } } - }); - - it("returns 404 for unknown path with allowed origin", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const res = await fetch(`${callback.origin}/does-not-exist`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "x", state: "x" }), - }); - expect(res.status).toBe(404); - await forceSettle(callback.origin); - await signIn; - } finally { - restore(); + const cb = protocolListeners[0]; + if (!cb) { + throw new Error("no protocol listener"); } - }); - it("handles OPTIONS preflight with CORS + PNA headers", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const res = await fetch(`${callback.origin}/callback`, { - method: "OPTIONS", - headers: { - Origin: "http://localhost:3024", - "Access-Control-Request-Method": "POST", - "Access-Control-Request-Private-Network": "true", - }, - }); - expect(res.status).toBe(204); - expect(res.headers.get("access-control-allow-origin")).toBe( - "http://localhost:3024" - ); - expect(res.headers.get("access-control-allow-methods")).toBe( - "POST, OPTIONS" - ); - expect(res.headers.get("access-control-allow-headers")).toBe( - "content-type" - ); - expect(res.headers.get("access-control-allow-private-network")).toBe( - "true" - ); - expect(res.headers.get("vary")).toBe("Origin"); - // OPTIONS does NOT settle the flow — connection still open. - // Settle it with a forbidden-origin POST so the server closes. - await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - }); - // Server requires a real settle — send a state-mismatch POST. - await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "x", state: "bad" }), - }); - await signIn; - } finally { - restore(); - } - }); + 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("returns 405 for GET requests from the allowed origin", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const res = await fetch(`${callback.origin}/callback`, { - method: "GET", - headers: { Origin: "http://localhost:3024" }, - }); - expect(res.status).toBe(405); - expect(res.headers.get("allow")).toBe("POST"); - // Close the flow. - await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "x", state: "bad" }), - }); - await signIn; - } finally { - restore(); - } + 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_API_URL: undefined, + LIGHTFAST_DESKTOP_AGENT_MODE: "1", }); - - it("returns 400 bad_request when body fails schema", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const res = await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "", state: "" }), - }); - expect(res.status).toBe(400); - const json = await res.json(); - expect(json).toEqual({ ok: false, reason: "bad_request" }); - const result = await signIn; - expect(result).toBeNull(); - } finally { - restore(); + 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("returns 400 state_mismatch when state doesn't match", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const res = await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "jwt-token", state: "wrong-state" }), - }); - expect(res.status).toBe(400); - const json = await res.json(); - expect(json).toEqual({ ok: false, reason: "state_mismatch" }); - expect(sentryCaptureMessageMock).toHaveBeenCalledWith( - expect.stringContaining("state mismatch"), - expect.objectContaining({ - level: "warning", - tags: { scope: "auth-flow.state_mismatch" }, - }) - ); - expect(setTokenMock).not.toHaveBeenCalled(); - const result = await signIn; - expect(result).toBeNull(); - } finally { - restore(); - } + 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_API_URL: undefined, + 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("accepts a valid POST, persists the token, and resolves with the JWT", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const state = extractState(); - if (!state) { - throw new Error("no state"); - } - const res = await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "real-jwt-token", state }), - }); - expect(res.status).toBe(204); - expect(setTokenMock).toHaveBeenCalledWith("real-jwt-token"); - const result = await signIn; - expect(result).toBe("real-jwt-token"); - } finally { - restore(); + 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_API_URL: undefined, + 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_API_URL: undefined, + 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(); + } + }, 5_000); - it("returns 500 and captures Sentry when setToken fails to persist", async () => { - setTokenMock.mockImplementationOnce(() => false); - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const state = extractState(); - if (!state) { - throw new Error("no state"); - } - const res = await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "jwt", state }), - }); - expect(res.status).toBe(500); - const json = await res.json(); - expect(json).toEqual({ ok: false, reason: "persist_failed" }); - expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( - expect.any(Error), - expect.objectContaining({ - tags: { scope: "auth-flow.persist_failed" }, - }) - ); - const result = await signIn; - expect(result).toBeNull(); - } finally { - restore(); - } + it("inflight singleton: concurrent beginSignIn calls share a single promise", async () => { + const { mod, restore } = await loadAuthFlow({ + NODE_ENV: "test", + LIGHTFAST_API_URL: undefined, + 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(); + } + }); +}); - it("rejects bodies larger than MAX_BODY_BYTES (16 KiB)", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const { callback, signIn } = await startFlowAndCaptureCallback(mod); - const huge = "a".repeat(32 * 1024); - // Server destroys the socket mid-stream — the fetch either rejects - // or returns a 500. Both are acceptable; what matters is we don't - // happily accept 32 KiB of token. - try { - const res = await fetch(`${callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: huge, state: "any" }), - }); - expect(res.status).toBeGreaterThanOrEqual(400); - } catch { - // Socket destruction → fetch rejects. Acceptable. - } - expect(setTokenMock).not.toHaveBeenCalled(); - const result = await signIn; - expect(result).toBeNull(); - } 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_API_URL: undefined, + 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_API_URL: undefined, + 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("concurrency", () => { - it("serializes concurrent beginSignIn calls — second caller gets the same promise", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const first = mod.beginSignIn(); - const second = mod.beginSignIn(); - expect(first).toBe(second); - - // Only one call to openExternal was queued - for (let i = 0; i < 100; i++) { - if (shellOpenExternalMock.mock.calls.length > 0) { - break; - } - await new Promise((r) => setTimeout(r, 10)); - } - expect(shellOpenExternalMock).toHaveBeenCalledTimes(1); +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", + LIGHTFAST_API_URL: undefined, + }); + try { + mod.maybeAutoBeginSignIn(); + expect(events).toHaveLength(0); + expect(shellOpenExternalMock).not.toHaveBeenCalled(); + expect(getTokenMock).not.toHaveBeenCalled(); + } finally { + restoreStdout(); + restore(); + } + }); - // Settle — both callers should see the same result. - const state = extractState(); - const firstCall = shellOpenExternalMock.mock.calls[0]; - if (!firstCall) { - throw new Error("no openExternal call"); - } - const callbackUrl = new URL(firstCall[0] as string).searchParams.get( - "callback" - ); - if (!(state && callbackUrl)) { - throw new Error("missing params"); - } - await fetch(callbackUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "shared-token", state }), - }); - const [r1, r2] = await Promise.all([first, second]); - expect(r1).toBe("shared-token"); - expect(r2).toBe("shared-token"); - } finally { - 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_API_URL: undefined, + 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("clears inflight after settle so the next sign-in starts a fresh flow", async () => { - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - // First flow — let it fail via state mismatch. - const first = await startFlowAndCaptureCallback(mod); - await fetch(`${first.callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "a", state: "bad" }), - }); - await first.signIn; - shellOpenExternalMock.mockClear(); - - // Second flow — should open a new browser tab. - const second = await startFlowAndCaptureCallback(mod); - expect(shellOpenExternalMock).toHaveBeenCalledTimes(1); - expect(second.callback.port).not.toBe(first.callback.port); - - // Close it. - await fetch(`${second.callback.origin}/callback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "http://localhost:3024", - }, - body: JSON.stringify({ token: "b", state: "bad" }), - }); - await second.signIn; - } finally { - 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_API_URL: undefined, + 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("timeout", () => { - it("resolves null and fires auth-flow.timeout Sentry message after 5 minutes", async () => { - vi.useFakeTimers(); - const { mod, restore } = await loadAuthFlow({ - NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, - }); - try { - const signIn = mod.beginSignIn(); - // Advance microtasks so the server binds - await vi.advanceTimersByTimeAsync(100); - // Advance past the 5-minute timeout - await vi.advanceTimersByTimeAsync(5 * 60_000 + 1000); - const result = await signIn; - expect(result).toBeNull(); - expect(sentryCaptureMessageMock).toHaveBeenCalledWith( - expect.stringContaining("timeout"), - expect.objectContaining({ - level: "warning", - tags: { scope: "auth-flow.timeout" }, - }) - ); - } finally { - vi.useRealTimers(); - 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_API_URL: undefined, + 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"); } - }, 10_000); + 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__/protocol.test.ts b/apps/desktop/src/main/__tests__/protocol.test.ts new file mode 100644 index 000000000..9dd093c2b --- /dev/null +++ b/apps/desktop/src/main/__tests__/protocol.test.ts @@ -0,0 +1,330 @@ +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; +}) { + vi.resetModules(); + eventHandlers.clear(); + setAsDefaultProtocolClientMock.mockClear(); + isPackagedFlag = opts?.isPackaged ?? false; + + const prevArgv = process.argv; + const prevPlatform = process.platform; + if (opts?.argv) { + process.argv = opts.argv; + } + if (opts?.platform) { + Object.defineProperty(process, "platform", { + value: opts.platform, + configurable: true, + }); + } + whenReadyResolved = true; + + const mod = await import("../protocol"); + return { + mod, + restore: () => { + process.argv = prevArgv; + Object.defineProperty(process, "platform", { + value: prevPlatform, + 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("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 ad96dfe9f..8d91246f1 100644 --- a/apps/desktop/src/main/auth-flow.ts +++ b/apps/desktop/src/main/auth-flow.ts @@ -1,24 +1,37 @@ -import { randomBytes } from "node:crypto"; -import { - createServer, - type IncomingMessage, - type Server, - type ServerResponse, -} from "node:http"; +import { createHash, randomBytes } from "node:crypto"; import * as Sentry from "@sentry/electron/main"; import { shell } from "electron"; import { z } from "zod"; -import { setToken } from "./auth-store"; +import { getToken, setToken } from "./auth-store"; +import { getProtocolScheme, onProtocolUrl } from "./protocol"; -const SIGNIN_TIMEOUT_MS = 5 * 60_000; -const LOOPBACK_HOST = "127.0.0.1"; -const CALLBACK_PATH = "/callback"; -const MAX_BODY_BYTES = 16 * 1024; +const DEFAULT_SIGNIN_TIMEOUT_MS = 5 * 60_000; -const callbackBodySchema = z.object({ - token: z.string().min(1), - state: z.string().min(1), -}); +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 ( @@ -29,62 +42,35 @@ function getApiOrigin(): string { ); } -const ALLOWED_ORIGIN = getApiOrigin(); +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) }); -console.log("[auth-flow] ALLOWED_ORIGIN =", ALLOWED_ORIGIN); +let inflight: Promise | null = null; +let pendingSigninUrl: string | null = null; +const urlListeners = new Set<(url: string | null) => void>(); -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"); - res.setHeader("Access-Control-Allow-Private-Network", "true"); +export function getPendingSigninUrl(): string | null { + return pendingSigninUrl; } -async function readJsonBody(req: IncomingMessage): Promise { - const chunks: Buffer[] = []; - let total = 0; - for await (const chunk of req) { - const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - total += buf.length; - if (total > MAX_BODY_BYTES) { - req.destroy(); - throw new Error("payload too large"); - } - chunks.push(buf); - } - return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; +export function onPendingSigninUrl( + listener: (url: string | null) => void +): () => void { + urlListeners.add(listener); + return () => urlListeners.delete(listener); } -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 setPendingSigninUrl(url: string | null): void { + pendingSigninUrl = url; + for (const listener of urlListeners) { + listener(url); } - return { server, port: address.port }; } -let inflight: Promise | null = null; - export function beginSignIn(): Promise { if (inflight) { return inflight; @@ -94,24 +80,58 @@ export function beginSignIn(): Promise { return await runSignIn(); } finally { inflight = null; + setPendingSigninUrl(null); } })(); return inflight; } -async function runSignIn(): Promise { - const state = randomBytes(32).toString("hex"); +// 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(); +} - let bound: { server: Server; port: number }; +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); - Sentry.captureException(error, { tags: { scope: "auth-flow.bind" } }); - 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 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; @@ -121,7 +141,7 @@ async function runSignIn(): Promise { } settled = true; clearTimeout(timer); - server.close(); + unsubscribe(); resolve(token); }; @@ -130,56 +150,40 @@ async function runSignIn(): Promise { level: "warning", tags: { scope: "auth-flow.timeout" }, }); + emitAgentEvent({ event: "auth_signin_failed", reason: "timeout" }); settle(null); - }, SIGNIN_TIMEOUT_MS); + }, getSigninTimeoutMs()); - server.on("request", async (req, res) => { + const unsubscribe = onProtocolUrl(async (rawUrl) => { try { - const origin = req.headers.origin ?? ""; - if (origin !== ALLOWED_ORIGIN) { - Sentry.captureMessage("auth-flow: forbidden origin", { - level: "warning", - tags: { scope: "auth-flow.forbidden_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"); + if (!matchesAuthCallback(rawUrl, scheme)) { 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); + const url = new URL(rawUrl); + const parsed = callbackSchema.safeParse({ + code: url.searchParams.get("code"), + state: url.searchParams.get("state"), + }); 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) { + if (parsed.data.state !== state) { Sentry.captureMessage("auth-flow: state mismatch", { level: "warning", tags: { scope: "auth-flow.state_mismatch" }, }); - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: false, reason: "state_mismatch" })); + return; + } + const token = await exchangeCode( + apiOrigin, + parsed.data.code, + codeVerifier + ); + if (!token) { + emitAgentEvent({ + event: "auth_signin_failed", + reason: "exchange_failed", + }); settle(null); return; } @@ -188,44 +192,40 @@ async function runSignIn(): Promise { 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; } - res.writeHead(persisted ? 204 : 500, { - "Content-Type": "application/json", - }); - res.end( - persisted - ? "" - : JSON.stringify({ ok: false, reason: "persist_failed" }) - ); - settle(persisted ? token : null); + emitAgentEvent({ event: "auth_signed_in" }); + settle(token); } catch (error) { - console.error("[auth-flow] loopback handler error", error); + console.error("[auth-flow] callback handler error", error); Sentry.captureException(error, { tags: { scope: "auth-flow.handler_error" }, }); - res.writeHead(500, { "Content-Type": "text/plain" }); - res.end("Internal Server Error"); + emitAgentEvent({ + event: "auth_signin_failed", + reason: "handler_error", + }); settle(null); } }); - server.on("error", (error) => { - console.error("[auth-flow] loopback server error", error); - Sentry.captureException(error, { - tags: { scope: "auth-flow.server_error" }, - }); - settle(null); - }); - - const signInUrl = new URL("/desktop/auth", ALLOWED_ORIGIN); - signInUrl.searchParams.set("state", state); - signInUrl.searchParams.set("callback", callbackUrl); + setPendingSigninUrl(signinUrl.toString()); - console.log( - `[auth-flow] signin url=${signInUrl.toString()} 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; + } - shell.openExternal(signInUrl.toString()).catch((error) => { + shell.openExternal(signinUrl.toString()).catch((error) => { console.error("[auth-flow] shell.openExternal failed", error); Sentry.captureException(error, { tags: { scope: "auth-flow.open_external" }, @@ -234,3 +234,34 @@ async function runSignIn(): Promise { }); }); } + +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; + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2992437fe..13333cd7f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -9,7 +9,12 @@ import { } from "electron"; import contextMenu from "electron-context-menu"; import { IpcChannels, type SystemThemeVariant } from "../shared/ipc"; -import { beginSignIn } from "./auth-flow"; +import { + beginSignIn, + getPendingSigninUrl, + maybeAutoBeginSignIn, + onPendingSigninUrl, +} from "./auth-flow"; import { createAuthFocusGate } from "./auth-focus-gate"; import { getAuthSnapshot, @@ -19,6 +24,7 @@ import { } from "./auth-store"; import { getBuildInfo } from "./build-info"; import { buildApplicationMenu } from "./menu"; +import { registerProtocolHandler } from "./protocol"; import { getSentryInitOptions, initSentry } from "./sentry"; import { getSettings, @@ -216,6 +222,7 @@ function registerIpcHandlers(): void { ipcMain.handle(IpcChannels.authGetToken, () => getAuthToken()); ipcMain.handle(IpcChannels.authSignIn, () => beginSignIn()); ipcMain.handle(IpcChannels.authSignOut, () => signOutAuth()); + ipcMain.handle(IpcChannels.authPendingSigninUrl, () => getPendingSigninUrl()); } function broadcastThemeUpdates(): void { @@ -351,6 +358,7 @@ app.whenReady().then(() => { registerIpcHandlers(); registerUpdaterIpc(); + registerProtocolHandler(() => BrowserWindow.getAllWindows()); broadcastThemeUpdates(); registerGlobalShortcuts({ toggleHud: toggleHudWindow }); applySettings(getSettings()); @@ -373,6 +381,15 @@ app.whenReady().then(() => { } 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..47c46187a --- /dev/null +++ b/apps/desktop/src/main/protocol.ts @@ -0,0 +1,63 @@ +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(); + 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/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b80672a57..80287275b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -40,6 +40,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, sentryInit, diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 15557c082..d20c90287 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -23,6 +23,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"), } as const; export type IpcChannel = (typeof IpcChannels)[keyof typeof IpcChannels]; @@ -105,6 +107,10 @@ export interface LightfastBridge { signIn: () => 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/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. From f89a3023c37e2d73e92fcefe503e0b258fc150e0 Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:16:14 +1000 Subject: [PATCH 09/18] chore(deps): reconcile pnpm-lock catalog entries after merge Post-merge lockfile reconciliation: catalog entries for @vitejs/plugin-react ^6.0.1 and happy-dom ^20.9.0 (defined in pnpm-workspace.yaml on this branch) were not captured when the merge took main's lockfile + pnpm install. Vitest's transitive vite peer also tracks to 8.0.10 to match the desktop upgrade. Entire-Checkpoint: 3c27d89b128e --- pnpm-lock.yaml | 1020 +++++++++++++++++++++++++----------------------- 1 file changed, 530 insertions(+), 490 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bdf26584..0492aafb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ catalogs: '@vercel/related-projects': specifier: ^1.0.1 version: 1.0.1 + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1 '@vitest/coverage-v8': specifier: ^4.1.4 version: 4.1.4 @@ -93,6 +96,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 @@ -272,7 +278,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: @@ -363,7 +369,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: @@ -456,7 +462,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/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/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) @@ -501,13 +507,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.0.1 '@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) @@ -525,10 +531,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) react: specifier: ^19.2.5 version: 19.2.5 @@ -563,6 +569,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 @@ -575,6 +584,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) @@ -588,7 +600,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:' @@ -607,7 +619,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: @@ -680,7 +692,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 @@ -708,15 +720,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) @@ -736,20 +745,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.3.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: @@ -767,7 +782,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/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/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) @@ -797,13 +812,13 @@ importers: version: 1.0.1 '@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 @@ -867,7 +882,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/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/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) @@ -897,13 +912,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.0.1 '@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 @@ -915,7 +930,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) @@ -942,7 +957,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 @@ -1021,7 +1036,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/ai-sdk: dependencies: @@ -1073,7 +1088,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/cli: dependencies: @@ -1131,7 +1146,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: @@ -1213,7 +1228,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: @@ -1344,7 +1359,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: @@ -1434,7 +1449,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: @@ -1578,7 +1593,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 @@ -1678,7 +1693,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: @@ -1718,7 +1733,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 @@ -1891,7 +1906,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) @@ -2025,7 +2040,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 @@ -2074,7 +2089,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 @@ -2240,7 +2255,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: @@ -2284,7 +2299,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 @@ -2439,7 +2454,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 @@ -2794,10 +2809,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'} @@ -2824,18 +2835,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'} @@ -3247,10 +3246,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'} @@ -3275,15 +3270,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' @@ -3292,12 +3296,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'} @@ -3328,12 +3326,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'} @@ -3364,12 +3356,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'} @@ -3400,12 +3386,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'} @@ -3436,12 +3416,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'} @@ -3472,12 +3446,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'} @@ -3508,12 +3476,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'} @@ -3544,12 +3506,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'} @@ -3580,12 +3536,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'} @@ -3616,12 +3566,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'} @@ -3652,12 +3596,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'} @@ -3688,12 +3626,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'} @@ -3724,12 +3656,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'} @@ -3760,12 +3686,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'} @@ -3796,12 +3716,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'} @@ -3832,12 +3746,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'} @@ -3868,12 +3776,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'} @@ -3928,12 +3830,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'} @@ -3988,12 +3884,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'} @@ -4042,12 +3932,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'} @@ -4078,12 +3962,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'} @@ -4114,12 +3992,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'} @@ -4150,12 +4022,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'} @@ -4800,6 +4666,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==} @@ -5999,6 +5871,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] @@ -7123,30 +6998,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} @@ -7154,6 +7059,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} @@ -7161,6 +7073,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} @@ -7168,6 +7101,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} @@ -7175,35 +7115,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'} @@ -8281,10 +8254,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'} @@ -8426,21 +8418,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==} @@ -8963,11 +8946,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==} @@ -9200,6 +9190,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'} @@ -9234,6 +9228,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'} @@ -10153,6 +10150,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==} @@ -10341,8 +10341,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.3.0: + resolution: {integrity: sha512-2Q5aeocmFdeheZGDUTrAvSR3t+n0c3d104AJWWEnt7syJU0tE4VdibMYaPtQ47QuXSoUf0/xSsfUUvu/uSXIfg==} engines: {node: '>= 12.20.55'} hasBin: true @@ -10480,11 +10480,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'} @@ -12083,6 +12078,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] @@ -13150,6 +13149,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'} @@ -13291,6 +13294,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==} @@ -13309,10 +13315,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'} @@ -13611,6 +13613,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'} @@ -14575,46 +14582,16 @@ 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} - 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 - peerDependenciesMeta: - '@types/node': - optional: true - less: - 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==} + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@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 - lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' @@ -14625,12 +14602,14 @@ packages: peerDependenciesMeta: '@types/node': optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -15080,7 +15059,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: @@ -15617,8 +15596,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': {} @@ -15638,16 +15615,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': @@ -15913,7 +15880,7 @@ snapshots: '@clerk/backend': 3.2.13(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@clerk/react': 6.4.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@clerk/shared': 4.8.2(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 @@ -16010,9 +15977,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 @@ -16050,7 +16017,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 @@ -16061,7 +16028,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 @@ -16239,13 +16206,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 @@ -16331,13 +16298,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 @@ -16417,12 +16377,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 @@ -16433,6 +16404,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 @@ -16443,9 +16419,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 @@ -16461,9 +16434,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 @@ -16479,9 +16449,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 @@ -16497,9 +16464,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 @@ -16515,9 +16479,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 @@ -16533,9 +16494,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 @@ -16551,9 +16509,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 @@ -16569,9 +16524,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 @@ -16587,9 +16539,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 @@ -16605,9 +16554,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 @@ -16623,9 +16569,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 @@ -16641,9 +16584,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 @@ -16659,9 +16599,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 @@ -16677,9 +16614,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 @@ -16695,9 +16629,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 @@ -16713,9 +16644,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 @@ -16731,9 +16659,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 @@ -16761,9 +16686,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 @@ -16791,9 +16713,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 @@ -16818,9 +16737,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 @@ -16836,9 +16752,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 @@ -16854,9 +16767,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 @@ -16872,9 +16782,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 @@ -17426,7 +17333,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 @@ -17594,6 +17501,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 @@ -17656,7 +17570,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': @@ -19124,6 +19038,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 @@ -20245,54 +20161,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) @@ -20675,6 +20642,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/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/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 @@ -20688,7 +20680,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: @@ -20836,6 +20828,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) @@ -21383,6 +21383,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 @@ -21392,6 +21403,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 @@ -21536,28 +21557,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/aria-query@5.0.4': {} - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@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: @@ -21862,7 +21864,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)': @@ -21954,7 +21956,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' @@ -22039,7 +22041,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 @@ -22056,14 +22058,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 @@ -22080,10 +22082,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 @@ -22222,7 +22224,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': @@ -22238,10 +22240,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 @@ -22250,9 +22252,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' @@ -22260,17 +22262,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: @@ -22284,7 +22281,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@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@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: @@ -22295,21 +22292,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: @@ -22338,7 +22335,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@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@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/utils@4.1.4': dependencies: @@ -22554,6 +22551,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -22600,6 +22599,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: {} @@ -23501,6 +23504,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: @@ -23646,10 +23651,10 @@ snapshots: - supports-color optional: true - electron@39.8.5: + electron@41.3.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 @@ -23796,32 +23801,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 @@ -24281,7 +24260,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) @@ -24453,14 +24432,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 @@ -24484,9 +24463,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 @@ -24552,7 +24531,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' @@ -24586,7 +24565,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: @@ -25189,7 +25168,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' @@ -25703,6 +25682,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + macos-alias@0.2.12: dependencies: nan: 2.26.2 @@ -26450,7 +26431,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 @@ -26459,7 +26440,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 @@ -26551,13 +26532,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: @@ -27078,6 +27059,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 @@ -27229,6 +27216,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): @@ -27258,8 +27247,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): @@ -27656,6 +27643,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 @@ -27717,6 +27725,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: {} @@ -28222,10 +28231,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: {} @@ -28315,7 +28326,7 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.4.0(esbuild@0.25.0)(webpack@5.105.0): + terser-webpack-plugin@5.4.0(esbuild@0.25.0)(webpack@5.105.0(esbuild@0.25.0)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 @@ -28325,6 +28336,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 @@ -28787,55 +28808,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.10 - 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.10 - 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 @@ -28852,7 +28860,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 @@ -28865,10 +28873,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 @@ -28885,7 +28893,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 @@ -28991,7 +28999,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.2 - terser-webpack-plugin: 5.4.0(esbuild@0.25.0)(webpack@5.105.0) + terser-webpack-plugin: 5.4.0(esbuild@0.25.0)(webpack@5.105.0(esbuild@0.25.0)) watchpack: 2.5.1 webpack-sources: 3.3.4 transitivePeerDependencies: @@ -29031,6 +29039,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 From 38ac764ddb75dcc4218980ded1a2fc0ab083398b Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Wed, 6 May 2026 15:47:59 +1000 Subject: [PATCH 10/18] =?UTF-8?q?fix(desktop):=20PR=20627=20review=20fixes?= =?UTF-8?q?=20=E2=80=94=20Sentry=20vendor=20wrap,=20blockers,=20Biome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves CodeRabbit review feedback on PR #627 across three phases of work (Phase 2/3/4 of thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md): Phase 2 — vendor abstraction: route all direct @sentry/* SDK imports through @vendor/observability. New façade modules sentry-electron-main, sentry-browser, sentry-nextjs use selective named re-exports matching the existing sentry.ts pattern. Updates 8 call sites (5 desktop, 3 next.js instrumentation) plus the auth-flow vi.mock target. Phase 3 — CodeRabbit blockers: - Hoist registerProtocolHandler() out of app.whenReady() so macOS cold-start open-url events delivered before whenReady are not lost. - Add callbackInFlight guard in auth-flow.ts to prevent duplicate exchangeCode calls when two open-url events arrive during the await window. - Wrap rmSync() in auth-store.load() with try/catch via purgePersisted helper so filesystem errors no longer crash startup. - Conditional Windows three-arg setAsDefaultProtocolClient for dev launches. - Single bearer-token parser: verifyCliJwt now returns { userId, jwt } so code/route.ts uses session.jwt directly (drops divergent regex re-parse). Phase 4 — Biome lint hygiene: - ultracite fix on 6 plan-listed files plus 3 organizeImports-only files introduced by Phase 2 import changes. - biome-ignore lint/correctness/noGlobalDirnameFilename on factory.ts:16 — Vite 8 emits the main bundle as CJS where import.meta.dirname is undefined. - biome-ignore lint/performance/noDelete on protocol.test.ts:73 — test cleanup must remove the property entirely; assigning undefined leaves a defined property where there was none. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../auth/_components/desktop-auth-client.tsx | 2 +- apps/app/src/app/api/cli/lib/verify-jwt.ts | 8 +- .../app/api/desktop/auth/code/route.test.ts | 27 +- .../src/app/api/desktop/auth/code/route.ts | 5 +- .../api/desktop/auth/exchange/route.test.ts | 4 +- .../app/api/desktop/auth/lib/code-store.ts | 6 +- apps/app/src/instrumentation.ts | 8 +- apps/desktop/package.json | 1 + .../src/main/__tests__/auth-flow.test.ts | 77 ++- .../src/main/__tests__/protocol.test.ts | 107 +++- apps/desktop/src/main/auth-flow.ts | 36 +- apps/desktop/src/main/auth-store.ts | 33 +- apps/desktop/src/main/index.ts | 6 +- apps/desktop/src/main/protocol.ts | 17 +- apps/desktop/src/main/sentry.ts | 8 +- apps/desktop/src/main/windows/factory.ts | 5 +- apps/desktop/src/renderer/src/main.ts | 4 +- .../src/renderer/src/react/app-shell.tsx | 4 +- apps/platform/src/instrumentation.ts | 8 +- apps/www/src/instrumentation.ts | 2 +- pnpm-lock.yaml | 22 +- .../plans/2026-05-06-pr627-merge-readiness.md | 589 ++++++++++++++++++ vendor/observability/package.json | 15 + 23 files changed, 887 insertions(+), 107 deletions(-) create mode 100644 thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md 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 ee7458613..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 @@ -15,7 +15,7 @@ export function DesktopAuthClient() { 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) { + if (!(state && codeChallenge) || method !== "S256" || !redirectUri) { return null; } if (!ALLOWED_REDIRECT_URIS.has(redirectUri)) { 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 index 6821ae083..d2e10b294 100644 --- a/apps/app/src/app/api/desktop/auth/code/route.test.ts +++ b/apps/app/src/app/api/desktop/auth/code/route.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const verifyCliJwtMock = vi.fn<(req: Request) => Promise<{ userId: string } | null>>(); +const verifyCliJwtMock = + vi.fn<(req: Request) => Promise<{ userId: string; jwt: string } | null>>(); vi.mock("../../../cli/lib/verify-jwt", () => ({ verifyCliJwt: (req: Request) => verifyCliJwtMock(req), })); @@ -54,7 +55,7 @@ describe("POST /api/desktop/auth/code", () => { }); it("returns 400 when body fails schema (missing fields)", async () => { - verifyCliJwtMock.mockResolvedValue({ userId: "user_123" }); + verifyCliJwtMock.mockResolvedValue({ userId: "user_123", jwt: "fake-jwt" }); const res = await POST(makeReq({ state: "x" })); @@ -64,7 +65,7 @@ describe("POST /api/desktop/auth/code", () => { }); it("returns 400 when redirect_uri is not in the allowlist", async () => { - verifyCliJwtMock.mockResolvedValue({ userId: "user_123" }); + verifyCliJwtMock.mockResolvedValue({ userId: "user_123", jwt: "fake-jwt" }); const res = await POST( makeReq({ ...VALID_BODY, redirect_uri: "https://evil.com/callback" }) @@ -76,7 +77,7 @@ describe("POST /api/desktop/auth/code", () => { }); it("returns 400 when code_challenge_method is not S256", async () => { - verifyCliJwtMock.mockResolvedValue({ userId: "user_123" }); + verifyCliJwtMock.mockResolvedValue({ userId: "user_123", jwt: "fake-jwt" }); const res = await POST( makeReq({ ...VALID_BODY, code_challenge_method: "plain" }) @@ -87,7 +88,7 @@ describe("POST /api/desktop/auth/code", () => { }); it("returns 400 when body is not valid JSON", async () => { - verifyCliJwtMock.mockResolvedValue({ userId: "user_123" }); + verifyCliJwtMock.mockResolvedValue({ userId: "user_123", jwt: "fake-jwt" }); const res = await POST(makeReq("not json")); @@ -96,7 +97,7 @@ describe("POST /api/desktop/auth/code", () => { }); it("issues a code and returns it on happy path with lightfast:// redirect", async () => { - verifyCliJwtMock.mockResolvedValue({ userId: "user_123" }); + verifyCliJwtMock.mockResolvedValue({ userId: "user_123", jwt: "fake-jwt" }); const res = await POST( makeReq({ ...VALID_BODY, redirect_uri: "lightfast://auth/callback" }) @@ -114,22 +115,28 @@ describe("POST /api/desktop/auth/code", () => { }); }); - it("strips Bearer prefix case-insensitively when storing the JWT", async () => { - verifyCliJwtMock.mockResolvedValue({ userId: "user_456" }); + 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 alt-jwt", + Authorization: "Bearer some-other-string", }, body: JSON.stringify(VALID_BODY), }) ); expect(issueCodeMock).toHaveBeenCalledWith( - expect.objectContaining({ jwt: "alt-jwt" }) + 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 index 24b9da23f..b7337f378 100644 --- a/apps/app/src/app/api/desktop/auth/code/route.ts +++ b/apps/app/src/app/api/desktop/auth/code/route.ts @@ -27,12 +27,9 @@ export async function POST(req: Request) { 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, + jwt: session.jwt, state: parsed.data.state, codeChallenge: parsed.data.code_challenge, redirectUri: parsed.data.redirect_uri, 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 index a62fe056f..ee5ce39ca 100644 --- a/apps/app/src/app/api/desktop/auth/exchange/route.test.ts +++ b/apps/app/src/app/api/desktop/auth/exchange/route.test.ts @@ -65,9 +65,7 @@ describe("POST /api/desktop/auth/exchange", () => { consumeCodeMock.mockResolvedValue(goodRecord); const tampered = "x".repeat(64); - const res = await POST( - makeReq({ code: CODE, code_verifier: tampered }) - ); + const res = await POST(makeReq({ code: CODE, code_verifier: tampered })); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: "invalid_verifier" }); 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 index d83c57497..a67d26adf 100644 --- a/apps/app/src/app/api/desktop/auth/lib/code-store.ts +++ b/apps/app/src/app/api/desktop/auth/lib/code-store.ts @@ -9,11 +9,11 @@ const PREFIX = "desktop_auth_code:"; const TTL_SECONDS = 30; export interface CodeRecord { - userId: string; - jwt: string; - state: string; codeChallenge: string; + jwt: string; redirectUri: string; + state: string; + userId: string; } export async function issueCode(record: CodeRecord): Promise { 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/desktop/package.json b/apps/desktop/package.json index 12a9a8880..8e57cb7d3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -57,6 +57,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 index a68c3599c..a291969f1 100644 --- a/apps/desktop/src/main/__tests__/auth-flow.test.ts +++ b/apps/desktop/src/main/__tests__/auth-flow.test.ts @@ -23,7 +23,7 @@ vi.mock("electron", () => ({ }, })); -vi.mock("@sentry/electron/main", () => ({ +vi.mock("@vendor/observability/sentry-electron-main", () => ({ captureException: (error: unknown, options?: unknown) => sentryCaptureExceptionMock(error, options), captureMessage: (message: string, options?: unknown) => @@ -81,10 +81,10 @@ async function loadAuthFlow(env?: Record) { } interface CapturedSignin { - url: URL; - state: string; codeChallenge: string; redirectUri: string; + state: string; + url: URL; } async function captureSigninUrl( @@ -141,7 +141,6 @@ function spyStdout(): { events: AuthLine[]; restore: () => void } { const parsed = JSON.parse(line); if (parsed && typeof parsed === "object" && "event" in parsed) { events.push(parsed as AuthLine); - continue; } } catch { // not JSON — fall through to original @@ -307,13 +306,11 @@ describe("auth-flow PKCE sign-in", () => { }); 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 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", @@ -449,7 +446,60 @@ describe("auth-flow PKCE sign-in", () => { restoreStdout(); restore(); } - }, 5_000); + }, 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", + LIGHTFAST_API_URL: undefined, + }); + 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({ @@ -620,8 +670,7 @@ describe("auth-flow event grammar", () => { (e) => e.event === "auth_signin_url" ).length; const terminalCount = events.filter( - (e) => - e.event === "auth_signed_in" || e.event === "auth_signin_failed" + (e) => e.event === "auth_signed_in" || e.event === "auth_signin_failed" ).length; expect(urlCount).toBe(1); expect(terminalCount).toBe(1); diff --git a/apps/desktop/src/main/__tests__/protocol.test.ts b/apps/desktop/src/main/__tests__/protocol.test.ts index 9dd093c2b..b8ddc12be 100644 --- a/apps/desktop/src/main/__tests__/protocol.test.ts +++ b/apps/desktop/src/main/__tests__/protocol.test.ts @@ -31,6 +31,8 @@ async function loadProtocol(opts?: { isPackaged?: boolean; argv?: string[]; platform?: NodeJS.Platform; + whenReady?: boolean; + defaultApp?: boolean; }) { vi.resetModules(); eventHandlers.clear(); @@ -39,6 +41,7 @@ async function loadProtocol(opts?: { const prevArgv = process.argv; const prevPlatform = process.platform; + const prevDefaultApp = (process as { defaultApp?: boolean }).defaultApp; if (opts?.argv) { process.argv = opts.argv; } @@ -48,7 +51,13 @@ async function loadProtocol(opts?: { configurable: true, }); } - whenReadyResolved = true; + if (opts?.defaultApp !== undefined) { + Object.defineProperty(process, "defaultApp", { + value: opts.defaultApp, + configurable: true, + }); + } + whenReadyResolved = opts?.whenReady ?? true; const mod = await import("../protocol"); return { @@ -59,14 +68,22 @@ async function loadProtocol(opts?: { 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; -}): { +function makeWindow(overrides?: { destroyed?: boolean; minimized?: boolean }): { win: { isDestroyed: () => boolean; isMinimized: () => boolean; @@ -128,6 +145,63 @@ describe("protocol", () => { } }); + 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 { @@ -202,14 +276,11 @@ describe("protocol", () => { if (!handler) { throw new Error("second-instance handler not registered"); } - handler( - {}, - [ - "/path/to/electron", - "--some-flag", - "lightfast-dev://auth/callback?code=z", - ] - ); + handler({}, [ + "/path/to/electron", + "--some-flag", + "lightfast-dev://auth/callback?code=z", + ]); expect(listener).toHaveBeenCalledWith( "lightfast-dev://auth/callback?code=z" @@ -244,10 +315,7 @@ describe("protocol", () => { mod.onProtocolUrl(vi.fn()); const handler = eventHandlers.get("open-url"); - handler?.( - { preventDefault: vi.fn() }, - "lightfast-dev://auth/callback" - ); + handler?.({ preventDefault: vi.fn() }, "lightfast-dev://auth/callback"); expect(win.show).toHaveBeenCalled(); expect(win.focus).toHaveBeenCalled(); @@ -264,10 +332,7 @@ describe("protocol", () => { mod.registerProtocolHandler(() => [win] as never); const handler = eventHandlers.get("open-url"); - handler?.( - { preventDefault: vi.fn() }, - "lightfast-dev://auth/callback" - ); + handler?.({ preventDefault: vi.fn() }, "lightfast-dev://auth/callback"); expect(win.restore).toHaveBeenCalled(); expect(win.show).toHaveBeenCalled(); diff --git a/apps/desktop/src/main/auth-flow.ts b/apps/desktop/src/main/auth-flow.ts index 8d91246f1..71c728d4c 100644 --- a/apps/desktop/src/main/auth-flow.ts +++ b/apps/desktop/src/main/auth-flow.ts @@ -1,5 +1,8 @@ import { createHash, randomBytes } from "node:crypto"; -import * as Sentry from "@sentry/electron/main"; +import { + captureException, + captureMessage, +} from "@vendor/observability/sentry-electron-main"; import { shell } from "electron"; import { z } from "zod"; import { getToken, setToken } from "./auth-store"; @@ -135,6 +138,13 @@ async function runSignIn(): Promise { 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; @@ -146,7 +156,7 @@ async function runSignIn(): Promise { }; const timer = setTimeout(() => { - Sentry.captureMessage("auth-flow: sign-in timeout", { + captureMessage("auth-flow: sign-in timeout", { level: "warning", tags: { scope: "auth-flow.timeout" }, }); @@ -156,7 +166,11 @@ async function runSignIn(): Promise { const unsubscribe = onProtocolUrl(async (rawUrl) => { try { - if (!matchesAuthCallback(rawUrl, scheme)) { + if ( + settled || + callbackInFlight || + !matchesAuthCallback(rawUrl, scheme) + ) { return; } const url = new URL(rawUrl); @@ -168,17 +182,21 @@ async function runSignIn(): Promise { return; } if (parsed.data.state !== state) { - Sentry.captureMessage("auth-flow: state mismatch", { + captureMessage("auth-flow: state mismatch", { level: "warning", tags: { scope: "auth-flow.state_mismatch" }, }); return; } + callbackInFlight = true; const token = await exchangeCode( apiOrigin, parsed.data.code, codeVerifier ); + if (settled) { + return; + } if (!token) { emitAgentEvent({ event: "auth_signin_failed", @@ -189,7 +207,7 @@ async function runSignIn(): Promise { } const persisted = setToken(token); if (!persisted) { - Sentry.captureException(new Error("auth-flow: persist failed"), { + captureException(new Error("auth-flow: persist failed"), { tags: { scope: "auth-flow.persist_failed" }, }); emitAgentEvent({ @@ -203,7 +221,7 @@ async function runSignIn(): Promise { settle(token); } catch (error) { console.error("[auth-flow] callback handler error", error); - Sentry.captureException(error, { + captureException(error, { tags: { scope: "auth-flow.handler_error" }, }); emitAgentEvent({ @@ -227,7 +245,7 @@ async function runSignIn(): Promise { shell.openExternal(signinUrl.toString()).catch((error) => { console.error("[auth-flow] shell.openExternal failed", error); - Sentry.captureException(error, { + captureException(error, { tags: { scope: "auth-flow.open_external" }, }); settle(null); @@ -247,7 +265,7 @@ async function exchangeCode( body: JSON.stringify({ code, code_verifier: codeVerifier }), }); if (!response.ok) { - Sentry.captureMessage("auth-flow: exchange non-ok", { + captureMessage("auth-flow: exchange non-ok", { level: "warning", tags: { scope: "auth-flow.exchange_non_ok", @@ -259,7 +277,7 @@ async function exchangeCode( const json = exchangeResponseSchema.safeParse(await response.json()); return json.success ? json.data.token : null; } catch (error) { - Sentry.captureException(error, { + captureException(error, { tags: { scope: "auth-flow.exchange_network" }, }); return null; diff --git a/apps/desktop/src/main/auth-store.ts b/apps/desktop/src/main/auth-store.ts index 5ff49f49e..e7bc77c38 100644 --- a/apps/desktop/src/main/auth-store.ts +++ b/apps/desktop/src/main/auth-store.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import * as Sentry from "@sentry/electron/main"; +import { captureException } from "@vendor/observability/sentry-electron-main"; import { app, safeStorage } from "electron"; import { z } from "zod"; @@ -21,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; @@ -38,18 +49,18 @@ function load(): string | null { const parsed = persistedSchema.safeParse(JSON.parse(plain)); if (!parsed.success) { console.error("[auth-store] invalid persisted payload", parsed.error); - Sentry.captureException(parsed.error, { + captureException(parsed.error, { tags: { scope: "auth-store.load.schema" }, }); - rmSync(path, { force: true }); + purgePersisted(path, "auth-store.load.schema.purge"); 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 }); + captureException(err, { tags: { scope: "auth-store.load" } }); + purgePersisted(path, "auth-store.load.purge"); return null; } } @@ -69,21 +80,17 @@ function persist(token: string): boolean { return true; } catch (err) { console.error("[auth-store] failed to persist", err); - Sentry.captureException(err, { tags: { scope: "auth-store.persist" } }); + captureException(err, { tags: { scope: "auth-store.persist" } }); return false; } } function clearPersisted(): boolean { - try { - rmSync(storePath(), { force: true }); + const ok = purgePersisted(storePath(), "auth-store.clear"); + if (ok) { memory = null; - return true; - } catch (err) { - console.error("[auth-store] failed to remove", err); - Sentry.captureException(err, { tags: { scope: "auth-store.clear" } }); - return false; } + return ok; } function emit(): void { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 697819454..373fdcbd1 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -313,6 +313,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, @@ -339,7 +344,6 @@ app.whenReady().then(() => { registerIpcHandlers(); registerUpdaterIpc(); - registerProtocolHandler(() => BrowserWindow.getAllWindows()); broadcastThemeUpdates(); registerGlobalShortcuts({ toggleHud: toggleHudWindow }); applySettings(getSettings()); diff --git a/apps/desktop/src/main/protocol.ts b/apps/desktop/src/main/protocol.ts index 47c46187a..4ddf0a96b 100644 --- a/apps/desktop/src/main/protocol.ts +++ b/apps/desktop/src/main/protocol.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { app, type BrowserWindow } from "electron"; export type ProtocolUrlListener = (url: string) => void; @@ -17,7 +18,21 @@ export function registerProtocolHandler( getWindows: () => BrowserWindow[] ): void { const scheme = getProtocolScheme(); - app.setAsDefaultProtocolClient(scheme); + // 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}://`)) { diff --git a/apps/desktop/src/main/sentry.ts b/apps/desktop/src/main/sentry.ts index 02725828e..5a7c0f69d 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"; @@ -36,7 +38,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/main/windows/factory.ts b/apps/desktop/src/main/windows/factory.ts index 00988fdfe..496cc2dd4 100644 --- a/apps/desktop/src/main/windows/factory.ts +++ b/apps/desktop/src/main/windows/factory.ts @@ -11,8 +11,9 @@ import { loadWindowState, trackWindowState } from "../window-state"; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; declare const MAIN_WINDOW_VITE_NAME: string; -// Vite 8 emits the main bundle as CJS, where `import.meta.url` resolves to -// `undefined`. Use the CJS-native `__dirname` instead. +// 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; const PRELOAD_PATH = join(factoryDir, "preload.js"); const RENDERER_DIST = join(factoryDir, `../renderer/${MAIN_WINDOW_VITE_NAME}`); diff --git a/apps/desktop/src/renderer/src/main.ts b/apps/desktop/src/renderer/src/main.ts index 430a15c56..f41946eac 100644 --- a/apps/desktop/src/renderer/src/main.ts +++ b/apps/desktop/src/renderer/src/main.ts @@ -1,4 +1,4 @@ -import * as Sentry from "@sentry/browser"; +import { init as initSentryBrowser } from "@vendor/observability/sentry-browser"; import "./react/entry"; import { ACCELERATORS, @@ -27,7 +27,7 @@ const formatPlatform: FormatPlatform = : "linux"; if (sentryInit.enabled) { - Sentry.init({ + initSentryBrowser({ dsn: sentryInit.dsn, release: sentryInit.release, environment: sentryInit.environment, diff --git a/apps/desktop/src/renderer/src/react/app-shell.tsx b/apps/desktop/src/renderer/src/react/app-shell.tsx index 3a0002ae9..b4cac8f49 100644 --- a/apps/desktop/src/renderer/src/react/app-shell.tsx +++ b/apps/desktop/src/renderer/src/react/app-shell.tsx @@ -1,5 +1,5 @@ -import * as Sentry from "@sentry/browser"; 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"; @@ -30,7 +30,7 @@ export function AppShell() { void window.lightfastBridge.auth.signOut().then((ok) => { if (!(ok || signoutFailureReported)) { signoutFailureReported = true; - Sentry.captureException(new Error("auto-sign-out failed"), { + captureException(new Error("auto-sign-out failed"), { tags: { scope: "app-shell.auto-sign-out" }, }); } 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/pnpm-lock.yaml b/pnpm-lock.yaml index adf1ac5b4..a10fb9386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -725,6 +725,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 @@ -2315,7 +2318,7 @@ importers: dependencies: '@sentry/nextjs': specifier: 'catalog:' - version: 10.49.0(@opentelemetry/core@2.7.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(@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) @@ -2344,9 +2347,18 @@ importers: '@orpc/client': specifier: ^1.13.14 version: 1.13.14(@opentelemetry/api@1.9.1) + '@sentry/browser': + specifier: ^10.49.0 + version: 10.49.0 '@sentry/core': specifier: 'catalog:' version: 10.49.0 + '@sentry/electron': + specifier: ^7.11.0 + 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) @@ -20772,7 +20784,7 @@ snapshots: - '@opentelemetry/exporter-trace-otlp-http' - supports-color - '@sentry/nextjs@10.49.0(@opentelemetry/core@2.7.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))': + '@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 '@opentelemetry/semantic-conventions': 1.40.0 @@ -20784,7 +20796,7 @@ snapshots: '@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)) + '@sentry/webpack-plugin': 5.2.0(encoding@0.1.13)(webpack@5.105.4) 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 @@ -20797,7 +20809,7 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@10.49.0(@opentelemetry/core@2.7.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)': + '@sentry/nextjs@10.49.0(@opentelemetry/core@2.7.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 @@ -20809,7 +20821,7 @@ snapshots: '@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) + '@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 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..965718988 --- /dev/null +++ b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md @@ -0,0 +1,589 @@ +--- +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 + +### 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 to `createAppUrl()` (the only behavioral change beyond bug fixes) and (b) re-runs the full happy path against the current branch tip with the existing skill. + +### 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 + +- [ ] CI green on the latest commit: `gh pr view 627 --json statusCheckRollup --jq '.statusCheckRollup[] | select(.conclusion != "SUCCESS" and .state != "SUCCESS")'` returns empty +- [ ] `gh pr view 627 --json mergeable --jq .mergeable` returns `"MERGEABLE"` +- [ ] 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 + +- [ ] Run the `lightfast-desktop-signin` skill end-to-end against the rebased branch: + - Start dev mesh on `:3024`, start desktop with no persisted token + - Sign into Clerk via `lightfast-clerk` skill (email + OTP `424242`) + - Confirm `/api/desktop/auth/code` issues a code, bridge does `window.location.href = lightfast-dev://auth/callback?code=…&state=…` + - Confirm macOS `app.on('open-url')` dispatches into the running desktop, exchange runs, `auth.bin` (~851 bytes) is persisted + - Expected observation: `auth_signed_in` event on stdout within ~14 seconds; renderer flips to signed-in +- [ ] Re-run with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and *no* persisted token: + - Expected observation: stdout emits `{"event":"auth_signin_url","url":"..."}` + - Drive URL via `agent-browser`, expected observation: stdout emits `{"event":"auth_signed_in"}` +- [ ] Re-run with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and *with* persisted token: + - Expected observation: stdout emits exactly one `{"event":"auth_already_signed_in"}` and nothing else; no signin URL, no `shell.openExternal` + +--- + +## 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..a5a1a49c6 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": "^10.49.0", "@sentry/core": "catalog:", + "@sentry/electron": "^7.11.0", + "@sentry/nextjs": "catalog:", "@t3-oss/env-nextjs": "catalog:", "@trpc/server": "catalog:", "@vendor/inngest": "workspace:*", From cf4438a1fd226f367f1439adfd4929b88b455589 Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Wed, 6 May 2026 15:51:31 +1000 Subject: [PATCH 11/18] feat(desktop/auth): build sign-in URL via createAppUrl() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md. Replace hand-built `new URL("/desktop/auth", apiOrigin)` with `createAppUrl("/desktop/auth")` so sign-in URL composition flows through `getRuntimeConfig().appOrigin`. This decouples the sign-in page origin from LIGHTFAST_API_URL — the latter still drives the exchangeCode POST endpoint via getApiOrigin(), but the user-facing sign-in URL now goes through the same runtime-config layer that main introduced for desktop URL composition. Test mocks `../app-url` with a let-bound `testAppOrigin` so per-test origin control stays test-local without depending on LIGHTFAST_APP_ORIGIN env var plumbing through t3-env. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/src/main/__tests__/auth-flow.test.ts | 8 ++++++++ apps/desktop/src/main/auth-flow.ts | 3 ++- thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/auth-flow.test.ts b/apps/desktop/src/main/__tests__/auth-flow.test.ts index a291969f1..a7ec98327 100644 --- a/apps/desktop/src/main/__tests__/auth-flow.test.ts +++ b/apps/desktop/src/main/__tests__/auth-flow.test.ts @@ -11,6 +11,7 @@ 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: { @@ -45,6 +46,11 @@ vi.mock("../protocol", () => ({ }, })); +vi.mock("../app-url", () => ({ + createAppUrl: (path: string) => new URL(path, testAppOrigin), + openAppOrigin: () => Promise.resolve(), +})); + async function loadAuthFlow(env?: Record) { vi.resetModules(); protocolListeners = []; @@ -167,6 +173,7 @@ beforeEach(() => { sentryCaptureExceptionMock.mockClear(); sentryCaptureMessageMock.mockClear(); isPackagedFlag = false; + testAppOrigin = "http://localhost:3024"; }); afterEach(() => { @@ -201,6 +208,7 @@ describe("auth-flow PKCE sign-in", () => { 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_API_URL: undefined, diff --git a/apps/desktop/src/main/auth-flow.ts b/apps/desktop/src/main/auth-flow.ts index 71c728d4c..ac91848ac 100644 --- a/apps/desktop/src/main/auth-flow.ts +++ b/apps/desktop/src/main/auth-flow.ts @@ -5,6 +5,7 @@ import { } from "@vendor/observability/sentry-electron-main"; import { shell } from "electron"; import { z } from "zod"; +import { createAppUrl } from "./app-url"; import { getToken, setToken } from "./auth-store"; import { getProtocolScheme, onProtocolUrl } from "./protocol"; @@ -130,7 +131,7 @@ async function runSignIn(): Promise { const redirectUri = `${scheme}://auth/callback`; const apiOrigin = getApiOrigin(); - const signinUrl = new URL("/desktop/auth", apiOrigin); + const signinUrl = createAppUrl("/desktop/auth"); signinUrl.searchParams.set("state", state); signinUrl.searchParams.set("code_challenge", codeChallenge); signinUrl.searchParams.set("code_challenge_method", "S256"); diff --git a/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md index 965718988..2c35dff81 100644 --- a/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md +++ b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md @@ -476,7 +476,7 @@ const factoryDir = __dirname; --- -## Phase 5: `createAppUrl()` adoption + live re-verification + push +## Phase 5: `createAppUrl()` adoption + live re-verification + push [in progress — code change applied 2026-05-06] ### Overview From 884a9eb979aa6b5b6af5082843d38afdffcb2db3 Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Wed, 6 May 2026 15:55:51 +1000 Subject: [PATCH 12/18] fix(vendor/observability): commit missing Sentry wrapper modules The Phase 2 commit (38ac764dd) added the package.json export entries for sentry-browser, sentry-electron-main, and sentry-nextjs but missed staging the wrapper files themselves. CI typecheck failed with TS2307 on the renderer files importing @vendor/observability/sentry-browser since the package.json pointed at files that didn't exist on the CI checkout. Co-Authored-By: Claude Opus 4.7 (1M context) --- vendor/observability/src/sentry-browser.ts | 1 + vendor/observability/src/sentry-electron-main.ts | 6 ++++++ vendor/observability/src/sentry-nextjs.ts | 7 +++++++ 3 files changed, 14 insertions(+) create mode 100644 vendor/observability/src/sentry-browser.ts create mode 100644 vendor/observability/src/sentry-electron-main.ts create mode 100644 vendor/observability/src/sentry-nextjs.ts 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..31254c6df --- /dev/null +++ b/vendor/observability/src/sentry-nextjs.ts @@ -0,0 +1,7 @@ +export { + captureConsoleIntegration, + captureRequestError, + extraErrorDataIntegration, + init, + spotlightIntegration, +} from "@sentry/nextjs"; From 6ffd1370d65d5c09fc60c5cba31c27e428c2d126 Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Wed, 6 May 2026 16:00:41 +1000 Subject: [PATCH 13/18] docs(plan/pr627): record wrapper-files fix-up and green CI on 884a9eb97 Co-Authored-By: Claude Opus 4.7 (1M context) --- thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md index 2c35dff81..4fd673484 100644 --- a/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md +++ b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md @@ -476,7 +476,9 @@ const factoryDir = __dirname; --- -## Phase 5: `createAppUrl()` adoption + live re-verification + push [in progress — code change applied 2026-05-06] +## Phase 5: `createAppUrl()` adoption + live re-verification + push [in progress — code change applied + wrapper-files fix 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 @@ -500,8 +502,8 @@ The remainder of this phase makes no further code changes. It is the final pre-p #### Automated Verification -- [ ] CI green on the latest commit: `gh pr view 627 --json statusCheckRollup --jq '.statusCheckRollup[] | select(.conclusion != "SUCCESS" and .state != "SUCCESS")'` returns empty -- [ ] `gh pr view 627 --json mergeable --jq .mergeable` returns `"MERGEABLE"` +- [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 From d1c04299a10f7cfc1dca48d3d4afd4cb505e644d Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Wed, 6 May 2026 16:29:59 +1000 Subject: [PATCH 14/18] docs(plan/pr627): annotate Phase 5 partial live verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-05-06 live verification got far enough to confirm Phase 5's behavioral change (createAppUrl() correctly routes through getRuntimeConfig().appOrigin — desktop emitted auth_signin_url with origin https://lightfast.localhost as expected). Beyond that, the lightfast-dev:// URL scheme dispatch did not deliver to the running unpackaged dev Electron; lsregister confirms no app claims that scheme on this host. This is the known unpackaged-Electron limitation called out in the desktop-signin skill, not a Phase 5 regression. Subsequent re-runs gated on either fixing the local URL-scheme registration or running against a packaged build. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-06-pr627-merge-readiness.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md index 4fd673484..3dba93e4e 100644 --- a/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md +++ b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md @@ -508,17 +508,10 @@ The remainder of this phase makes no further code changes. It is the final pre-p #### Human Review -- [ ] Run the `lightfast-desktop-signin` skill end-to-end against the rebased branch: - - Start dev mesh on `:3024`, start desktop with no persisted token - - Sign into Clerk via `lightfast-clerk` skill (email + OTP `424242`) - - Confirm `/api/desktop/auth/code` issues a code, bridge does `window.location.href = lightfast-dev://auth/callback?code=…&state=…` - - Confirm macOS `app.on('open-url')` dispatches into the running desktop, exchange runs, `auth.bin` (~851 bytes) is persisted - - Expected observation: `auth_signed_in` event on stdout within ~14 seconds; renderer flips to signed-in -- [ ] Re-run with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and *no* persisted token: - - Expected observation: stdout emits `{"event":"auth_signin_url","url":"..."}` - - Drive URL via `agent-browser`, expected observation: stdout emits `{"event":"auth_signed_in"}` -- [ ] Re-run with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and *with* persisted token: - - Expected observation: stdout emits exactly one `{"event":"auth_already_signed_in"}` and nothing else; no signin URL, no `shell.openExternal` +- [~] Run the `lightfast-desktop-signin` skill end-to-end against the rebased branch: + - **PARTIAL on 2026-05-06**: dev:app + dev:desktop in AGENT_MODE both started cleanly. Desktop emitted `auth_signin_url` with URL origin `https://lightfast.localhost` — **this is the Phase 5 behavioral observation: `createAppUrl()` correctly routes through `getRuntimeConfig().appOrigin` (Portless aggregate) rather than the legacy inline `getApiOrigin()` fallback.** Clerk sign-in via agent-browser succeeded; landed on `/desktop/auth` page which rendered "Opening Lightfast…" (the bridge stage). Beyond that, the `lightfast-dev://auth/callback` dispatch did not deliver to the running dev Electron — `lsregister -dump` confirms no app claims the `lightfast-dev:` scheme on this host. This is the well-known unpackaged-Electron URL-scheme limitation called out in `lightfast-desktop-signin/SKILL.md` ("unpackaged Electron registers `lightfast-dev://` against `com.github.electron`, not Lightfast's bundle id"), not a Phase 5 regression. Manual `open lightfast-dev://...` from the shell also produced no response, confirming OS-level registration absence. +- [ ] Re-run with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and *no* persisted token: gated by URL-scheme-registration fix (or packaged build). +- [ ] Re-run with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and *with* persisted token: gated by URL-scheme-registration fix (or packaged build). --- From ad7d1641eb2996079fd393dc5ef16b41bf69bd2b Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Wed, 6 May 2026 17:41:59 +1000 Subject: [PATCH 15/18] fix(desktop/auth): exchangeCode via createAppUrl(), drop legacy LIGHTFAST_API_URL The sign-in URL composition already routed through createAppUrl() (Phase 5), but exchangeCode's POST kept calling getApiOrigin() which read a different env var (LIGHTFAST_API_URL) than what scripts/with-desktop-env.mjs injects (LIGHTFAST_APP_ORIGIN). With pnpm dev:desktop, getApiOrigin() fell through to the legacy http://localhost:3024 default and the exchange POST failed with auth_signin_failed{reason:"exchange_failed"}. Drop getApiOrigin(); have exchangeCode build its URL via createAppUrl(), 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. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/__tests__/auth-flow.test.ts | 16 ---------- apps/desktop/src/main/auth-flow.ts | 30 ++++++------------- .../plans/2026-05-06-pr627-merge-readiness.md | 13 ++++---- 3 files changed, 16 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/main/__tests__/auth-flow.test.ts b/apps/desktop/src/main/__tests__/auth-flow.test.ts index a7ec98327..b6dd27ba6 100644 --- a/apps/desktop/src/main/__tests__/auth-flow.test.ts +++ b/apps/desktop/src/main/__tests__/auth-flow.test.ts @@ -184,7 +184,6 @@ 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_API_URL: undefined, LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", }); try { @@ -211,7 +210,6 @@ describe("auth-flow PKCE sign-in", () => { testAppOrigin = "https://lightfast.ai"; const { mod, restore } = await loadAuthFlow({ NODE_ENV: "production", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", }); try { @@ -235,7 +233,6 @@ describe("auth-flow PKCE sign-in", () => { ); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, }); try { const signIn = mod.beginSignIn(); @@ -272,7 +269,6 @@ describe("auth-flow PKCE sign-in", () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, }); try { vi.useFakeTimers(); @@ -322,7 +318,6 @@ describe("auth-flow PKCE sign-in", () => { const { events, restore: restoreStdout } = spyStdout(); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AGENT_MODE: "1", }); try { @@ -358,7 +353,6 @@ describe("auth-flow PKCE sign-in", () => { const { events, restore: restoreStdout } = spyStdout(); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AGENT_MODE: "1", }); try { @@ -397,7 +391,6 @@ describe("auth-flow PKCE sign-in", () => { const { events, restore: restoreStdout } = spyStdout(); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AGENT_MODE: "1", }); try { @@ -432,7 +425,6 @@ describe("auth-flow PKCE sign-in", () => { const { events, restore: restoreStdout } = spyStdout(); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AGENT_MODE: "1", LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", }); @@ -470,7 +462,6 @@ describe("auth-flow PKCE sign-in", () => { ); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, }); try { const signIn = mod.beginSignIn(); @@ -512,7 +503,6 @@ describe("auth-flow PKCE sign-in", () => { it("inflight singleton: concurrent beginSignIn calls share a single promise", async () => { const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", }); try { @@ -538,7 +528,6 @@ describe("auth-flow LIGHTFAST_DESKTOP_AGENT_MODE", () => { const { events, restore: restoreStdout } = spyStdout(); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AGENT_MODE: "1", LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", }); @@ -570,7 +559,6 @@ describe("auth-flow LIGHTFAST_DESKTOP_AGENT_MODE", () => { const { events, restore: restoreStdout } = spyStdout(); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", }); try { @@ -590,7 +578,6 @@ describe("auth-flow maybeAutoBeginSignIn", () => { const { events, restore: restoreStdout } = spyStdout(); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, }); try { mod.maybeAutoBeginSignIn(); @@ -608,7 +595,6 @@ describe("auth-flow maybeAutoBeginSignIn", () => { const { events, restore: restoreStdout } = spyStdout(); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AGENT_MODE: "1", }); try { @@ -629,7 +615,6 @@ describe("auth-flow maybeAutoBeginSignIn", () => { const { events, restore: restoreStdout } = spyStdout(); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AGENT_MODE: "1", LIGHTFAST_DESKTOP_AUTH_TIMEOUT_MS: "100", }); @@ -659,7 +644,6 @@ describe("auth-flow event grammar", () => { const { events, restore: restoreStdout } = spyStdout(); const { mod, restore } = await loadAuthFlow({ NODE_ENV: "test", - LIGHTFAST_API_URL: undefined, LIGHTFAST_DESKTOP_AGENT_MODE: "1", }); try { diff --git a/apps/desktop/src/main/auth-flow.ts b/apps/desktop/src/main/auth-flow.ts index ac91848ac..6dec2d8c1 100644 --- a/apps/desktop/src/main/auth-flow.ts +++ b/apps/desktop/src/main/auth-flow.ts @@ -37,15 +37,6 @@ function emitAgentEvent(payload: AuthEvent): void { 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), @@ -130,7 +121,6 @@ async function runSignIn(): Promise { const scheme = getProtocolScheme(); const redirectUri = `${scheme}://auth/callback`; - const apiOrigin = getApiOrigin(); const signinUrl = createAppUrl("/desktop/auth"); signinUrl.searchParams.set("state", state); signinUrl.searchParams.set("code_challenge", codeChallenge); @@ -190,11 +180,7 @@ async function runSignIn(): Promise { return; } callbackInFlight = true; - const token = await exchangeCode( - apiOrigin, - parsed.data.code, - codeVerifier - ); + const token = await exchangeCode(parsed.data.code, codeVerifier); if (settled) { return; } @@ -255,16 +241,18 @@ async function runSignIn(): Promise { } 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 }), - }); + 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", diff --git a/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md index 3dba93e4e..c75ebaeb0 100644 --- a/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md +++ b/thoughts/shared/plans/2026-05-06-pr627-merge-readiness.md @@ -476,13 +476,13 @@ const factoryDir = __dirname; --- -## Phase 5: `createAppUrl()` adoption + live re-verification + push [in progress — code change applied + wrapper-files fix 2026-05-06] +## 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 to `createAppUrl()` (the only behavioral change beyond bug fixes) and (b) re-runs the full happy path against the current branch tip with the existing skill. +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 @@ -508,10 +508,11 @@ The remainder of this phase makes no further code changes. It is the final pre-p #### Human Review -- [~] Run the `lightfast-desktop-signin` skill end-to-end against the rebased branch: - - **PARTIAL on 2026-05-06**: dev:app + dev:desktop in AGENT_MODE both started cleanly. Desktop emitted `auth_signin_url` with URL origin `https://lightfast.localhost` — **this is the Phase 5 behavioral observation: `createAppUrl()` correctly routes through `getRuntimeConfig().appOrigin` (Portless aggregate) rather than the legacy inline `getApiOrigin()` fallback.** Clerk sign-in via agent-browser succeeded; landed on `/desktop/auth` page which rendered "Opening Lightfast…" (the bridge stage). Beyond that, the `lightfast-dev://auth/callback` dispatch did not deliver to the running dev Electron — `lsregister -dump` confirms no app claims the `lightfast-dev:` scheme on this host. This is the well-known unpackaged-Electron URL-scheme limitation called out in `lightfast-desktop-signin/SKILL.md` ("unpackaged Electron registers `lightfast-dev://` against `com.github.electron`, not Lightfast's bundle id"), not a Phase 5 regression. Manual `open lightfast-dev://...` from the shell also produced no response, confirming OS-level registration absence. -- [ ] Re-run with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and *no* persisted token: gated by URL-scheme-registration fix (or packaged build). -- [ ] Re-run with `LIGHTFAST_DESKTOP_AGENT_MODE=1` and *with* persisted token: gated by URL-scheme-registration fix (or packaged build). +- [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. --- From 060ac5558b75b88772db6360a1fa11311a8e00ac Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Wed, 6 May 2026 18:21:20 +1000 Subject: [PATCH 16/18] =?UTF-8?q?fix(pr627):=20address=20CodeRabbit=20T2?= =?UTF-8?q?=20+=20T3=20=E2=80=94=20vendor=20abstraction=20+=20test=20env?= =?UTF-8?q?=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2 (apps/app/.../client-auth-bridge.tsx): Swap direct @sentry/nextjs import for the @vendor/observability/sentry-nextjs re-export so this client component honors the repo's vendor-abstraction rule. Adds captureException and captureMessage to the wrapper's re-exports. T3 (apps/desktop/.../__tests__/auth-flow.test.ts): loadAuthFlow() mutated process.env then awaited a dynamic import without ensuring restore() runs on import failure. If the import throws, the mutation now leaks into every subsequent test. Hoist restore() above the import and wrap the import in try/catch so env state is reverted before the rejection propagates. Verified: 38/38 desktop tests pass; @lightfast/app and @vendor/observability typecheck clean. --- .../_components/client-auth-bridge.tsx | 5 ++- .../src/main/__tests__/auth-flow.test.ts | 35 +++++++++++-------- vendor/observability/src/sentry-nextjs.ts | 2 ++ 3 files changed, 27 insertions(+), 15 deletions(-) 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 bf5852942..eda011df7 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,6 +1,9 @@ "use client"; -import { captureException, captureMessage } from "@sentry/nextjs"; +import { + captureException, + captureMessage, +} from "@vendor/observability/sentry-nextjs"; import { useAuth } from "@vendor/clerk/client"; import { useSearchParams } from "next/navigation"; import { type ReactNode, Suspense, useEffect, useRef, useState } from "react"; diff --git a/apps/desktop/src/main/__tests__/auth-flow.test.ts b/apps/desktop/src/main/__tests__/auth-flow.test.ts index b6dd27ba6..2d66ae93c 100644 --- a/apps/desktop/src/main/__tests__/auth-flow.test.ts +++ b/apps/desktop/src/main/__tests__/auth-flow.test.ts @@ -61,6 +61,16 @@ async function loadAuthFlow(env?: 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) { @@ -70,20 +80,17 @@ async function loadAuthFlow(env?: Record) { } } } - const mod = await import("../auth-flow"); - return { - mod, - restore: () => { - for (const k of touchedKeys) { - const original = prev[k]; - if (original === undefined) { - delete process.env[k]; - } else { - process.env[k] = original; - } - } - }, - }; + // 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 { diff --git a/vendor/observability/src/sentry-nextjs.ts b/vendor/observability/src/sentry-nextjs.ts index 31254c6df..db7b9bbab 100644 --- a/vendor/observability/src/sentry-nextjs.ts +++ b/vendor/observability/src/sentry-nextjs.ts @@ -1,5 +1,7 @@ export { captureConsoleIntegration, + captureException, + captureMessage, captureRequestError, extraErrorDataIntegration, init, From 21c0eaa203ad50b59b45b823d4b90b63b24b482e Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Wed, 6 May 2026 18:27:31 +1000 Subject: [PATCH 17/18] =?UTF-8?q?fix(pr627):=20address=20CodeRabbit=20T4?= =?UTF-8?q?=20=E2=80=94=20pin=20Sentry=20deps=20via=20catalog=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vendor/observability/package.json had `@sentry/browser` and `@sentry/electron` on inline ^X.Y.Z specs while `@sentry/core` and `@sentry/nextjs` were on `catalog:`. Adds the two missing entries to pnpm-workspace.yaml's catalog (@sentry/browser ^10.49.0 to match the rest of the v10 Sentry surface, @sentry/electron ^7.11.0 to match the existing pin) and switches both deps to `catalog:`. Prevents version drift across the workspace. Verified: 38/38 desktop tests pass; @vendor/observability, @lightfast/desktop, @lightfast/app typecheck clean. --- pnpm-lock.yaml | 10 ++++++++-- pnpm-workspace.yaml | 2 ++ vendor/observability/package.json | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a53f64784..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 @@ -2327,13 +2333,13 @@ importers: specifier: ^1.13.14 version: 1.13.14(@opentelemetry/api@1.9.1) '@sentry/browser': - specifier: ^10.49.0 + specifier: 'catalog:' version: 10.49.0 '@sentry/core': specifier: 'catalog:' version: 10.49.0 '@sentry/electron': - specifier: ^7.11.0 + specifier: 'catalog:' version: 7.11.0(@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1)) '@sentry/nextjs': specifier: 'catalog:' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4a6d5a3a7..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 diff --git a/vendor/observability/package.json b/vendor/observability/package.json index a5a1a49c6..c6d45b226 100644 --- a/vendor/observability/package.json +++ b/vendor/observability/package.json @@ -75,9 +75,9 @@ "dependencies": { "@logtail/next": "^0.3.1", "@orpc/client": "^1.13.14", - "@sentry/browser": "^10.49.0", + "@sentry/browser": "catalog:", "@sentry/core": "catalog:", - "@sentry/electron": "^7.11.0", + "@sentry/electron": "catalog:", "@sentry/nextjs": "catalog:", "@t3-oss/env-nextjs": "catalog:", "@trpc/server": "catalog:", From fde860de30ba5cd3d77c062ffc59919973def88a Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Wed, 6 May 2026 18:33:09 +1000 Subject: [PATCH 18/18] =?UTF-8?q?fix(pr627):=20T2=20follow-up=20=E2=80=94?= =?UTF-8?q?=20sort=20imports=20and=20update=20test=20mock=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The T2 swap from `@sentry/nextjs` to `@vendor/observability/sentry-nextjs` broke two CI checks on `bf1699fa5`: 1. Quality (biome organizeImports): `@vendor/clerk/client` must precede `@vendor/observability/sentry-nextjs` alphabetically. Reorder the import block. 2. Test (vitest, client-auth-bridge.test.tsx): the test still mocked `@sentry/nextjs`, so after the swap the wrapper's real captureException/captureMessage ran instead of the mocks. Update `vi.mock("@sentry/nextjs")` → `vi.mock("@vendor/observability/sentry-nextjs")`. Verified: 120/120 app tests pass; ultracite check clean on both files. --- .../_components/client-auth-bridge.test.tsx | 2 +- .../(pending-not-allowed)/_components/client-auth-bridge.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index e4ce7b14a..71315cda9 100644 --- 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 @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const captureExceptionMock = vi.fn(); const captureMessageMock = vi.fn(); -vi.mock("@sentry/nextjs", () => ({ +vi.mock("@vendor/observability/sentry-nextjs", () => ({ captureException: (...args: unknown[]) => captureExceptionMock(...args), captureMessage: (...args: unknown[]) => captureMessageMock(...args), })); 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 eda011df7..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,10 +1,10 @@ "use client"; +import { useAuth } from "@vendor/clerk/client"; import { captureException, captureMessage, } from "@vendor/observability/sentry-nextjs"; -import { useAuth } from "@vendor/clerk/client"; import { useSearchParams } from "next/navigation"; import { type ReactNode, Suspense, useEffect, useRef, useState } from "react";