Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .claude/skills/verify-debatelab/features/auth-demo-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log
- `auth-reject` shows the alert `Demo code not found.` for an unknown code and stays on `/login`.
- `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`).

## How to get to it (user POV)

Expand All @@ -26,9 +27,11 @@ 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`.
- **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).
- 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.
- 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.
5 changes: 5 additions & 0 deletions .claude/skills/verify-debatelab/features/practice.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ Live practice modes. Sparring runs a multi-speech round against an AI opponent a
- `cross-turn` answers a user question with an AI response and attaches a Jev `Direct answer` signal.
- `cross-debrief` ends the session and shows the debrief. A `crossfires` row is filed in the debate for its resolution.
- `cross-ai-questions` reverses the roles: the AI asks (`mode-ai-questions-user`), the user answers, and the AI follows up.
- `cross-daily-limit` with no AI requests left, the first AI turn is refused: the daily-limit dialog opens and the page stays in setup with no clock and no `end-crossfire`.
- `cross-end-early` ending a round before any exchange returns to setup with no error.
- `spar-daily-limit` Sparring setup warns (test id `sparring-request-shortfall`) when today's remaining requests are fewer than the round needs.

## How to get to it (user POV)

Expand All @@ -27,10 +30,12 @@ Preconditions:
- **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`.
- **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired.
- **Debrief.** Click `end-crossfire`. Test id `crossfire-debrief` is visible. `crossfires` for the user equals `[{title: <resolution>, debates: {motion: <resolution>}}]`.
- **Out of requests.** Through `admin`, set `user_settings.daily_limit_override` to N, upsert today's `daily_usage_counters` row (`quota_key: "ai"`, `request_count: N`; what the `ai` function checks), and insert N `ai_requests` rows (what the browser counts). Start Crossfire in `mode-ai-questions-user`: a `dialog` containing `N of N` opens and `start-crossfire` is back. On `/app/sparring`, `sparring-request-shortfall` says `You have no AI requests left today`.

## Gotchas

- Opponent speeches carry a server-signed integrity token. Scoring fails if the client drops it, so a verdict proves the round-trip.
- Jev is faked with a fixed confident answer. `Direct answer` proves the wiring, not the quality of the judgement.
- Sparring AI calls happen once per opponent turn. Keep the per-step wait near 30 s rather than one long wait for the verdict.
- The browser's remaining-requests count leaves out `rate_limited` and `jev_*` rows and cannot see global `rate_limits` rows, so it warns and never blocks. The `ai` function's 402 decides.
- In `mode-ai-questions-user` the opening question is requested as soon as the round starts, after the motion dialog. Create the `aiResponse(page, "crossfire", "next-turn")` promise before clicking `start-crossfire`.
29 changes: 29 additions & 0 deletions .claude/skills/verify-debatelab/recipes/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,32 @@ test("auth-email-otp: an emailed one-time code signs an existing account in", as
await expect(page).toHaveURL(/\/app/);
await evidence.shot("02-signed-in-by-email");
});

test("auth-session-expired: a session the server revoked signs out to the login page with a message", async ({ signedIn: page, evidence }) => {
await page.goto("/app");
await expect(page).toHaveURL(/\/app$/);
// Like an account deleted in another tab or a password reset: the stored
// tokens are still there, but the server refuses both of them.
await page.evaluate(() => {
const key = Object.keys(localStorage).find((name) => /^debate-auth-[a-z]+$/.test(name));
if (!key) throw new Error("no stored session");
const stored = JSON.parse(localStorage.getItem(key)!) as Record<string, unknown>;
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" })).replace(/=+$/, "");
const claims = btoa(JSON.stringify({ sub: "00000000-0000-4000-8000-000000000000", exp: Math.floor(Date.now() / 1000) + 3000 })).replace(/=+$/, "");
localStorage.setItem(key, JSON.stringify({
...stored,
access_token: `${header}.${claims}.invalid-signature`,
refresh_token: "x",
expires_at: Math.floor(Date.now() / 1000) + 3000,
}));
});
await evidence.step("Replaced the stored session with tokens the server refuses");
await page.reload();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
await expect(page.getByRole("status").filter({ hasText: "Your session expired. Sign in again." })).toBeVisible();
await evidence.shot("01-login-session-expired");
await evidence.aria("01-login-session-expired");
// The notice is for this sign-out only.
await page.reload();
await expect(page.getByText("Your session expired. Sign in again.")).toHaveCount(0);
});
51 changes: 51 additions & 0 deletions .claude/skills/verify-debatelab/recipes/practice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,54 @@ test("cross-ai-questions: the AI questions the user, answers are judged, and the
await evidence.shot("02-debrief");
await expectCrossfireSaved(await user.userId());
});

