Skip to content

Commit da2ee2e

Browse files
committed
Merge remote-tracking branch 'origin/main' into live-stream-drain-204
2 parents 6595290 + 4338727 commit da2ee2e

35 files changed

Lines changed: 1396 additions & 143 deletions

‎.claude/skills/verify-debatelab/features/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
3939

4040
## Features
4141

42-
- [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`).
42+
- [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`).
4343
- [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`).
4444
- [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`).
4545
- [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`).

‎.claude/skills/verify-debatelab/features/auth-demo-login.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log
1010
- `auth-signout` signs out from Settings, after which `/app/generate` redirects to `/login`.
1111
- `auth-refresh` refreshes an expired access token before an AI request.
1212
- `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`).
13+
- `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`).
14+
- `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`).
1315

1416
## How to get to it (user POV)
1517

@@ -27,11 +29,13 @@ Preconditions:
2729
- **Persist.** Reload the page. The URL stays `/app`. On `/app/settings`, the page shows `user.email`.
2830
- **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`.
2931
- **Sign out.** On `/app/settings`, click the button named `Sign out`, then go to `/app/generate`. The URL matches `/login`.
32+
- **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.
33+
- **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`.
3034
- **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.
3135

3236
## Gotchas
3337

34-
- 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).
38+
- 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).
3539
- 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.
36-
- Google sign-in is not wired to the local stack. Only demo codes work here.
40+
- Google sign-in is not wired to the local stack. Demo codes, email codes and the email link work here.
3741
- 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.

‎.claude/skills/verify-debatelab/recipes/account.spec.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ test("timer-run: the speech timer starts, pauses, and resets", async ({ signedIn
8282
await page.waitForTimeout(1_500);
8383
expect(await display.textContent()).toBe(paused);
8484
await evidence.shot("01-paused");
85+
// Timer shortcuts ignore a focused button (#172), and Pause still has focus.
86+
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
8587
await page.keyboard.press("r");
8688
await expect(display).toHaveText(initial!);
8789
await evidence.shot("02-reset");

‎.claude/skills/verify-debatelab/recipes/auth.spec.ts‎

Lines changed: 147 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,63 @@
1-
import { expect, test } from "./fixtures";
1+
import { createHash } from "node:crypto";
2+
import type { APIRequestContext, Page } from "@playwright/test";
3+
import { createDemoUser, debateControl, deleteDemoUser, expect, test } from "./fixtures";
24

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

68
const mailer = process.env.WORKSPACE_TEST_MAILER_URL;
9+
const supabaseUrl = process.env.LOCAL_SUPABASE_URL;
710

8-
test("auth-email-otp: an emailed one-time code signs an existing account in", async ({ page, user, request, evidence }) => {
9-
test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive");
10-
const sentAfter = Date.now();
11-
await page.goto("/login");
12-
await page.getByRole("tab", { name: "Email" }).click();
13-
await page.getByLabel("Email address").fill(user.email);
14-
await page.getByRole("button", { name: "Send code" }).click();
15-
await expect(page.getByText("Code sent").first()).toBeVisible();
16-
await evidence.shot("01-code-sent");
17-
18-
// Read the real message from the local Mailpit inbox.
11+
/** Reads the six-digit code sent to `email` after `sentAfter` from the local Mailpit inbox. */
12+
async function readOtp(request: APIRequestContext, email: string, sentAfter: number) {
1913
let otp = "";
2014
await expect.poll(async () => {
2115
const payload = await (await request.get(`${mailer}/api/v1/messages`)).json() as { messages?: Array<Record<string, unknown>> };
2216
for (const message of payload.messages ?? []) {
23-
if (!JSON.stringify(message).includes(user.email)) continue;
17+
if (!JSON.stringify(message).includes(email)) continue;
2418
if (Date.parse(String(message.Created ?? 0)) < sentAfter - 5_000) continue;
2519
const detail = await (await request.get(`${mailer}/api/v1/message/${message.ID}`)).text();
2620
otp = detail.match(/\b(\d{6})\b/)?.[1] ?? "";
2721
if (otp) break;
2822
}
2923
return otp;
3024
}, { timeout: 30_000 }).toMatch(/^\d{6}$/);
25+
return otp;
26+
}
27+
28+
/** Every path this tab shows, kept across reloads, so a flash of /login is caught. */
29+
async function recordPaths(page: Page) {
30+
await page.addInitScript(() => {
31+
const record = () => {
32+
const seen = JSON.parse(sessionStorage.getItem("verify-paths") ?? "[]") as string[];
33+
sessionStorage.setItem("verify-paths", JSON.stringify([...seen, location.pathname]));
34+
};
35+
for (const method of ["pushState", "replaceState"] as const) {
36+
const original = history[method].bind(history);
37+
history[method] = (...args: Parameters<History["pushState"]>) => {
38+
original(...args);
39+
record();
40+
};
41+
}
42+
record();
43+
});
44+
return {
45+
clear: () => page.evaluate(() => sessionStorage.removeItem("verify-paths")),
46+
seen: () => page.evaluate(() => JSON.parse(sessionStorage.getItem("verify-paths") ?? "[]") as string[]),
47+
};
48+
}
49+
50+
test("auth-email-otp: an emailed one-time code signs an existing account in", async ({ page, user, request, evidence }) => {
51+
test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive");
52+
const sentAfter = Date.now();
53+
await page.goto("/login");
54+
await page.getByRole("tab", { name: "Email" }).click();
55+
await page.getByLabel("Email address").fill(user.email);
56+
await page.getByRole("button", { name: "Send code" }).click();
57+
await expect(page.getByText("Code sent").first()).toBeVisible();
58+
await evidence.shot("01-code-sent");
59+
60+
const otp = await readOtp(request, user.email, sentAfter);
3161
await evidence.step("Read the one-time code from Mailpit");
3262

3363
await page.getByRole("textbox", { name: "Verification code" }).fill(otp);
@@ -66,3 +96,106 @@ test("auth-session-expired: a session the server revoked signs out to the login
6696
await page.reload();
6797
await expect(page.getByText("Your session expired. Sign in again.")).toHaveCount(0);
6898
});
99+
100+
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 }) => {
101+
test.skip(!mailer || !supabaseUrl, "Mailpit or LOCAL_SUPABASE_URL is missing; run through verify.sh drive");
102+
const paths = await recordPaths(page);
103+
const sentAfter = Date.now();
104+
await page.goto("/login");
105+
await page.getByRole("tab", { name: "Email" }).click();
106+
await page.getByLabel("Email address").fill(user.email);
107+
const otpRequest = page.waitForRequest((request) => request.url().includes("/auth/v1/otp"));
108+
await page.getByRole("button", { name: "Send code" }).click();
109+
// The link returns to the callback route with this sign-in's own flow id.
110+
const redirectTo = new URL((await otpRequest).url()).searchParams.get("redirect_to") ?? "";
111+
expect(redirectTo).toMatch(/^http:\/\/127\.0\.0\.1:18081\/auth\/callback\?sb_flow_id=[0-9a-f]{32}$/);
112+
await expect(page.getByText("Code sent").first()).toBeVisible();
113+
const otp = await readOtp(request, user.email, sentAfter);
114+
await evidence.step(`Sent a code; the link returns to ${redirectTo.replace(/sb_flow_id=.*/, "sb_flow_id=<flow>")}`);
115+
116+
// AUTH-01: the resend hits Auth's rate limit, and auth-js drops that resend's verifier.
117+
await page.route("**/auth/v1/otp**", (route) => route.fulfill({
118+
status: 429,
119+
contentType: "application/json",
120+
body: JSON.stringify({ code: 429, error_code: "over_email_send_rate_limit", msg: "email rate limit exceeded" }),
121+
}));
122+
await page.getByRole("button", { name: "Resend code" }).click();
123+
await expect(page.getByText("Too many attempts. Try again later.")).toBeVisible();
124+
await page.unroute("**/auth/v1/otp**");
125+
await evidence.shot("01-resend-rate-limited");
126+
127+
// The link in prod's email: Auth's /verify with the PKCE token hash of this code.
128+
const link = `${supabaseUrl}/auth/v1/verify?${new URLSearchParams({
129+
token: `pkce_${createHash("sha224").update(user.email + otp).digest("hex")}`,
130+
type: "magiclink",
131+
redirect_to: redirectTo,
132+
})}`;
133+
await paths.clear();
134+
await page.goto(link);
135+
await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 });
136+
await expect(debateControl(page)).toBeVisible();
137+
// AUTH-17: the callback waits for the exchange; it never shows /login on the way.
138+
const seen = await paths.seen();
139+
await evidence.json("02-paths-after-link", seen);
140+
expect(seen).toContain("/auth/callback");
141+
expect(seen).not.toContain("/login");
142+
await page.reload();
143+
await expect(page).toHaveURL(/\/app$/);
144+
await evidence.shot("02-signed-in-by-link");
145+
await evidence.step("Opened the emailed link after the failed resend; signed in on /app without passing /login");
146+
147+
// AUTH-02: the same link again, in a browser that is no longer signed in.
148+
await page.evaluate(() => {
149+
for (const key of Object.keys(localStorage)) if (/^debate-auth-[a-z]+$/.test(key)) localStorage.removeItem(key);
150+
});
151+
await page.goto(link);
152+
await expect(page).toHaveURL(/\/login$/, { timeout: 20_000 });
153+
await expect(page.getByRole("alert")).toHaveText("This sign-in link has expired or was already used. Send a new code.");
154+
await evidence.shot("03-used-link-message");
155+
await evidence.aria("03-used-link-message");
156+
await evidence.step("Opened the used link again; /login says it expired or was used");
157+
});
158+
159+
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) => {
160+
const second = await createDemoUser(`${testInfo.project.name}-second`);
161+
try {
162+
// The first account (tour already seen) leaves off on Settings and signs out.
163+
await page.goto("/app/settings");
164+
const logout = page.waitForRequest((request) => request.url().includes("/auth/v1/logout"));
165+
await page.getByRole("button", { name: "Sign out" }).click();
166+
// AUTH-19: this device only.
167+
expect(new URL((await logout).url()).searchParams.get("scope")).toBe("local");
168+
await expect(page).toHaveURL(/\/login$/);
169+
await evidence.step("Signed out from /app/settings with scope=local");
170+
171+
// AUTH-06: a different account signs in on the same /login page.
172+
await page.getByRole("tab", { name: "Demo code" }).click();
173+
await page.getByRole("textbox", { name: "Demo code" }).fill(second.code);
174+
await page.getByRole("button", { name: "Sign in with demo code" }).click();
175+
await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 });
176+
await evidence.step("Signed in as a second account; landed on /app, not /app/settings");
177+
178+
// AUTH-07: the second account gets its own tour.
179+
const tour = page.getByText("Welcome to DebateLab!");
180+
await expect(tour).toBeVisible({ timeout: 10_000 });
181+
await evidence.shot("01-second-account-tour");
182+
if (await page.getByRole("button", { name: "Open menu" }).isVisible()) {
183+
// On phones the tour must not block the header's menu: open it and use it.
184+
await page.getByRole("button", { name: "Open menu" }).click();
185+
const menu = page.getByRole("navigation", { name: "Mobile navigation" });
186+
await expect(menu).toBeVisible();
187+
await evidence.shot("02-menu-open-during-tour");
188+
await menu.getByRole("link", { name: /History/ }).click();
189+
await expect(page).toHaveURL(/\/app\/history$/);
190+
await evidence.step("Opened the menu and went to History while the tour showed");
191+
// The tour waits on /app and picks up again there.
192+
await page.goBack();
193+
await expect(tour).toBeVisible({ timeout: 10_000 });
194+
}
195+
await page.locator('[data-test-id="button-skip"]').click();
196+
await expect(tour).toBeHidden();
197+
await evidence.step("Skipped the tour");
198+
} finally {
199+
await deleteDemoUser(second);
200+
}
201+
});

‎docs/architecture.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ Admin-authored rows such as promo codes, curated sources, announcements, and gra
132132
Supabase Auth owns user sessions. Protected routes provide a client navigation boundary, but server authorization is decisive.
133133

134134
- 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.
135+
- 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.
136+
- 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.
135137
- RLS owns row visibility and mutation rights.
136138
- Shared backend helpers validate bearer tokens and admin roles.
137139
- Admin endpoints verify server-side roles.

‎e2e/login.spec.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ test.describe('login passwordless flows', { tag: '@smoke' }, () => {
1111
await page.goto('/login', { waitUntil: 'domcontentloaded' });
1212

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

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

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

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

0 commit comments

Comments
 (0)