From ea3f6841024936cc8472b3985b59ff13f4d81df1 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Fri, 4 Sep 2026 17:07:42 -0700 Subject: [PATCH 1/4] Preserve CLI authorization through login and signup Keep validated local return paths through OAuth, magic links, auth retries, and login/signup navigation. New accounts can return to explicit CLI approval before optional profile setup. Co-authored-by: Codex --- apps/web/__tests__/api/auth-callback.test.ts | 80 +++++++++++++++++++ .../components/AuthReturnPath.test.tsx | 75 +++++++++++++++++ apps/web/app/(auth)/callback/route.ts | 11 +-- apps/web/app/(auth)/login/page.tsx | 22 +++-- apps/web/app/(auth)/signup/page.tsx | 22 +++-- apps/web/e2e/golden-path/cli-verify.spec.ts | 22 ++--- apps/web/lib/supabase/redirect.ts | 23 ++++++ 7 files changed, 230 insertions(+), 25 deletions(-) create mode 100644 apps/web/__tests__/api/auth-callback.test.ts create mode 100644 apps/web/__tests__/components/AuthReturnPath.test.tsx create mode 100644 apps/web/lib/supabase/redirect.ts diff --git a/apps/web/__tests__/api/auth-callback.test.ts b/apps/web/__tests__/api/auth-callback.test.ts new file mode 100644 index 00000000..abdba1c3 --- /dev/null +++ b/apps/web/__tests__/api/auth-callback.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "@/app/(auth)/callback/route"; + +const mocks = vi.hoisted(() => ({ + exchange: vi.fn(), + getUser: vi.fn(), + profile: vi.fn(), +})); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: () => ({ auth: { exchangeCodeForSession: mocks.exchange, getUser: mocks.getUser } }), +})); +vi.mock("@/lib/supabase/service", () => ({ + getServiceClient: () => ({ + from: () => ({ select: () => ({ eq: () => ({ single: mocks.profile }) }) }), + }), +})); +vi.mock("@/lib/analytics/server", () => ({ + captureServerActivationEvent: vi.fn(), + identifyServerActivationUser: vi.fn(), +})); + +function callback(next?: string, withCode = true) { + const url = new URL("https://straude.com/callback"); + if (withCode) url.searchParams.set("code", "oauth-code"); + if (next !== undefined) url.searchParams.set("next", next); + return GET(new Request(url)); +} + +describe("auth callback return path", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.exchange.mockResolvedValue({ error: null }); + mocks.getUser.mockResolvedValue({ data: { user: { id: "user-1" } } }); + mocks.profile.mockResolvedValue({ data: { username: "developer", onboarding_completed: true } }); + }); + + it.each([false, true])("returns a user with onboarding_completed=%s to explicit CLI approval", async (completed) => { + mocks.profile.mockResolvedValue({ data: { username: "developer", onboarding_completed: completed } }); + const next = "/cli/verify?code=ABCD1234&verify_secret=secret%2Bwith%2Fsymbols%3D"; + const response = await callback(next); + expect(response.headers.get("location")).toBe(`https://straude.com${next}`); + expect(mocks.exchange).toHaveBeenCalledWith("oauth-code"); + }); + + it("sends ordinary fresh signups to onboarding", async () => { + mocks.profile.mockResolvedValue({ data: { username: null, onboarding_completed: false } }); + expect((await callback()).headers.get("location")).toBe("https://straude.com/onboarding"); + }); + + it("sends ordinary returning users to feed", async () => { + expect((await callback()).headers.get("location")).toBe("https://straude.com/feed"); + }); + + it("honors an explicit onboarding recovery destination", async () => { + expect((await callback("/onboarding")).headers.get("location")).toBe("https://straude.com/onboarding"); + }); + + it.each([ + "https://evil.example", "//evil.example", "/\\evil.example", "/%5cevil.example", + "/%2fevil.example", "/\tevil.example", "/%0a/evil.example", "/%0d/evil.example", + "/%00evil.example", "/%7fevil.example", "javascript:alert(1)", "/%broken", + ])("rejects an unsafe destination: %s", async (next) => { + expect((await callback(next)).headers.get("location")).toBe("https://straude.com/feed"); + }); + + it.each([true, false])("preserves the CLI return path when auth fails (withCode=%s)", async (withCode) => { + mocks.exchange.mockResolvedValue({ error: { message: "Expired code" } }); + const next = "/cli/verify?code=ABCD1234&verify_secret=secret"; + const destination = new URL((await callback(next, withCode)).headers.get("location")!); + expect(destination.pathname).toBe("/login"); + expect(destination.searchParams.get("error")).toBe("auth"); + expect(destination.searchParams.get("next")).toBe(next); + }); + + it("does not forward an unsafe destination after an auth failure", async () => { + mocks.exchange.mockResolvedValue({ error: { message: "Expired code" } }); + expect((await callback("//evil.example")).headers.get("location")).toBe("https://straude.com/login?error=auth"); + }); +}); diff --git a/apps/web/__tests__/components/AuthReturnPath.test.tsx b/apps/web/__tests__/components/AuthReturnPath.test.tsx new file mode 100644 index 00000000..865ffa26 --- /dev/null +++ b/apps/web/__tests__/components/AuthReturnPath.test.tsx @@ -0,0 +1,75 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LoginPage from "@/app/(auth)/login/page"; +import SignupPage from "@/app/(auth)/signup/page"; + +const mocks = vi.hoisted(() => ({ + params: new URLSearchParams(), + otp: vi.fn(), + oauth: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ useSearchParams: () => mocks.params })); +vi.mock("@/lib/supabase/client", () => ({ + createClient: () => ({ auth: { signInWithOtp: mocks.otp, signInWithOAuth: mocks.oauth } }), +})); +vi.mock("@/lib/analytics/client", () => ({ trackActivationEvent: vi.fn() })); + +const returnTo = "/cli/verify?code=ABCD1234&verify_secret=secret%2Bwith%2Fsymbols%3D"; + +describe.each([ + { name: "login", Page: LoginPage, link: "Sign up", otherPath: "/signup" }, + { name: "signup", Page: SignupPage, link: "Log in", otherPath: "/login" }, +])("$name return path", ({ Page, link, otherPath }) => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.params = new URLSearchParams({ next: returnTo }); + mocks.otp.mockResolvedValue({ error: null }); + mocks.oauth.mockResolvedValue({ error: null }); + }); + afterEach(cleanup); + + it("preserves the complete CLI request in the magic link callback", async () => { + render(); + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "dev@example.com" } }); + fireEvent.click(screen.getByRole("button", { name: "Send magic link" })); + await waitFor(() => expect(mocks.otp).toHaveBeenCalledOnce()); + const callback = new URL(mocks.otp.mock.calls[0][0].options.emailRedirectTo); + expect(callback.origin).toBe(window.location.origin); + expect(callback.pathname).toBe("/callback"); + expect(callback.searchParams.get("next")).toBe(returnTo); + await screen.findByText("Check your email"); + }); + + it("preserves the complete CLI request in the GitHub callback", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Continue with GitHub" })); + expect(mocks.oauth).toHaveBeenCalledOnce(); + expect(mocks.oauth.mock.calls[0][0].provider).toBe("github"); + const callback = new URL(mocks.oauth.mock.calls[0][0].options.redirectTo); + expect(callback.pathname).toBe("/callback"); + expect(callback.searchParams.get("next")).toBe(returnTo); + }); + + it("keeps the CLI request when switching between login and signup", () => { + render(); + const destination = new URL(screen.getByRole("link", { name: link }).getAttribute("href")!, window.location.origin); + expect(destination.pathname).toBe(otherPath); + expect(destination.searchParams.get("next")).toBe(returnTo); + }); + + it("supports returning to onboarding after session recovery", () => { + mocks.params = new URLSearchParams({ next: "/onboarding" }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Continue with GitHub" })); + expect(new URL(mocks.oauth.mock.calls[0][0].options.redirectTo).searchParams.get("next")).toBe("/onboarding"); + }); + + it.each([null, "https://evil.example", "/\\evil.example", "//evil.example"])("omits an absent or unsafe return path: %s", (next) => { + mocks.params = new URLSearchParams(next ? { next } : {}); + render(); + expect(screen.getByRole("link", { name: link })).toHaveAttribute("href", otherPath); + fireEvent.click(screen.getByRole("button", { name: "Continue with GitHub" })); + expect(mocks.oauth.mock.calls[0][0].options.redirectTo).toBe(`${window.location.origin}/callback`); + }); +}); diff --git a/apps/web/app/(auth)/callback/route.ts b/apps/web/app/(auth)/callback/route.ts index ed6be60d..d3d4796a 100644 --- a/apps/web/app/(auth)/callback/route.ts +++ b/apps/web/app/(auth)/callback/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { safeAuthNext } from "@/lib/supabase/redirect"; import { after } from "@/lib/utils/after"; import { ACTIVATION_ANONYMOUS_COOKIE, deriveActivationState, getCookieValue } from "@/lib/analytics/activation"; import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server"; @@ -9,10 +10,8 @@ export async function GET(request: Request) { const { searchParams, origin: requestOrigin } = new URL(request.url); const origin = requestOrigin; const code = searchParams.get("code"); - const rawNext = searchParams.get("next") ?? "/feed"; - // Prevent open redirect: only allow relative paths starting with / - const next = - rawNext.startsWith("/") && !rawNext.startsWith("//") ? rawNext : "/feed"; + const returnTo = safeAuthNext(searchParams.get("next")); + const next = returnTo ?? "/feed"; if (code) { const supabase = await createClient(); @@ -84,5 +83,7 @@ export async function GET(request: Request) { } } - return NextResponse.redirect(`${origin}/login?error=auth`); + const retryParams = new URLSearchParams({ error: "auth" }); + if (returnTo) retryParams.set("next", returnTo); + return NextResponse.redirect(`${origin}/login?${retryParams}`); } diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 98019931..d40cf2a4 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,13 +1,17 @@ "use client"; import { createClient } from "@/lib/supabase/client"; -import { useState } from "react"; +import { Suspense, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { authPathWithNext, safeAuthNext } from "@/lib/supabase/redirect"; import Link from "next/link"; import { BoltIcon } from "@/components/landing/icons"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; -export default function LoginPage() { +function LoginContent() { + const searchParams = useSearchParams(); + const next = safeAuthNext(searchParams.get("next")); const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); @@ -21,7 +25,7 @@ export default function LoginPage() { const supabase = createClient(); const { error } = await supabase.auth.signInWithOtp({ email, - options: { emailRedirectTo: `${window.location.origin}/callback` }, + options: { emailRedirectTo: `${window.location.origin}${authPathWithNext("/callback", next)}` }, }); if (error) { @@ -36,7 +40,7 @@ export default function LoginPage() { const supabase = createClient(); await supabase.auth.signInWithOAuth({ provider: "github", - options: { redirectTo: `${window.location.origin}/callback` }, + options: { redirectTo: `${window.location.origin}${authPathWithNext("/callback", next)}` }, }); } @@ -54,7 +58,7 @@ export default function LoginPage() {