/**
* Today's AI requests are used up: the server's counter (what the ai function
* checks) and the usage rows (what the browser counts). A refused call and a
* Jev helper row are on the list too, and must not count as used.
*/
async function useUpDailyRequests(userId: string, limit: number) {
await admin.from("user_settings").update({ daily_limit_override: limit }).eq("user_id", userId);
const today = new Date().toISOString().slice(0, 10);
const counter = await admin.from("daily_usage_counters").upsert({ user_id: userId, usage_date: today, quota_key: "ai", request_count: limit });
expect(counter.error).toBeNull();
const rows = [
...Array.from({ length: limit }, () => ({ user_id: userId, feature: "generate", provider: "openrouter", model: "fake", status: "success" })),
{ user_id: userId, feature: "ask", provider: "openrouter", model: "fake", status: "rate_limited" },
{ user_id: userId, feature: "jev_model_router", provider: "openrouter", model: "fake", status: "success" },
];
const usage = await admin.from("ai_requests").insert(rows);
expect(usage.error).toBeNull();
}

test("cross-daily-limit and spar-daily-limit: with no requests left, Crossfire stays in setup and Sparring warns first", async ({ signedIn: page, user, evidence }) => {
await useUpDailyRequests(await user.userId(), 2);
await evidence.step("Used up the account's 2 daily AI requests");

await startCrossfire(page, "mode-ai-questions-user");
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: AI_TIMEOUT });
await expect(dialog).toContainText("2 of 2");
await evidence.shot("01-crossfire-limit-dialog");
await evidence.aria("01-crossfire-limit-dialog", dialog);
// The round never started: no clock, no End button, the setup kept.
await expect(page.getByTestId("end-crossfire")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.getByTestId("start-crossfire")).toBeVisible();
await expect(page.getByTestId("crossfire-resolution")).toHaveValue(resolution);
await evidence.shot("02-crossfire-back-in-setup");

await page.goto("/app/sparring");
await expect(page.getByTestId("sparring-request-shortfall")).toContainText("You have no AI requests left today");
await evidence.shot("03-sparring-warns");
await evidence.aria("03-sparring-warns", page.getByTestId("sparring-request-shortfall"));
});

