Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b046055
Fix sign-in links, the OAuth callback and per-account state (T2 auth …
Programcode Oct 22, 2026
3891981
Address Codex review: hold loading for another account's callback, ke…
Programcode Oct 22, 2026
6b26b63
Keep the callback code in the URL until the exchange settles
Programcode Oct 22, 2026
42eb5a5
Keep the callback code after an exchange timeout
Programcode Oct 22, 2026
1d718c7
Merge remote-tracking branch 'origin/main' into fix/t2-auth-callback
Programcode Oct 22, 2026
d2b067a
Offer a retry when the callback exchange times out; bound the user lo…
Programcode Oct 22, 2026
a84cd22
Merge remote-tracking branch 'origin/main' into fix/t2-auth-callback
Programcode Oct 22, 2026
17e13f0
Keep a stored account out of a callback; settle late exchange answers
Programcode Oct 22, 2026
7f1fc05
Let only the exchange's answer sign an account in during a callback
Programcode Oct 22, 2026
074c9fe
After a callback timeout, let the user act; guard the stored-session …
Programcode Oct 22, 2026
f291919
Keep a newer sign-in when the restored stored account turns out dead
Programcode Oct 22, 2026
1354fbc
Merge remote-tracking branch 'origin/main' into fix/t2-auth-callback
Programcode Oct 22, 2026
9acb8e2
Add callback fields to the SharedPage auth mock after merging main
Programcode Oct 22, 2026
d2c9c99
Blur the paused timer button before the reset shortcut in the timer r…
Programcode Oct 22, 2026
da4638c
Merge remote-tracking branch 'origin/main' into fix/t2-auth-callback
Programcode Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-

## Features

- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`), and email one-time-code sign-in through Mailpit (`recipes/auth.spec.ts`).
- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`); email one-time-code sign-in through Mailpit, the emailed sign-in link through `/auth/callback` (including after a failed resend, and a used link), and a second account on the same browser (`recipes/auth.spec.ts`).
- [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, provider failure and retry, a reply cut off at its length cap, quota block (`recipes/case-generation.spec.ts`).
- [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`).
- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cut off at its length cap, cited, and with web search (`recipes/ai-tools.spec.ts`).
Expand Down
8 changes: 6 additions & 2 deletions .claude/skills/verify-debatelab/features/auth-demo-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log
- `auth-signout` signs out from Settings, after which `/app/generate` redirects to `/login`.
- `auth-refresh` refreshes an expired access token before an AI request.
- `auth-session-expired` signs out a session the server revoked: the stored tokens are refused, so the app goes to `/login` and shows `Your session expired. Sign in again.` once (`recipes/auth.spec.ts`).
- `auth-email-link` opens the sign-in link from the email after a resend failed with 429. It lands on `/app` through `/auth/callback` without showing `/login` on the way, and the same link opened again shows `This sign-in link has expired or was already used. Send a new code.` on `/login` (`recipes/auth.spec.ts`).
- `auth-switch-account` signs out from Settings (the logout request has `scope=local`) and signs a second account in on the same `/login` page. It lands on `/app`, not on Settings, and gets its own onboarding tour. On phones the menu still opens and navigates while the tour shows (`recipes/auth.spec.ts`).

## How to get to it (user POV)

Expand All @@ -27,11 +29,13 @@ Preconditions:
- **Persist.** Reload the page. The URL stays `/app`. On `/app/settings`, the page shows `user.email`.
- **Reject.** On a signed-out page, submit `DEMO-ZZZZ-ZZZZ`. The element with role `alert` has the text `Demo code not found.`, and the URL still matches `/login`.
- **Sign out.** On `/app/settings`, click the button named `Sign out`, then go to `/app/generate`. The URL matches `/login`.
- **Email link.** Local email templates are code-only, so the recipe builds the link prod's email carries: `${LOCAL_SUPABASE_URL}/auth/v1/verify?token=pkce_<sha224(email + code)>&type=magiclink&redirect_to=<redirect_to of the /auth/v1/otp request>`. The `redirect_to` is `http://127.0.0.1:18081/auth/callback?sb_flow_id=<32 hex>`. Route `**/auth/v1/otp**` to a 429 before clicking `Resend code` to reproduce the failed resend.
- **Switch account.** After `Sign out`, sign the second account in on the same `/login` page. `page.goto("/login")` drops the history state that the bug depended on. Create the second user with `createDemoUser` and delete it in `finally`.
- **Revoked session.** Signed in on `/app`, replace `access_token` in the `debate-auth-local` localStorage entry with a JWT the server refuses, `refresh_token` with `x`, and `expires_at` with now + 3000 s, then reload. Within a few seconds the URL matches `/login` and a `status` element says `Your session expired. Sign in again.`. A second reload no longer shows it.

## Gotchas

- A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateLab!`, `Start your first debate here.`) offers `Start debate`. The fixtures pre-seed `debate-trainer-onboarding` to skip it. Interactive runs click `Skip Tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md).
- A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateLab!`, `Start your first debate here.`) offers `Start debate`. The fixtures pre-seed `debate-trainer-onboarding` to skip it. The tour is tracked per account (store version 1, `completedUserIds`). A version 0 seed with `hasCompletedOnboarding: true` covers only the next account that signs in, so a second account in the same browser sees the tour. Interactive runs click `Skip Tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md).
- Sessions are stored under the `debate-auth-local` localStorage key in local-preview mode. Don't reuse stub-session helpers (`e2e/helpers/auth.ts`). They fake auth and prove rendering only.
- Google sign-in is not wired to the local stack. Only demo codes work here.
- Google sign-in is not wired to the local stack. Demo codes, email codes and the email link work here.
- The revoked-session check forces one token refresh before it signs out. An Auth outage (network error, 5xx, 429) keeps the session, and that path is covered by `src/test/auth/session-expiry.test.ts`, not by a recipe.
2 changes: 2 additions & 0 deletions .claude/skills/verify-debatelab/recipes/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ test("timer-run: the speech timer starts, pauses, and resets", async ({ signedIn
await page.waitForTimeout(1_500);
expect(await display.textContent()).toBe(paused);
await evidence.shot("01-paused");
// Timer shortcuts ignore a focused button (#172), and Pause still has focus.
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await page.keyboard.press("r");
await expect(display).toHaveText(initial!);
await evidence.shot("02-reset");
Expand Down
161 changes: 147 additions & 14 deletions .claude/skills/verify-debatelab/recipes/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,63 @@
import { expect, test } from "./fixtures";
import { createHash } from "node:crypto";
import type { APIRequestContext, Page } from "@playwright/test";
import { createDemoUser, debateControl, deleteDemoUser, expect, test } from "./fixtures";

// Feature map: features/auth.md. Demo-code sign-in, reload, rejection, and
// Feature map: features/auth-demo-login.md. Demo-code sign-in, reload, rejection, and
// sign-out are covered by e2e/full-stack/auth.spec.ts, which drive also runs.

const mailer = process.env.WORKSPACE_TEST_MAILER_URL;
const supabaseUrl = process.env.LOCAL_SUPABASE_URL;

test("auth-email-otp: an emailed one-time code signs an existing account in", async ({ page, user, request, evidence }) => {
test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive");
const sentAfter = Date.now();
await page.goto("/login");
await page.getByRole("tab", { name: "Email" }).click();
await page.getByLabel("Email address").fill(user.email);
await page.getByRole("button", { name: "Send code" }).click();
await expect(page.getByText("Code sent").first()).toBeVisible();
await evidence.shot("01-code-sent");

// Read the real message from the local Mailpit inbox.
/** Reads the six-digit code sent to `email` after `sentAfter` from the local Mailpit inbox. */
async function readOtp(request: APIRequestContext, email: string, sentAfter: number) {
let otp = "";
await expect.poll(async () => {
const payload = await (await request.get(`${mailer}/api/v1/messages`)).json() as { messages?: Array<Record<string, unknown>> };
for (const message of payload.messages ?? []) {
if (!JSON.stringify(message).includes(user.email)) continue;
if (!JSON.stringify(message).includes(email)) continue;
if (Date.parse(String(message.Created ?? 0)) < sentAfter - 5_000) continue;
const detail = await (await request.get(`${mailer}/api/v1/message/${message.ID}`)).text();
otp = detail.match(/\b(\d{6})\b/)?.[1] ?? "";
if (otp) break;
}
return otp;
}, { timeout: 30_000 }).toMatch(/^\d{6}$/);
return otp;
}

/** Every path this tab shows, kept across reloads, so a flash of /login is caught. */
async function recordPaths(page: Page) {
await page.addInitScript(() => {
const record = () => {
const seen = JSON.parse(sessionStorage.getItem("verify-paths") ?? "[]") as string[];
sessionStorage.setItem("verify-paths", JSON.stringify([...seen, location.pathname]));
};
for (const method of ["pushState", "replaceState"] as const) {
const original = history[method].bind(history);
history[method] = (...args: Parameters<History["pushState"]>) => {
original(...args);
record();
};
}
record();
});
return {
clear: () => page.evaluate(() => sessionStorage.removeItem("verify-paths")),
seen: () => page.evaluate(() => JSON.parse(sessionStorage.getItem("verify-paths") ?? "[]") as string[]),
};
}

test("auth-email-otp: an emailed one-time code signs an existing account in", async ({ page, user, request, evidence }) => {
test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive");
const sentAfter = Date.now();
await page.goto("/login");
await page.getByRole("tab", { name: "Email" }).click();
await page.getByLabel("Email address").fill(user.email);
await page.getByRole("button", { name: "Send code" }).click();
await expect(page.getByText("Code sent").first()).toBeVisible();
await evidence.shot("01-code-sent");

const otp = await readOtp(request, user.email, sentAfter);
await evidence.step("Read the one-time code from Mailpit");

await page.getByRole("textbox", { name: "Verification code" }).fill(otp);
Expand Down Expand Up @@ -66,3 +96,106 @@ test("auth-session-expired: a session the server revoked signs out to the login
await page.reload();
await expect(page.getByText("Your session expired. Sign in again.")).toHaveCount(0);
});

test("auth-email-link: the emailed sign-in link signs in after a failed resend, and a used link says so", async ({ page, user, request, evidence }) => {
test.skip(!mailer || !supabaseUrl, "Mailpit or LOCAL_SUPABASE_URL is missing; run through verify.sh drive");
const paths = await recordPaths(page);
const sentAfter = Date.now();
await page.goto("/login");
await page.getByRole("tab", { name: "Email" }).click();
await page.getByLabel("Email address").fill(user.email);
const otpRequest = page.waitForRequest((request) => request.url().includes("/auth/v1/otp"));
await page.getByRole("button", { name: "Send code" }).click();
// The link returns to the callback route with this sign-in's own flow id.
const redirectTo = new URL((await otpRequest).url()).searchParams.get("redirect_to") ?? "";
expect(redirectTo).toMatch(/^http:\/\/127\.0\.0\.1:18081\/auth\/callback\?sb_flow_id=[0-9a-f]{32}$/);
await expect(page.getByText("Code sent").first()).toBeVisible();
const otp = await readOtp(request, user.email, sentAfter);
await evidence.step(`Sent a code; the link returns to ${redirectTo.replace(/sb_flow_id=.*/, "sb_flow_id=<flow>")}`);

// AUTH-01: the resend hits Auth's rate limit, and auth-js drops that resend's verifier.
await page.route("**/auth/v1/otp**", (route) => route.fulfill({
status: 429,
contentType: "application/json",
body: JSON.stringify({ code: 429, error_code: "over_email_send_rate_limit", msg: "email rate limit exceeded" }),
}));
await page.getByRole("button", { name: "Resend code" }).click();
await expect(page.getByText("Too many attempts. Try again later.")).toBeVisible();
await page.unroute("**/auth/v1/otp**");
await evidence.shot("01-resend-rate-limited");

// The link in prod's email: Auth's /verify with the PKCE token hash of this code.
const link = `${supabaseUrl}/auth/v1/verify?${new URLSearchParams({
token: `pkce_${createHash("sha224").update(user.email + otp).digest("hex")}`,
type: "magiclink",
redirect_to: redirectTo,
})}`;
await paths.clear();
await page.goto(link);
await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 });
await expect(debateControl(page)).toBeVisible();
// AUTH-17: the callback waits for the exchange; it never shows /login on the way.
const seen = await paths.seen();
await evidence.json("02-paths-after-link", seen);
expect(seen).toContain("/auth/callback");
expect(seen).not.toContain("/login");
await page.reload();
await expect(page).toHaveURL(/\/app$/);
await evidence.shot("02-signed-in-by-link");
await evidence.step("Opened the emailed link after the failed resend; signed in on /app without passing /login");

// AUTH-02: the same link again, in a browser that is no longer signed in.
await page.evaluate(() => {
for (const key of Object.keys(localStorage)) if (/^debate-auth-[a-z]+$/.test(key)) localStorage.removeItem(key);
});
await page.goto(link);
await expect(page).toHaveURL(/\/login$/, { timeout: 20_000 });
await expect(page.getByRole("alert")).toHaveText("This sign-in link has expired or was already used. Send a new code.");
await evidence.shot("03-used-link-message");
await evidence.aria("03-used-link-message");
await evidence.step("Opened the used link again; /login says it expired or was used");
});

test("auth-switch-account: the next account on this browser starts on /app, gets its own tour, and sign-out stays local", async ({ signedIn: page, evidence }, testInfo) => {
const second = await createDemoUser(`${testInfo.project.name}-second`);
try {
// The first account (tour already seen) leaves off on Settings and signs out.
await page.goto("/app/settings");
const logout = page.waitForRequest((request) => request.url().includes("/auth/v1/logout"));
await page.getByRole("button", { name: "Sign out" }).click();
// AUTH-19: this device only.
expect(new URL((await logout).url()).searchParams.get("scope")).toBe("local");
await expect(page).toHaveURL(/\/login$/);
await evidence.step("Signed out from /app/settings with scope=local");

// AUTH-06: a different account signs in on the same /login page.
await page.getByRole("tab", { name: "Demo code" }).click();
await page.getByRole("textbox", { name: "Demo code" }).fill(second.code);
await page.getByRole("button", { name: "Sign in with demo code" }).click();
await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 });
await evidence.step("Signed in as a second account; landed on /app, not /app/settings");

// AUTH-07: the second account gets its own tour.
const tour = page.getByText("Welcome to DebateLab!");
await expect(tour).toBeVisible({ timeout: 10_000 });
await evidence.shot("01-second-account-tour");
if (await page.getByRole("button", { name: "Open menu" }).isVisible()) {
// On phones the tour must not block the header's menu: open it and use it.
await page.getByRole("button", { name: "Open menu" }).click();
const menu = page.getByRole("navigation", { name: "Mobile navigation" });
await expect(menu).toBeVisible();
await evidence.shot("02-menu-open-during-tour");
await menu.getByRole("link", { name: /History/ }).click();
await expect(page).toHaveURL(/\/app\/history$/);
await evidence.step("Opened the menu and went to History while the tour showed");
// The tour waits on /app and picks up again there.
await page.goBack();
await expect(tour).toBeVisible({ timeout: 10_000 });
}
await page.locator('[data-test-id="button-skip"]').click();
await expect(tour).toBeHidden();
await evidence.step("Skipped the tour");
} finally {
await deleteDemoUser(second);
}
});
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ Admin-authored rows such as promo codes, curated sources, announcements, and gra
Supabase Auth owns user sessions. Protected routes provide a client navigation boundary, but server authorization is decisive.

- Google OAuth, six-digit email OTP, and demo-code token-hash handoffs all finish as ordinary Supabase sessions and use the same store hydration path.
- Google and any emailed sign-in link return to `/auth/callback` (with `?next=/app/...` for a deep link; only `/app` paths are accepted). [`AuthProvider`](../src/lib/supabase/AuthProvider.tsx) exchanges the code there and keeps `loading` true until the exchange settles. The client sets `experimental.appendPkceFlowIdToRedirects`, so each sign-in keeps its own PKCE verifier and its redirect carries `sb_flow_id`; another tab's sign-in or a failed resend no longer breaks a pending link. An `error`/`error_code` from Auth, or a failed exchange, opens `/login` with a localized message. Links that still point at `/?code=` or `/app?code=` are moved onto the callback route before the first render ([`src/lib/auth-callback.ts`](../src/lib/auth-callback.ts)). Each project's Auth redirect allowlist needs `<site origin>/auth/callback**`; a redirect to the Site URL's own host passes without it.
- Sign-out ends only this device's session (`scope: 'local'`). After a sign-out, `/login` returns only the same account to the page it left; another account starts on `/app`. The onboarding tour is tracked per account on each browser.
- RLS owns row visibility and mutation rights.
- Shared backend helpers validate bearer tokens and admin roles.
- Admin endpoints verify server-side roles.
Expand Down
11 changes: 8 additions & 3 deletions e2e/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => {
await page.goto('/login', { waitUntil: 'domcontentloaded' });

const googleButton = page.getByRole('button', { name: 'Continue with Google' });
const expectedRedirect = `${new URL(page.url()).origin}/app`;
// Google returns to the callback route with this sign-in's own PKCE flow id.
const expectedRedirect = new RegExp(`^${new URL(page.url()).origin.replace(/[.]/g, '\\.')}/auth/callback\\?sb_flow_id=[0-9a-f]{32}$`);
await expect(googleButton).toBeVisible();
await expect(page.getByRole('tab', { name: 'Email' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Demo code' })).toBeVisible();
Expand All @@ -20,7 +21,7 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => {
await googleButton.click();
const requestUrl = new URL((await requestPromise).url());
expect(requestUrl.searchParams.get('provider')).toBe('google');
expect(requestUrl.searchParams.get('redirect_to')).toBe(expectedRedirect);
expect(requestUrl.searchParams.get('redirect_to')).toMatch(expectedRedirect);
});

test('exchanges a demo-code magic-link hash for a Supabase session', async ({ page }) => {
Expand Down Expand Up @@ -55,9 +56,12 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => {

test('sends an email code and focuses the verification field', async ({ page }) => {
let requestEmail: string | undefined;
let redirectTo: string | null = null;
let releaseOtpResponse: (() => void) | undefined;
const otpResponseReleased = new Promise<void>((resolve) => { releaseOtpResponse = resolve; });
await page.route('**/auth/v1/otp', async (route) => {
// The request carries `?redirect_to=`, the callback the emailed link opens.
await page.route('**/auth/v1/otp**', async (route) => {
redirectTo = new URL(route.request().url()).searchParams.get('redirect_to');
const body = route.request().postDataJSON() as { email?: unknown };
requestEmail = typeof body.email === 'string' ? body.email : undefined;
await otpResponseReleased;
Expand All @@ -78,6 +82,7 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => {
await expect(page.getByText('Code sent', { exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Verification code' })).toBeFocused();
expect(requestEmail).toBe('student@example.com');
expect(redirectTo).toMatch(/\/auth\/callback\?sb_flow_id=[0-9a-f]{32}$/);
});

test('shows a demo-code backend error while keeping the form editable', async ({ page }) => {
Expand Down
Loading
Loading