Don't have an account?{" "} - + Sign up

@@ -131,3 +135,11 @@ export default function LoginPage() { ); } + +export default function LoginPage() { + return ( + Loading...

}> + +
+ ); +} diff --git a/apps/web/app/(auth)/signup/page.tsx b/apps/web/app/(auth)/signup/page.tsx index 6d916796..5de6da87 100644 --- a/apps/web/app/(auth)/signup/page.tsx +++ b/apps/web/app/(auth)/signup/page.tsx @@ -1,14 +1,18 @@ "use client"; import { createClient } from "@/lib/supabase/client"; -import { useState } from "react"; +import { Suspense, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { authPathWithNext, safeAuthNext } from "@/lib/supabase/redirect"; import Link from "next/link"; import { BoltIcon } from "@/components/landing/icons"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { trackActivationEvent } from "@/lib/analytics/client"; -export default function SignupPage() { +function SignupContent() { + const searchParams = useSearchParams(); + const next = safeAuthNext(searchParams.get("next")); const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); @@ -28,7 +32,7 @@ export default function SignupPage() { const supabase = createClient(); const { error } = await supabase.auth.signInWithOtp({ email, - options: { emailRedirectTo: `${window.location.origin}/callback` }, + options: { emailRedirectTo: `${window.location.origin}${authPathWithNext("/callback", next)}` }, }); if (error) { @@ -49,7 +53,7 @@ export default function SignupPage() { const supabase = createClient(); await supabase.auth.signInWithOAuth({ provider: "github", - options: { redirectTo: `${window.location.origin}/callback` }, + options: { redirectTo: `${window.location.origin}${authPathWithNext("/callback", next)}` }, }); } @@ -67,7 +71,7 @@ export default function SignupPage() {

Already have an account?{" "} - + Log in

@@ -144,3 +148,11 @@ export default function SignupPage() { ); } + +export default function SignupPage() { + return ( + Loading...

}> + +
+ ); +} diff --git a/apps/web/e2e/golden-path/cli-verify.spec.ts b/apps/web/e2e/golden-path/cli-verify.spec.ts index 1017d90a..f42bb2a1 100644 --- a/apps/web/e2e/golden-path/cli-verify.spec.ts +++ b/apps/web/e2e/golden-path/cli-verify.spec.ts @@ -38,18 +38,20 @@ test.describe("CLI verify page", () => { expect(hasSignIn || hasAuthorize).toBeTruthy(); }); - test("sign-in link includes return URL", async ({ page }) => { - await page.goto(verifyUrl("TEST1234")); - await page.waitForLoadState("networkidle"); + test("sign-in preserves the CLI request through signup and login", async ({ page }) => { + const returnTo = verifyUrl("TEST1234"); + await page.goto(returnTo); + await page.getByRole("button", { name: "Sign in to authorize" }).click(); + await expect(page).toHaveURL(/\/login\?/); + expect(new URL(page.url()).searchParams.get("next")).toBe(returnTo); - const signInLink = page.locator('a:has-text("Sign in to authorize")'); - const isVisible = await signInLink.isVisible().catch(() => false); + await page.getByRole("link", { name: "Sign up", exact: true }).click(); + await expect(page).toHaveURL(/\/signup\?/); + expect(new URL(page.url()).searchParams.get("next")).toBe(returnTo); - if (isVisible) { - const href = await signInLink.getAttribute("href"); - expect(href).toContain("/login"); - expect(href).toContain("next="); - } + await page.getByRole("link", { name: "Log in", exact: true }).click(); + await expect(page).toHaveURL(/\/login\?/); + expect(new URL(page.url()).searchParams.get("next")).toBe(returnTo); }); test("page without code param shows missing code message", async ({ diff --git a/apps/web/lib/supabase/redirect.ts b/apps/web/lib/supabase/redirect.ts new file mode 100644 index 00000000..c604ea9f --- /dev/null +++ b/apps/web/lib/supabase/redirect.ts @@ -0,0 +1,23 @@ +/** Keep auth return paths local, including after URL decoding and normalization. */ +export function safeAuthNext(value: string | null): string | null { + if (!value?.startsWith("/") || value.startsWith("//")) return null; + + try { + const decoded = decodeURIComponent(value); + if ( + decoded.startsWith("//") || + /[\\\u0000-\u001f\u007f]/.test(decoded) + ) return null; + + const base = "https://straude.invalid"; + if (new URL(value, base).origin !== base) return null; + return value; + } catch { + return null; + } +} + +export function authPathWithNext(path: string, next: string | null): string { + const safeNext = safeAuthNext(next); + return safeNext ? `${path}?${new URLSearchParams({ next: safeNext })}` : path; +} From 3488a3c784becb0ce7781e9534f5ce1f7dfea817 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Fri, 4 Sep 2026 17:09:07 -0700 Subject: [PATCH 2/4] Show the first-sync command immediately after signup Move optional profile editing to Settings. Add manual-copy fallback and bounded, non-overlapping usage checks with retry and sign-in recovery. Complete setup only after confirmed usage and a successful save, and show accurate usage totals. Co-authored-by: Codex --- .../components/OnboardingPage.test.tsx | 200 +++++ apps/web/app/(onboarding)/layout.tsx | 4 +- apps/web/app/(onboarding)/onboarding/page.tsx | 743 ++++++------------ 3 files changed, 462 insertions(+), 485 deletions(-) create mode 100644 apps/web/__tests__/components/OnboardingPage.test.tsx diff --git a/apps/web/__tests__/components/OnboardingPage.test.tsx b/apps/web/__tests__/components/OnboardingPage.test.tsx new file mode 100644 index 00000000..7ff36fd6 --- /dev/null +++ b/apps/web/__tests__/components/OnboardingPage.test.tsx @@ -0,0 +1,200 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import OnboardingPage from "@/app/(onboarding)/onboarding/page"; + +const { push, track } = vi.hoisted(() => ({ push: vi.fn(), track: vi.fn() })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) })); +vi.mock("@/lib/analytics/client", () => ({ trackActivationEvent: track })); + +const usage = { + has_data: true, + has_usage: true, + cost_usd: 12.34, + total_tokens: 12_000, + session_count: 3, + top_model: "gpt-5.4", + latest_usage_id: "usage-1", + latest_usage_date: "2026-09-03", + latest_post_url: "/post/post-1", +}; +const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const getStatus = vi.fn(); +const completeSetup = vi.fn(); +const copy = vi.fn(); +let fetchMock: ReturnType; + +async function flush() { + await act(async () => {}); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + getStatus.mockReset().mockImplementation(() => Promise.resolve(response({ has_data: false }))); + completeSetup.mockReset().mockImplementation(() => Promise.resolve(response({ username: "oscar" }))); + copy.mockReset().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: copy } }); + fetchMock = vi.fn((url: string, options?: RequestInit) => { + if (url === "/api/usage/status") return getStatus(options); + if (options?.method === "PATCH") return completeSetup(options); + return Promise.resolve(response({ username: "oscar" })); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("first-sync onboarding", () => { + it("shows the command immediately without requiring a profile save", async () => { + render(); + expect(screen.getByRole("textbox", { name: "Sync command" })).toHaveValue("npx straude@latest"); + expect(screen.queryByRole("textbox", { name: /username/i })).not.toBeInTheDocument(); + expect(screen.queryByText("Ready to sync")).not.toBeInTheDocument(); + await flush(); + expect(completeSetup).not.toHaveBeenCalled(); + expect(fetchMock.mock.calls.some(([url]) => url.includes("check-username"))).toBe(false); + }); + + it("activates only after confirmed usage and a successful completion save", async () => { + let resolveCompletion!: (value: Response) => void; + completeSetup.mockImplementationOnce(() => new Promise((resolve) => { resolveCompletion = resolve; })); + getStatus.mockResolvedValueOnce(response({ has_data: false })).mockResolvedValueOnce(response(usage)); + render(); + await flush(); + fireEvent.click(screen.getByRole("button", { name: "Copy sync command" })); + await flush(); + await act(async () => { await vi.advanceTimersByTimeAsync(4000); }); + expect(screen.getByText("Finishing setup…")).toBeInTheDocument(); + expect(track).not.toHaveBeenCalledWith("activation_completed", expect.anything()); + expect(screen.queryByText("Your first sync is complete")).not.toBeInTheDocument(); + expect(JSON.parse(completeSetup.mock.calls[0][0].body)).toEqual({ + onboarding_completed: true, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }); + await act(async () => { resolveCompletion(response({})); }); + expect(screen.getByText("Your first sync is complete")).toBeInTheDocument(); + expect(screen.getByText("$12.34")).toBeInTheDocument(); + expect(screen.getByText("12k")).toBeInTheDocument(); + expect(screen.getByText("gpt-5.4")).toBeInTheDocument(); + expect(screen.getByText("2026-09-03")).toBeInTheDocument(); + expect(screen.queryByText("Sessions")).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: /add a handle/ })).toHaveAttribute("href", "/settings"); + expect(track).toHaveBeenCalledWith("activation_completed", expect.objectContaining({ + has_existing_usage: false, + session_count: 3, + "$insert_id": "activation_completed:usage-1", + })); + fireEvent.click(screen.getByRole("button", { name: "View your profile" })); + expect(push).toHaveBeenCalledWith("/u/oscar"); + await act(async () => { await vi.advanceTimersByTimeAsync(12_000); }); + expect(getStatus).toHaveBeenCalledTimes(2); + expect(completeSetup).toHaveBeenCalledTimes(1); + }); + + it.each(["network", "http"])("recovers from a %s status failure", async (failure) => { + if (failure === "network") getStatus.mockRejectedValueOnce(new Error("offline")); + else getStatus.mockResolvedValueOnce(response({}, 503)); + getStatus.mockResolvedValueOnce(response(usage)); + render(); + await flush(); + expect(screen.getByRole("alert")).toHaveTextContent("could not check your usage"); + expect(completeSetup).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Check again" })); + await flush(); + expect(screen.getByText("Your first sync is complete")).toBeInTheDocument(); + }); + + it.each(["network", "http"])("retries completion after a %s failure without claiming activation", async (failure) => { + getStatus.mockResolvedValueOnce(response(usage)); + if (failure === "network") completeSetup.mockRejectedValueOnce(new Error("offline")); + else completeSetup.mockResolvedValueOnce(response({ error: "Unable to verify first sync" }, 500)); + render(); + await flush(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(track).not.toHaveBeenCalledWith("activation_completed", expect.anything()); + expect(screen.queryByText("Your first sync is complete")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry setup" })); + await flush(); + expect(screen.getByText("Your first sync is complete")).toBeInTheDocument(); + expect(getStatus).toHaveBeenCalledTimes(1); + expect(completeSetup).toHaveBeenCalledTimes(2); + expect(track.mock.calls.filter(([event]) => event === "activation_completed")).toHaveLength(1); + }); + + it("offers sign-in recovery with an onboarding return path", async () => { + getStatus.mockResolvedValueOnce(response({}, 401)); + render(); + await flush(); + expect(screen.getByRole("link", { name: "Sign in again" })).toHaveAttribute("href", "/login?next=%2Fonboarding"); + expect(completeSetup).not.toHaveBeenCalled(); + }); + + it("keeps the command selectable when clipboard access is refused", async () => { + copy.mockRejectedValueOnce(new Error("denied")); + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy sync command" })); + await flush(); + expect(screen.getByText(/copy it manually/)).toBeInTheDocument(); + const input = screen.getByRole("textbox", { name: "Sync command" }) as HTMLInputElement; + fireEvent.focus(input); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe("npx straude@latest".length); + expect(track).not.toHaveBeenCalledWith("sync_command_copied", expect.anything()); + }); + + it("does not activate on explore or act on an in-flight check after unmount", async () => { + let resolveStatus!: (value: Response) => void; + getStatus.mockImplementationOnce(() => new Promise((resolve) => { resolveStatus = resolve; })); + const { unmount } = render(); + fireEvent.click(screen.getByRole("button", { name: "Explore without syncing" })); + expect(push).toHaveBeenCalledWith("/feed"); + unmount(); + expect(getStatus.mock.calls[0][0].signal.aborted).toBe(true); + await act(async () => { resolveStatus(response(usage)); }); + expect(completeSetup).not.toHaveBeenCalled(); + expect(track).not.toHaveBeenCalledWith("activation_completed", expect.anything()); + }); + + it("does not overlap slow status requests", async () => { + let resolveStatus!: (value: Response) => void; + getStatus.mockImplementationOnce(() => new Promise((resolve) => { resolveStatus = resolve; })); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(12_000); }); + expect(getStatus).toHaveBeenCalledTimes(1); + await act(async () => { resolveStatus(response({ has_data: false })); }); + await act(async () => { await vi.advanceTimersByTimeAsync(4000); }); + expect(getStatus).toHaveBeenCalledTimes(2); + }); + + it("turns a stalled request into a recoverable error after fifteen seconds", async () => { + getStatus.mockImplementationOnce((options: RequestInit) => new Promise((_resolve, reject) => { + options.signal?.addEventListener("abort", () => reject(new Error("request aborted"))); + })); + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); + expect(screen.getByRole("alert")).toHaveTextContent("could not check your usage"); + expect(getStatus.mock.calls[0][0].signal.aborted).toBe(true); + expect(completeSetup).not.toHaveBeenCalled(); + getStatus.mockResolvedValueOnce(response(usage)); + fireEvent.click(screen.getByRole("button", { name: "Check again" })); + await flush(); + expect(screen.getByText("Your first sync is complete")).toBeInTheDocument(); + }); + + it("stops waiting after five minutes and can resume checks", async () => { + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(300_000); }); + expect(screen.getByRole("alert")).toHaveTextContent("No usage received yet"); + const previousChecks = getStatus.mock.calls.length; + await act(async () => { await vi.advanceTimersByTimeAsync(8000); }); + expect(getStatus).toHaveBeenCalledTimes(previousChecks); + getStatus.mockResolvedValueOnce(response(usage)); + fireEvent.click(screen.getByRole("button", { name: "Check again" })); + await flush(); + expect(screen.getByText("Your first sync is complete")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(onboarding)/layout.tsx b/apps/web/app/(onboarding)/layout.tsx index 73f475be..28a99a77 100644 --- a/apps/web/app/(onboarding)/layout.tsx +++ b/apps/web/app/(onboarding)/layout.tsx @@ -5,7 +5,7 @@ import { AppProviders } from "@/components/providers/AppProviders"; import type { Metadata } from "next"; export const metadata: Metadata = { - title: "Set up your profile — Straude", + title: "Sync your first session — Straude", }; export default async function OnboardingLayout({ @@ -37,7 +37,7 @@ export default async function OnboardingLayout({ return ( -
+
{children}
diff --git a/apps/web/app/(onboarding)/onboarding/page.tsx b/apps/web/app/(onboarding)/onboarding/page.tsx index 934d111b..9bcebb79 100644 --- a/apps/web/app/(onboarding)/onboarding/page.tsx +++ b/apps/web/app/(onboarding)/onboarding/page.tsx @@ -1,16 +1,18 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { BoltIcon } from "@/components/landing/icons"; -import { Check, X, Loader2, ArrowRight, Copy } from "lucide-react"; +import { Check, ArrowRight, Copy } from "lucide-react"; import { Button } from "@/components/ui/Button"; -import { Input } from "@/components/ui/Input"; import { trackActivationEvent } from "@/lib/analytics/client"; import { formatCurrency } from "@/lib/utils/format"; const SYNC_COMMAND = "npx straude@latest"; +const POLL_INTERVAL_MS = 4000; +const REQUEST_TIMEOUT_MS = 15_000; +const WAIT_TIMEOUT_MS = 5 * 60_000; interface UsageStatus { has_data: boolean; @@ -20,537 +22,312 @@ interface UsageStatus { session_count?: number; top_model?: string | null; latest_usage_id?: string; - latest_usage_at?: string | null; + latest_usage_date?: string; latest_post_url?: string | null; } +type SyncState = + | { phase: "waiting" } + | { phase: "confirming"; data: UsageStatus } + | { phase: "success"; data: UsageStatus } + | { phase: "error"; data?: UsageStatus; message: string; requiresLogin?: boolean }; + function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; return String(n); } -function Step3LogSession({ username }: { username: string }) { +export default function OnboardingPage() { const router = useRouter(); + const [username, setUsername] = useState(""); const [copied, setCopied] = useState(false); - const [phase, setPhase] = useState<"waiting" | "success">("waiting"); - const [data, setData] = useState(null); - const [hasExistingUsage, setHasExistingUsage] = useState(false); - const [completionError, setCompletionError] = useState(null); - const activationTrackedRef = useRef(false); - const activationPersistedRef = useRef(false); + const [copyError, setCopyError] = useState(false); + const [sync, setSync] = useState({ phase: "waiting" }); + const [attempt, setAttempt] = useState(0); const commandCopiedRef = useRef(false); + const confirmedUsageRef = useRef(null); + const hasExistingUsageRef = useRef(false); + const activationTrackedRef = useRef(false); + const copyTimeoutRef = useRef | undefined>(undefined); - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(SYNC_COMMAND).then(() => { - commandCopiedRef.current = true; - trackActivationEvent("sync_command_copied", { - surface: "onboarding", - command: SYNC_COMMAND, - activation_state: "sync_command_copied", - is_authenticated: true, - }); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - }, []); - - const intervalRef = useRef>(undefined); - + // Profile details are optional and must never delay access to the sync command. useEffect(() => { - let active = true; - - async function poll() { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + async function loadProfile() { try { - const res = await fetch("/api/usage/status"); + const res = await fetch("/api/users/me", { signal: controller.signal }); if (!res.ok) return; - const json: UsageStatus = await res.json(); - const hasUsage = json.has_usage ?? json.has_data; - - if (hasUsage && active) { - setData(json); - setHasExistingUsage(!commandCopiedRef.current); - setPhase("success"); - if (intervalRef.current) clearInterval(intervalRef.current); + const profile = await res.json(); + if (!controller.signal.aborted && typeof profile.username === "string") { + setUsername(profile.username); } } catch { - // ignore — will retry on next interval + // The feed and Settings remain available if this optional lookup fails. + } finally { + clearTimeout(timeout); } } - - poll(); - intervalRef.current = setInterval(poll, 4000); + void loadProfile(); return () => { - active = false; - if (intervalRef.current) clearInterval(intervalRef.current); + controller.abort(); + clearTimeout(timeout); + clearTimeout(copyTimeoutRef.current); }; }, []); useEffect(() => { - if (phase !== "success" || !data || activationPersistedRef.current) return; - - activationPersistedRef.current = true; - const observedUsage = data; + let active = true; + let pollTimeout: ReturnType | undefined; + let requestTimeout: ReturnType | undefined; + let controller: AbortController | undefined; + const startedAt = Date.now(); + + async function request(url: string, options?: RequestInit) { + controller = new AbortController(); + requestTimeout = setTimeout(() => controller?.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; + } finally { + clearTimeout(requestTimeout); + } + } - async function persistActivation() { - const res = await fetch("/api/users/me", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ onboarding_completed: true }), - }); + async function checkUsage() { + let observedUsage = confirmedUsageRef.current; + try { + if (!observedUsage) { + const { response, payload } = await request("/api/usage/status"); + if (!active) return; + if (!response.ok) { + setSync({ + phase: "error", + message: response.status === 401 + ? "Your sign-in expired. Sign in again, then return here to check your sync." + : "We could not check your usage. Your terminal can keep syncing. Try checking again.", + requiresLogin: response.status === 401, + }); + return; + } + if (!(payload.has_usage ?? payload.has_data)) { + if (Date.now() - startedAt >= WAIT_TIMEOUT_MS) { + setSync({ + phase: "error", + message: "No usage received yet. Check that the command finished in your terminal, then check again.", + }); + return; + } + // Schedule only after the previous request finishes, so checks never overlap. + pollTimeout = setTimeout(checkUsage, POLL_INTERVAL_MS); + return; + } + observedUsage = payload as UsageStatus; + confirmedUsageRef.current = observedUsage; + hasExistingUsageRef.current = !commandCopiedRef.current; + } - if (!res.ok) { - const payload = await res.json().catch(() => ({})); - setCompletionError( - typeof payload.error === "string" - ? payload.error - : "We saw your usage, but setup could not be completed. Refresh and try again.", - ); - return; - } + setSync({ phase: "confirming", data: observedUsage }); + const { response, payload } = await request("/api/users/me", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + onboarding_completed: true, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }), + }); + if (!active) return; + if (!response.ok) { + setSync({ + phase: "error", + data: observedUsage, + message: response.status === 401 + ? "Your usage arrived, but your sign-in expired. Sign in again, then return here to finish setup." + : typeof payload.error === "string" + ? payload.error + : "Your usage arrived, but we could not finish setup. Try again.", + requiresLogin: response.status === 401, + }); + return; + } - if (!activationTrackedRef.current) { - activationTrackedRef.current = true; - trackActivationEvent("activation_completed", { - surface: "onboarding", - activation_state: "activated", - is_authenticated: true, - session_count: observedUsage.session_count, - total_tokens: observedUsage.total_tokens, - total_cost_usd: observedUsage.cost_usd, - has_existing_usage: hasExistingUsage, - "$insert_id": observedUsage.latest_usage_id - ? `activation_completed:${observedUsage.latest_usage_id}` - : "activation_completed:onboarding", + if (!activationTrackedRef.current) { + activationTrackedRef.current = true; + trackActivationEvent("activation_completed", { + surface: "onboarding", + activation_state: "activated", + is_authenticated: true, + session_count: observedUsage.session_count, + total_tokens: observedUsage.total_tokens, + total_cost_usd: observedUsage.cost_usd, + has_existing_usage: hasExistingUsageRef.current, + "$insert_id": observedUsage.latest_usage_id + ? `activation_completed:${observedUsage.latest_usage_id}` + : "activation_completed:onboarding", + }); + } + setSync({ phase: "success", data: observedUsage }); + } catch { + if (!active) return; + setSync({ + phase: "error", + data: observedUsage ?? undefined, + message: observedUsage + ? "Your usage arrived, but we could not finish setup. Check your connection and try again." + : "We could not check your usage. Check your connection and try again.", }); } } - void persistActivation(); - }, [data, hasExistingUsage, phase]); + void checkUsage(); + return () => { + active = false; + clearTimeout(pollTimeout); + clearTimeout(requestTimeout); + controller?.abort(); + }; + }, [attempt]); + + async function handleCopy() { + setCopyError(false); + try { + await navigator.clipboard.writeText(SYNC_COMMAND); + commandCopiedRef.current = true; + trackActivationEvent("sync_command_copied", { + surface: "onboarding", + command: SYNC_COMMAND, + activation_state: "sync_command_copied", + is_authenticated: true, + }); + setCopied(true); + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + setCopyError(true); + } + } + + const data = "data" in sync ? sync.data : undefined; - if (phase === "success" && data) { - return ( - <> -
- -
+ return ( + <> +
+ +
-
-
-

- Your usage is live. Straude can now build your streak, spend totals, - and shareable session history. -

+
+ {sync.phase === "success" &&
+

+ {data + ? "Your logged spend and tokens are ready. Keep syncing to build your history and streak." + : "Run this command in the terminal on the computer where you use your coding agent. Sign in when prompted, then return here."} +

- {/* Stats grid */} -
+ {data ? ( +
-

Cost

-

- ${formatCurrency(data.cost_usd)} -

+
Logged spend
+
${formatCurrency(data.cost_usd)}
-

Tokens

-

- {formatTokens(data.total_tokens ?? 0)} -

+
Logged tokens
+
{formatTokens(data.total_tokens ?? 0)}
-

Sessions

-

- {data.session_count} -

+
Latest usage model
+
{data.top_model ?? "Not reported"}
-

Top model

-

- {data.top_model ?? "—"} -

+
Latest usage date
+
+ {data.latest_usage_date ? : "Not reported"} +
-
- - {completionError && ( -

- {completionError} + + ) : ( + <> +

+ + event.currentTarget.select()} + className="min-w-0 flex-1 bg-transparent font-[family-name:var(--font-mono)] text-sm focus-visible:outline-2 focus-visible:outline-accent" + /> + +
+

+ {copyError ? "Clipboard access was blocked. Select the command above and copy it manually." : copied ? "Copied to clipboard" : "Requires Node.js 18 or later. No global install needed."} +

+

+ Straude uses ccusage to read local usage from supported coding agents, including Claude Code and Codex. The first sync checks your last three days. +

+

+ Only aggregate stats leave your machine: token counts, cost, and model names. Your prompts, code, and conversations stay private.{" "} + Privacy policy

- )} + + )} -
- {data.latest_post_url && ( + {sync.phase === "waiting" && ( +

+ Waiting for your first sync. This page checks automatically. +

+ )} + {sync.phase === "confirming" && ( +

Finishing setup…

+ )} + {sync.phase === "error" && ( +
+

{sync.message}

+
- )} - -
- -
- - - -
- - ); - } - - // Waiting state - return ( - <> -
- -
- -

- Sync your first session -

-

- Run this in your terminal after a Claude Code or Codex session. Straude - will post your usage stats as soon as the web app sees them. -

- - {/* Copy-to-clipboard command */} - -

- {copied ? "Copied to clipboard" : "Click to copy"} -

- - {/* Privacy assurance */} -

- Only aggregate stats leave your machine - token counts, cost, model - names. Your prompts, code, and conversations never do.{" "} - - Privacy policy - -

- - {/* Listening indicator */} -
- - Listening for your first session… -
- -
- -
- -
- - - -
- - ); -} - -type UsernameStatus = "idle" | "checking" | "available" | "taken" | "invalid"; - -export default function OnboardingPage() { - const [step, setStep] = useState(1); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - // Step 1 - const [username, setUsername] = useState(""); - const [displayName, setDisplayName] = useState(""); - const [usernameStatus, setUsernameStatus] = useState("idle"); - const debounceRef = useRef>(undefined); - - // Auto-detect timezone - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - - // Pre-fill from existing profile (e.g. GitHub OAuth data) - useEffect(() => { - async function loadProfile() { - const res = await fetch("/api/users/me"); - if (res.ok) { - const profile = await res.json(); - // Pre-fill username: use existing username (e.g. auto-claimed from GitHub), - // otherwise suggest from GitHub handle - if (profile.username) { - setUsername(profile.username); - } else if (profile.github_username) { - const suggested = profile.github_username - .toLowerCase() - .replace(/[^a-z0-9_]/g, "_") - .replace(/^_+|_+$/g, "") - .slice(0, 20); - if (/^[a-z0-9_]{3,20}$/.test(suggested)) { - setUsername(suggested); - } - } - if (profile.display_name) setDisplayName(profile.display_name); - } - } - loadProfile(); - }, []); - - // Debounced username availability check - useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - - if (!username || username.length < 3) { - setUsernameStatus(username.length > 0 ? "invalid" : "idle"); - return; - } - - if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) { - setUsernameStatus("invalid"); - return; - } - - setUsernameStatus("checking"); - debounceRef.current = setTimeout(async () => { - const res = await fetch( - `/api/users/check-username?username=${encodeURIComponent(username)}` - ); - if (res.ok) { - const data = await res.json(); - setUsernameStatus(data.available ? "available" : "taken"); - } - }, 400); - - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [username]); - - async function handleProfileSave() { - setSaving(true); - setError(null); - - const body: Record = { - timezone, - }; - if (username) body.username = username; - if (displayName) body.display_name = displayName; - - const res = await fetch("/api/users/me", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const data = await res.json(); - setError(data.error ?? "Something went wrong"); - setSaving(false); - return; - } - - setSaving(false); - setStep(3); - } - - // Allow proceeding if username is valid+available, or if left empty (optional) - const canProceed = - usernameStatus === "available" || (!username && usernameStatus === "idle"); - - if (step === 1) { - return ( - <> -
- -
- -

- Claim your handle -

-

- Let friends find you and see your stats. This is your public identity - on Straude. -

- -
-
- -
- - setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, "")) - } - placeholder="your_handle" - maxLength={20} - /> -
- {usernameStatus === "checking" && ( - - )} - {usernameStatus === "available" && ( - - )} - {usernameStatus === "taken" && ( - - )} -
-
-

- {usernameStatus === "idle" && "3-20 characters, letters, numbers, underscores"} - {usernameStatus === "invalid" && "3-20 characters, letters, numbers, underscores"} - {usernameStatus === "checking" && "Checking availability\u2026"} - {usernameStatus === "available" && ( - - straude.com/u/{username} is yours - - )} - {usernameStatus === "taken" && ( - Already taken - )} -

-
- -
- - setDisplayName(e.target.value)} - placeholder="How you want to appear" - /> + {sync.requiresLogin && Sign in again}
- -
- -
- -
- - - -
- - ); - } - - if (step === 2) { - return ( - <> -
- -
- -

- Ready to sync -

-

- Your first sync unlocks the useful parts of Straude: spend, tokens, - streaks, and a shareable session you can edit after it lands. -

- -
-
-
-
-
-
-
- - {error &&

{error}

} - -
- - -
- -
- - - -
- - ); - } - - // Step 3: Log your first session - return ; +

