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
2 changes: 2 additions & 0 deletions e2e/ai-debate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ async function mockDemo(page: Page) {
contentType: 'application/json',
body: JSON.stringify({
sessionId: '11111111-1111-4111-8111-111111111111',
...(turn === 1 ? { sessionToken: 'signed-session-token' } : {}),
turn,
turnsLeft: turn === 1 ? 1 : 0,
rebuttal: turn === 1 ? 'Phones also let pupils reach their parents in an emergency.' : 'Office phones are slower when every second counts.',
Expand Down Expand Up @@ -59,6 +60,7 @@ test.describe('AI debate mini round', () => {
expect(bodies[0].sessionId).toBeUndefined();
expect(bodies[1]).toMatchObject({
sessionId: '11111111-1111-4111-8111-111111111111',
sessionToken: 'signed-session-token',
previous: { argument: 'Phones distract pupils in class and make bullying easier.', rebuttal: 'Phones also let pupils reach their parents in an emergency.' },
});
});
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"test:collaboration": "vitest run --config services/collaboration/vitest.config.ts",
"start:collaboration": "tsx services/collaboration/src/index.ts",
"test:watch": "vitest",
"test:edge": "deno test --config supabase/functions/deno.json --node-modules-dir=auto --allow-read --allow-env=LOCAL_GATE_APP_ORIGINS,DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:edge": "deno test --config supabase/functions/deno.json --node-modules-dir=auto --allow-read --allow-env=LOCAL_GATE_APP_ORIGINS,DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,DEMO_ENABLED,DEMO_DAILY_LIMIT,TURNSTILE_SECRET_KEY,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-opus-default-migration.sh && bash scripts/test-admin-table-drift-migration.sh &&bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh",
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
"test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke",
Expand Down
21 changes: 12 additions & 9 deletions src/components/ai-debate/MiniRound.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,12 @@ function Exchange({ argument, rebuttal, judgeNote, label }: { argument: string;
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">AI opponent</p>
<p className="mt-1 whitespace-pre-line text-foreground" data-testid="demo-rebuttal">{rebuttal}</p>
</div>
<p className="rounded-lg bg-surface-sunken px-4 py-3 text-sm text-foreground" data-testid="demo-judge-note">
<span className="font-bold">Judge note: </span>{judgeNote}
</p>
{/* A reply the server saved from a cut-off answer can come without one. */}
{judgeNote && (
<p className="rounded-lg bg-surface-sunken px-4 py-3 text-sm text-foreground" data-testid="demo-judge-note">
<span className="font-bold">Judge note: </span>{judgeNote}
</p>
)}
</div>
);
}
Expand Down Expand Up @@ -78,8 +81,8 @@ const ERRORS: Record<string, string> = {
daily_limit: 'Today’s demo rounds are used up. Come back tomorrow, or start a trial now.',
not_allowed: 'That motion or argument cannot be used in the demo. Try a different one.',
bot_check_failed: 'The bot check did not pass. Wait for it to finish and try again.',
// A follow-up from a different IP (a phone switching networks) cannot continue the session.
unknown_session: 'Your connection changed, so this round cannot continue. Reload the page to start a new one.',
// The session token expired, or the round started before midnight UTC.
unknown_session: 'This demo round has expired. Reload the page to start a new one.',
};
const ENDS_DEMO = new Set(['ip_limit', 'turn_limit', 'daily_limit', 'unknown_session']);

Expand All @@ -91,7 +94,7 @@ function LiveRound({ onDisabled }: { onDisabled: () => void }) {
const [side, setSide] = useState<DemoSide>('for');
const [argument, setArgument] = useState('');
const [token, setToken] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [session, setSession] = useState<{ id: string; token?: string } | null>(null);
const [turns, setTurns] = useState<Turn[]>([]);
const [turnsLeft, setTurnsLeft] = useState(1);
const [pending, setPending] = useState(false);
Expand Down Expand Up @@ -135,10 +138,10 @@ function LiveRound({ onDisabled }: { onDisabled: () => void }) {
setError(null);
try {
const first = turns[0];
const reply = await requestDemoTurn(started && sessionId && first
? { sessionId, motion, side, argument: text, previous: { argument: first.argument, rebuttal: first.rebuttal } }
const reply = await requestDemoTurn(started && session && first
? { sessionId: session.id, sessionToken: session.token, motion, side, argument: text, previous: { argument: first.argument, rebuttal: first.rebuttal } }
: { turnstileToken: token ?? undefined, motion, side, argument: text });
setSessionId(reply.sessionId);
if (!started) setSession({ id: reply.sessionId, token: reply.sessionToken });
setTurns((previous) => [...previous, { argument: text, rebuttal: reply.rebuttal, judgeNote: reply.judgeNote }]);
setTurnsLeft(reply.turnsLeft);
setArgument('');
Expand Down
4 changes: 4 additions & 0 deletions src/lib/ai-debate-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type DemoSide = "for" | "against";
export interface DemoTurnRequest {
turnstileToken?: string;
sessionId?: string;
/** The signed token the first turn returned; sent back with the follow-up. */
sessionToken?: string;
motion: string;
side: DemoSide;
argument: string;
Expand All @@ -33,6 +35,8 @@ export interface DemoTurnRequest {

export interface DemoTurnReply {
sessionId: string;
/** Only on the first turn. */
sessionToken?: string;
turn: number;
turnsLeft: number;
rebuttal: string;
Expand Down
2 changes: 1 addition & 1 deletion supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Sh

### Authentication and administration

- `demo-rebuttal`: the no-login mini round on `/ai-debate`. Off unless `DEMO_ENABLED=true` and `TURNSTILE_SECRET_KEY` is set. It accepts only allowed origins, checks a Cloudflare Turnstile token before a session starts, and reserves every model call through the service-role-only `claim_demo_round_turn` RPC: 3 sessions per IP per UTC day, 2 turns per session, and `DEMO_DAILY_LIMIT` model calls per UTC day across all visitors (500 when unset). Rows keep a salted, per-day hash of the IP and counts only; nothing the visitor typed is stored or logged. One Claude Haiku call per turn at low reasoning effort, with `max_tokens` sized so the reasoning cannot crowd out the JSON reply.
- `demo-rebuttal`: the no-login mini round on `/ai-debate`. Off unless `DEMO_ENABLED=true` and `TURNSTILE_SECRET_KEY` is set. It accepts only allowed origins, checks a Cloudflare Turnstile token before a session starts, and reserves every model call through the service-role-only `claim_demo_round_turn` RPC: 3 sessions per IP per UTC day (counted when a session starts, by `CF-Connecting-IP`; the right-most `X-Forwarded-For` entry is an internal hop that changes per request), 2 turns per session, and `DEMO_DAILY_LIMIT` model calls per UTC day across all visitors (500 when unset). The first turn returns a session token signed with `SPARRING_INTEGRITY_SECRET` (or the service-role fallback) that holds the session's IP hash; the follow-up must send it back and is charged to that hash, so a visitor whose IP changes mid-round can finish it. Rows keep a salted, per-day hash of the IP and counts only; nothing the visitor typed is stored or logged. One Claude Haiku call per turn at low reasoning effort, asked for 90 to 130 words, with `max_tokens` sized so the reasoning cannot crowd out the JSON reply. A rebuttal past 170 words, or a reply cut off at `max_tokens` whose moderation flag arrived, is served up to its last full sentence.
- `demo-code-login`: bounded pre-session access-code flow with normalized hashing, fail-closed attempt limits, atomic use consumption, server-owned demo-user bindings in Auth `app_metadata`, and a prohibition on granting administrator status.
- `admin`: authenticated administrator operations with server-side role checks, including redacted demo-code list/create/disable/reset actions.
- `announcements`: authenticated announcement listing and dismissal.
Expand Down
Loading
Loading