test("cross-end-early: ending a round before any exchange returns to setup without an error", async ({ signedIn: page, evidence }) => {
await startCrossfire(page, "mode-question-ai");
await expect(page.getByTestId("end-crossfire")).toBeVisible();
await page.getByTestId("end-crossfire").click();
await expect(page.getByTestId("start-crossfire")).toBeVisible();
await expect(page.getByText("Complete at least one exchange before ending the session.")).toHaveCount(0);
await evidence.shot("01-back-in-setup");
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
"actions": [
{
"name": "type",
"summary": "type \"How do I weigh access against cost in a…\" into textbox \"Ask your debate coach...\"",
"summary": "type \"How do I weigh access against cost in a…\" into textbox \"Message to your debate coach\"",
"target": {
"role": "textbox",
"name": "Ask your debate coach...",
"name": "Message to your debate coach",
"placeholder": "Ask your debate coach..."
},
"value": "How do I weigh access against cost in a rebuttal?"
Expand Down Expand Up @@ -64,7 +64,7 @@
},
{
"role": "textbox",
"name": "Ask your debate coach...",
"name": "Message to your debate coach",
"placeholder": "Ask your debate coach..."
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
"actions": [
{
"name": "type",
"summary": "type \"How do I weigh access against cost in a…\" into textbox \"Ask your debate coach...\"",
"summary": "type \"How do I weigh access against cost in a…\" into textbox \"Message to your debate coach\"",
"target": {
"role": "textbox",
"name": "Ask your debate coach...",
"name": "Message to your debate coach",
"placeholder": "Ask your debate coach..."
},
"value": "How do I weigh access against cost in a rebuttal?"
Expand Down
6 changes: 5 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ The repository is an npm workspace. The web app is the root package and still li

[`src/lib/supabase/AuthProvider.tsx`](../src/lib/supabase/AuthProvider.tsx) coordinates store hydration and reset across authentication changes. Store code is not the database schema source of truth; migrations are.

Discovered-source autosave in [`src/lib/ai/sources.ts`](../src/lib/ai/sources.ts) keeps one Library row per page per debate. It matches on a normalized URL (host without `www.`, no scheme, fragment, tracking parameters or trailing slash) and refreshes the existing row's research metadata instead of inserting a second one. Batches run one at a time within a tab; two tabs can still race, which only a unique index would stop.

[`src/hooks/use-subscription.ts`](../src/hooks/use-subscription.ts) estimates today's remaining AI requests from the account's `ai_requests` rows (refused calls and `jev_*` helper rows excluded) and its own `rate_limits` override. Global `rate_limits` rows are server-only, so the estimate only warns (Sparring setup) and informs (Settings); it is null, never blocking, when any read fails. The `ai` function's 402 stays the decision, and every tool shows it with the same daily-limit dialog.

## Language behavior

[`packages/core/src/languages.ts`](../packages/core/src/languages.ts) is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. [`src/i18n.ts`](../src/i18n.ts) owns the locale resources and applies the selected interface language to i18next, document metadata, and `<html lang>`. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages.
Expand All @@ -49,7 +53,7 @@ Interface language and AI-output language are separate settings. Interface langu

[`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) resolves production or staging client configuration. Public URLs and anon keys are browser configuration. Do not copy their values into docs. Admins switch projects from Settings; see [operations.md](operations.md#switching-the-frontend-to-staging).

[`src/lib/supabase/client.ts`](../src/lib/supabase/client.ts) creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys.
[`src/lib/supabase/client.ts`](../src/lib/supabase/client.ts) creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys. Its fetch notices when a signed-in request is refused (401 from REST or Functions, 401 or 403 from Auth's user lookup). [`session-expiry.ts`](../src/lib/supabase/session-expiry.ts) then forces a token refresh outside that request: if Auth refuses the refresh too, the session was revoked, so the browser signs out locally and the login page says the session expired. A network failure, 5xx or rate limit keeps the session.

Client-side environment selection is a developer convenience, not authorization. Edge Functions and database policies must enforce access independently.

Expand Down
10 changes: 10 additions & 0 deletions e2e/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ export async function stubSupabaseSession(page: Page, opts: StubAuthOptions = {}
}
}, { key: `debate-auth-${env}`, value: session });

// The bogus JWT makes data requests fail with 401, and the client then
// forces a token refresh to tell a revoked session from a stale one. Accept
// that refresh with the same stub session, so a render check keeps its
// session and never asks the real Auth server about it.
await page.route((url) => url.pathname.endsWith('/auth/v1/token') && url.searchParams.get('grant_type') === 'refresh_token', (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...session, expires_at: Math.floor(Date.now() / 1000) + HOUR_SECONDS }),
}));

// Credential-free render checks must not call the production announcement
// function. Tests that exercise announcements register a more specific
// route after this default.
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { StagingBanner } from "@/components/StagingBanner";
import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability";
import { routeLoaders, type RouteLoader } from "@/lib/route-modules";
import type { PrerenderedRoute } from "@/lib/seo/pages";
import { usePrivateRouteHead } from "@/lib/seo/use-page-head";
import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton";
import { useTranslation } from "react-i18next";
import { COLLABORATION_URL } from "@/lib/workspace/collaboration-config";
Expand Down Expand Up @@ -212,6 +213,7 @@ function RouteContent({ children }: { children: ReactNode }) {
const fallback = getRouteFallback(location.pathname);
const label = fallback.labelKey ? t(fallback.labelKey, fallback.label) : fallback.label;
const statusText = t("common.openingPage", { page: label });
usePrivateRouteHead(location.pathname, label);

return (
<LocalizedRouteErrorBoundary key={location.pathname}>
Expand Down
1 change: 1 addition & 0 deletions src/components/features/ask/AskChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,7 @@ export function AskChat({ messages, streamingMessage, isLoading, error, onDismis
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={isListening ? t('askChat.placeholderListening') : t('askChat.placeholder')}
aria-label={t('askChat.inputLabel')}
className="flex-1"
/>
{isLoading && onStop ? (
Expand Down
12 changes: 8 additions & 4 deletions src/components/features/attack/AttackForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,20 +162,24 @@ export function AttackForm({ onSubmit, isLoading, importedCase, debateContext =
<div className="space-y-4 rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t('attackForm.detectFallacies')}</Label>
<p className="text-xs text-muted-foreground">{t('attackForm.detectFallaciesDesc')}</p>
<Label htmlFor="attack-detectFallacies" className="text-sm font-medium">{t('attackForm.detectFallacies')}</Label>
<p id="attack-detectFallacies-desc" className="text-xs text-muted-foreground">{t('attackForm.detectFallaciesDesc')}</p>
</div>
<Switch
id="attack-detectFallacies"
aria-describedby="attack-detectFallacies-desc"
checked={currentValues.detectFallacies}
onCheckedChange={(checked) => setValue('detectFallacies', checked)}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t('attackForm.analyzeWeighing')}</Label>
<p className="text-xs text-muted-foreground">{t('attackForm.analyzeWeighingDesc')}</p>
<Label htmlFor="attack-analyzeWeighing" className="text-sm font-medium">{t('attackForm.analyzeWeighing')}</Label>
<p id="attack-analyzeWeighing-desc" className="text-xs text-muted-foreground">{t('attackForm.analyzeWeighingDesc')}</p>
</div>
<Switch
id="attack-analyzeWeighing"
aria-describedby="attack-analyzeWeighing-desc"
checked={currentValues.analyzeWeighing}
onCheckedChange={(checked) => setValue('analyzeWeighing', checked)}
/>
Expand Down
Loading
Loading