+ Make it yours: add a handle and profile details (optional). +

+ + ) : ( + <> + +

Your handle and profile details are optional. Add them in Settings after your first sync.

+ + )} + + ); } From 506bfc5efd78926ae7f371f7621465bda2a6f639 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Fri, 4 Sep 2026 17:09:21 -0700 Subject: [PATCH 3/4] Record activation decisions and issue dispositions Document observable first-sync success conditions, retain ccusage, and carry forward source-attribution and model-presentation questions from the superseded provider review. Co-authored-by: Codex --- docs/CHANGELOG.md | 9 +++++++++ docs/DECISIONS.md | 15 +++++++++++++++ docs/ROADMAP.md | 5 +++++ 3 files changed, 29 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c6b73e30..bdace0d3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Changed + +- **Show the first-sync command immediately after signup.** Onboarding opens with `npx straude@latest`, live usage checks, and privacy guidance. Handle and profile editing move to Settings. Users can explore the feed without marking onboarding complete; confirmed usage unlocks real stats and the completion step. + +### Fixed + +- **Recover from interrupted first-sync setup.** Clipboard failures offer manual copying, failed usage checks have a retry action, and failed completion saves can be retried without repeating the sync. The success summary describes usage totals rather than treating daily usage rows as individual coding sessions. +- **Keep CLI authorization open through sign-in and signup.** Magic links, GitHub sign-in, and login/signup navigation preserve a validated local return destination. A new account can return to the CLI authorization page before optional profile setup. + ### Fixed - **CLI telemetry shutdown no longer leaks a "Timeout while shutting down PostHog" exception.** `@posthog/core` rejects `_shutdown` when the flush exceeds the timeout; the rejection could win the race in `shutdownTelemetryWithTimeout`, becoming an unhandled rejection that exception autocapture re-reported to PostHog daily and that skipped the CLI's final `process.exit`. The rejection is now swallowed — a slow telemetry flush is expected and silent. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 461ce624..b68b7d52 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,5 +1,17 @@ # Architecture & Design Decisions +## Put the first sync before optional profile setup (2026-09-04) + +**Problem:** Issue [#19](https://github.com/ohong/straude/issues/19) identifies the gap between signup and the first successful sync. Live status checks, three-day first-run backfill, and a CLI sign-in action already exist. However, onboarding still requires two screens before showing the command. Failed checks can appear to wait indefinitely, and login/signup discard the CLI's return destination. + +**Decision:** Show the sync command on the first onboarding screen. Keep profile editing in Settings. Reuse `/api/usage/status` and the existing completion PATCH, with visible failure states and retry actions. Preserve safe local return destinations through both authentication methods and between login and signup. CLI authorization remains an explicit action. + +**Alternatives:** Keeping profile fields beside the command preserves early customization but splits attention and retains availability checks before value. A separate CLI-first signup funnel could demonstrate value earlier but changes the CLI and acquisition flow. The selected web change removes the existing obstacle with a smaller scope. + +**Success conditions:** A signed-in new user sees the command without editing a profile; transient failures offer a next action; real usage is required before activation; login returns a CLI user to authorization. Copying a command and browsing the feed do not count as activation. Daily usage counts are not presented as individual coding-session counts. + +**Measurement:** Local tests establish these behaviors, not a conversion lift. After release, compare `signup_completed` to `first_sync_confirmed` and `activation_completed`, including time to first sync. Profile setup is optional and is no longer a required funnel step. + ## Route sensitive mutations through the server service client (2026-08-27) **Decision:** Publishable Supabase roles retain only the reads and narrow self-service updates the browser needs. Security-sensitive writes go through authenticated API routes, which validate ownership and input before using the server service client. `SECURITY DEFINER` functions authorize their callers or restrict execution to the service role; public read RPCs expose explicit fields, enforce ownership joins, and bound batch and pagination work. @@ -10,6 +22,7 @@ **Trade-offs:** Route availability now gates sensitive writes, and the service client must preserve each route's ownership predicates. Migration tests lock the grants, function signatures, public field lists, ownership joins, and work limits. Full migration behavior still requires the local Supabase integration suite or equivalent PostgreSQL validation. + ## Negotiate curated Markdown through the Next.js proxy (2026-08-21) **Decision:** The agent independently chose q-value- and specificity-aware content negotiation in the existing Next.js 16 `proxy.ts`. Markdown-preferred requests for supported public informational pages rewrite to a dedicated internal route handler; ordinary HTML, API, asset, mutation, RSC, OAuth callback, and Supabase session behavior remain on their existing paths. Curated Markdown is available for `/`, `/about`, `/contact`, `/privacy`, `/cli`, and `/open`. Unknown document paths can return a recovery-oriented Markdown 404, while an explicit registry of existing static and dynamic page patterns prevents valid HTML-only routes from becoming false 404s. @@ -22,6 +35,8 @@ ## Delegate all usage accounting to bundled ccusage v20 (2026-06-09) +**Reconfirmed (2026-09-04):** Keep ccusage following its speed and coding-agent support improvements, as requested during issue triage. [#99](https://github.com/ohong/straude/issues/99)'s AgentsView replacement proposal is not planned. The current native collector already accepts all sources emitted by ccusage; Straude will not revive the separate provider parsers reviewed in [#28](https://github.com/ohong/straude/issues/28). Model presentation, source attribution, and unsupported sources remain explicit roadmap items. + **Decision:** All supported coding-agent ingestion runs through a single bundled `ccusage` (compatible `^20.0.16` range) invoked as a native binary, with a `>=20.0.16` accuracy floor validated against the bundled package version. Straude's native collectors, token normalizer, source whitelist, and pricing aliases are deleted; Straude only parses ccusage's unified daily JSON into storage rows. **Alternatives considered:** (a) Keep the native Codex collector in parallel with ccusage Claude collection — rejected because it duplicates upstream parsing/dedupe/pricing work that ccusage now does correctly (v20 ships `metadata.agents`, archived-session dedupe, `thread_spawn` replay skipping) and was the source of two past inflation incidents. (b) Use a global `ccusage` from PATH — rejected because version skew on user machines breaks the accuracy floor; bundling pins the exact behavior we tested. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9ab57cc2..0fb98d0b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -38,6 +38,7 @@ Discovered while triaging PRs #77 and #22, both of which hand-wrote parsers for - `prettifyModel` cases for Gemini, Qwen, Kimi and Copilot model IDs, so they read as product names rather than raw slugs. - Deliberate entries in `MODEL_COLOR_PATTERNS` for those families, so they get a chosen colour instead of a hashed one from the fallback palette. - Landing-page and README copy naming the supported agents. "Works with your whole toolkit" is a stronger acquisition line than "works with Claude Code", and it costs no engineering. +- Preserve source attribution if a future UI needs to distinguish the same model name across agents. Current model-cost breakdowns aggregate by model name, while collector metadata records contributing agents for the day. Adding an optional field alone would not preserve attribution through aggregation; define that behavior before changing the storage contract. This carries forward the relevant design question from [#28](https://github.com/ohong/straude/issues/28), whose custom-parser review is superseded by bundled ccusage. - Mistral Vibe is the one real gap — it is not a ccusage source. Best path is upstreaming it to ccusage rather than carrying a Straude-local parser. ### Team / Org Workspaces @@ -70,6 +71,10 @@ Requires: new server-side call in the leaderboard API route, caching strategy (r ## Activation +### Measure the shorter first-sync flow + +The conversion follow-up to [#19](https://github.com/ohong/straude/issues/19) puts the sync command before optional profile setup, adds retry controls, and preserves CLI authorization through sign-in. After release, compare signup-to-confirmed-sync conversion and time to first sync using the existing activation events. Local functional tests cannot establish a conversion lift. A separate CLI-first signup funnel remains an experiment to consider if this shorter web flow still loses users before their first sync. + ### Ship Week Countdown Banner Show "4 days left — 3/5 synced" banner for users in their first week. The Ship Week achievement is live but the countdown UI is deferred. Creates urgency in the critical first 7 days. From 9ae41a59cdb8de6485479e706fb4b86b4d94c058 Mon Sep 17 00:00:00 2001 From: Oscar Hong Date: Fri, 4 Sep 2026 17:16:25 -0700 Subject: [PATCH 4/4] Verify first-sync onboarding with local authenticated browser tests Cover immediate access and skipping, real usage confirmation and completion, and retry after a failed save. Use isolated loopback-only test accounts and capture desktop/mobile evidence without storing sessions in the repository. Co-authored-by: Codex --- apps/web/e2e/onboarding-first-sync.spec.ts | 361 +++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 apps/web/e2e/onboarding-first-sync.spec.ts diff --git a/apps/web/e2e/onboarding-first-sync.spec.ts b/apps/web/e2e/onboarding-first-sync.spec.ts new file mode 100644 index 00000000..e45659bd --- /dev/null +++ b/apps/web/e2e/onboarding-first-sync.spec.ts @@ -0,0 +1,361 @@ +import { createChunks, stringToBase64URL } from "@supabase/ssr"; +import { createClient, type Session } from "@supabase/supabase-js"; +import { test, expect, type BrowserContext, type Page, type TestInfo } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; + +type LocalEnv = { + supabaseUrl: string; + publishableKey: string; + secretKey: string; + skipReason?: string; +}; + +type LocalUser = { + id: string; + email: string; + password: string; + usage?: { + id: string; + cost_usd: number; + total_tokens: number; + session_count: number; + top_model: string; + latest_usage_date: string; + }; +}; + +function parseEnvFile(path: string): Map { + try { + return new Map( + readFileSync(path, "utf8") + .split("\n") + .filter((line) => line.trim() && !line.trim().startsWith("#")) + .flatMap((line) => { + const separator = line.indexOf("="); + if (separator < 0) return []; + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim().replace(/^"(.*)"$/, "$1"); + return [[key, value] as const]; + }), + ); + } catch { + return new Map(); + } +} + +function readLocalEnv(): LocalEnv { + const fileEnv = parseEnvFile(resolve(process.cwd(), ".env.local")); + const value = (key: string) => process.env[key] || fileEnv.get(key) || ""; + const supabaseUrl = value("NEXT_PUBLIC_SUPABASE_URL"); + const publishableKey = value("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY"); + const secretKey = value("SUPABASE_SECRET_KEY"); + + // CI often supplies placeholder values so unrelated browser tests can run. + // Do not turn those placeholders into a live Supabase call. + const isPlaceholder = (candidate: string) => + /(?:placeholder|dummy|changeme|replace[_ -]?me|your[_ -]?key|example(?:\.com)?|^test(?:[-_]|$))/i.test(candidate); + + if (!supabaseUrl || !publishableKey || !secretKey || [supabaseUrl, publishableKey, secretKey].some(isPlaceholder)) { + return { + supabaseUrl, + publishableKey, + secretKey, + skipReason: + "Skipped: local Supabase URL, publishable key, and secret key are required.", + }; + } + + if (!isLoopbackUrl(supabaseUrl)) { + return { + supabaseUrl, + publishableKey, + secretKey, + skipReason: + `Refusing onboarding E2E against non-loopback Supabase URL ${supabaseUrl}.`, + }; + } + + return { supabaseUrl, publishableKey, secretKey }; +} + +function isLoopbackUrl(url: string): boolean { + try { + const host = new URL(url).hostname; + return host === "localhost" || host === "127.0.0.1" || host === "::1"; + } catch { + return false; + } +} + +const env = readLocalEnv(); + +function adminClient() { + return createClient(env.supabaseUrl, env.secretKey, { + auth: { + autoRefreshToken: false, + detectSessionInUrl: false, + persistSession: false, + }, + }); +} + +async function createLocalUser(withUsage = false): Promise { + const admin = adminClient(); + const suffix = randomUUID().replaceAll("-", "").slice(0, 20); + const email = `onboarding-e2e-${suffix}@local.test`; + const password = `E2E-${randomUUID()}-Aa1!`; + const { data: authData, error: authError } = await admin.auth.admin.createUser({ + email, + password, + email_confirm: true, + }); + if (authError || !authData.user) { + throw new Error(`Could not create local E2E user: ${authError?.message ?? "missing user"}`); + } + + const { error: profileError } = await admin.from("users").upsert( + { + id: authData.user.id, + is_public: true, + onboarding_completed: false, + timezone: "UTC", + }, + { onConflict: "id" }, + ); + if (profileError) { + const { error: cleanupError } = await admin.auth.admin.deleteUser(authData.user.id); + throw new Error( + [ + `Could not create local E2E profile: ${profileError.message}`, + cleanupError ? `Cleanup also failed: ${cleanupError.message}` : "", + ].filter(Boolean).join(" "), + ); + } + + const user: LocalUser = { id: authData.user.id, email, password }; + if (withUsage) { + const usage = { + user_id: user.id, + date: new Date().toISOString().slice(0, 10), + cost_usd: 12.34, + input_tokens: 1_000, + output_tokens: 234, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 1_234, + models: ["gpt-5.6-terra"], + session_count: 2, + is_verified: true, + raw_hash: `onboarding-e2e-${user.id}`, + }; + const { data: usageRow, error: usageError } = await admin + .from("daily_usage") + .insert(usage) + .select("id,date,cost_usd,total_tokens,session_count,models") + .single(); + if (usageError || !usageRow) { + const { error: cleanupError } = await admin.auth.admin.deleteUser(user.id); + throw new Error( + [ + `Could not seed local E2E usage: ${usageError?.message ?? "missing row"}`, + cleanupError ? `Cleanup also failed: ${cleanupError.message}` : "", + ].filter(Boolean).join(" "), + ); + } + user.usage = { + id: usageRow.id, + cost_usd: Number(usageRow.cost_usd), + total_tokens: Number(usageRow.total_tokens), + session_count: Number(usageRow.session_count), + top_model: Array.isArray(usageRow.models) ? String(usageRow.models[0]) : "gpt-5.6-terra", + latest_usage_date: usageRow.date, + }; + } + + return user; +} + +async function deleteLocalUser(user: LocalUser): Promise { + // The auth FK cascades to public.users and its usage/post rows. This targets + // only the unique user created by the current test. + const { error } = await adminClient().auth.admin.deleteUser(user.id); + if (error) throw new Error(`Could not clean local E2E user ${user.id}: ${error.message}`); +} + +async function signInAndSetSsrCookies( + context: BrowserContext, + user: LocalUser, + appOrigin: string, +): Promise { + const client = createClient(env.supabaseUrl, env.publishableKey, { + auth: { + autoRefreshToken: false, + detectSessionInUrl: false, + persistSession: false, + }, + }); + const { data, error } = await client.auth.signInWithPassword({ + email: user.email, + password: user.password, + }); + if (error || !data.session) { + throw new Error(`Could not sign in local E2E user: ${error?.message ?? "missing session"}`); + } + + const session = data.session as Session; + const storageKey = `sb-${new URL(env.supabaseUrl).hostname.split(".")[0]}-auth-token`; + const encoded = `base64-${stringToBase64URL(JSON.stringify(session))}`; + await context.addCookies( + createChunks(storageKey, encoded).map(({ name, value }) => ({ + name, + value, + url: appOrigin, + httpOnly: false, + secure: false, + sameSite: "Lax" as const, + })), + ); +} + +function projectBaseUrl(testInfo: TestInfo): string { + const baseURL = testInfo.project.use.baseURL; + if (typeof baseURL !== "string" || !baseURL) { + throw new Error("Playwright project must provide a loopback baseURL for onboarding E2E."); + } + return baseURL; +} + +async function openOnboarding(page: Page, user: LocalUser, testInfo: TestInfo): Promise { + await signInAndSetSsrCookies(page.context(), user, projectBaseUrl(testInfo)); + const response = await page.goto("/onboarding"); + expect(response?.status(), "authenticated onboarding page should load").toBe(200); +} + +async function expectIncompleteProfile(user: LocalUser): Promise { + const { data, error } = await adminClient() + .from("users") + .select("onboarding_completed") + .eq("id", user.id) + .single(); + expect(error).toBeNull(); + expect(data?.onboarding_completed).toBe(false); +} + +test.describe("first-sync onboarding", () => { + test.beforeEach(({}, testInfo) => { + if (env.skipReason?.startsWith("Refusing")) { + throw new Error(env.skipReason); + } + const appOrigin = projectBaseUrl(testInfo); + if (!isLoopbackUrl(appOrigin)) { + throw new Error(`Refusing onboarding E2E against non-loopback app URL ${appOrigin}.`); + } + test.skip(Boolean(env.skipReason), env.skipReason); + }); + + test("shows the command immediately and skip keeps activation incomplete", async ({ page }, testInfo) => { + const user = await createLocalUser(); + try { + await openOnboarding(page, user, testInfo); + await expect(page.locator("#sync-command")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Sync your first session" })).toBeVisible(); + await expect(page.getByText("Claim your handle")).toHaveCount(0); + await expect(page.getByRole("button", { name: "Explore without syncing" })).toBeVisible(); + + await page.screenshot({ path: testInfo.outputPath("onboarding-command-desktop.png"), fullPage: true }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: testInfo.outputPath("onboarding-command-mobile.png"), fullPage: true }); + + await page.getByRole("button", { name: "Explore without syncing" }).click(); + await expect(page).toHaveURL(/\/feed$/); + await expectIncompleteProfile(user); + } finally { + await deleteLocalUser(user); + } + }); + + test("moves from waiting to real stats and completes onboarding", async ({ page }, testInfo) => { + const user = await createLocalUser(true); + try { + let statusCalls = 0; + await page.route("**/api/usage/status", async (route) => { + statusCalls += 1; + if (statusCalls === 1) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ has_data: false, has_usage: false }), + }); + return; + } + await route.continue(); + }); + + const patchResponse = page.waitForResponse((response) => + response.url().endsWith("/api/users/me") && response.request().method() === "PATCH", + ); + await openOnboarding(page, user, testInfo); + await expect(page.getByText("Waiting for your first sync. This page checks automatically.")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Your first sync is complete" })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("$12.34")).toBeVisible(); + await expect(page.getByText("234", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Go to your feed" })).toBeVisible(); + expect((await patchResponse).status()).toBe(200); + + const { data: profile, error } = await adminClient() + .from("users") + .select("onboarding_completed") + .eq("id", user.id) + .single(); + expect(error).toBeNull(); + expect(profile?.onboarding_completed).toBe(true); + expect(statusCalls).toBeGreaterThanOrEqual(2); + } finally { + await deleteLocalUser(user); + } + }); + + test("shows completion failure and retries setup", async ({ page }, testInfo) => { + const user = await createLocalUser(true); + try { + let patchAttempts = 0; + await page.route("**/api/users/me", async (route) => { + if (route.request().method() !== "PATCH") { + await route.continue(); + return; + } + patchAttempts += 1; + if (patchAttempts === 1) { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: "Temporary setup failure" }), + }); + return; + } + await route.continue(); + }); + + await openOnboarding(page, user, testInfo); + await expect(page.getByText("Temporary setup failure", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry setup" })).toBeVisible(); + await expectIncompleteProfile(user); + + await page.getByRole("button", { name: "Retry setup" }).click(); + await expect(page.getByRole("heading", { name: "Your first sync is complete" })).toBeVisible(); + expect(patchAttempts).toBe(2); + + const { data: profile, error } = await adminClient() + .from("users") + .select("onboarding_completed") + .eq("id", user.id) + .single(); + expect(error).toBeNull(); + expect(profile?.onboarding_completed).toBe(true); + } finally { + await deleteLocalUser(user); + } + }); +});