Skip to content

Commit 6c152fe

Browse files
committed
fix: enforce AI control contracts at the edge
1 parent 7b1dfc8 commit 6c152fe

9 files changed

Lines changed: 296 additions & 14 deletions

File tree

‎e2e/authed-pages.spec.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,64 @@ for (const locale of LOCALES) {
5454
}
5555
});
5656
}
57+
58+
test('visible Ask advanced controls use the non-streaming contract', async ({ page }) => {
59+
await setLocale(page, 'en');
60+
await stubSupabaseSession(page);
61+
await page.addInitScript(() => {
62+
window.localStorage.setItem('debate-trainer-settings', JSON.stringify({
63+
state: {
64+
language: 'en',
65+
outputLanguage: 'en',
66+
theme: 'light',
67+
featureConfigs: {
68+
ask: { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 },
69+
},
70+
},
71+
version: 0,
72+
}));
73+
});
74+
75+
let requestBody: Record<string, unknown> | undefined;
76+
let streamCalls = 0;
77+
await page.route('**/functions/v1/ai-stream', async (route) => {
78+
streamCalls += 1;
79+
await route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"unexpected stream"}' });
80+
});
81+
await page.route('**/functions/v1/ai', async (route) => {
82+
requestBody = route.request().postDataJSON() as Record<string, unknown>;
83+
await route.fulfill({
84+
status: 200,
85+
contentType: 'application/json',
86+
body: JSON.stringify({
87+
output: {
88+
response: 'Clarification contract response',
89+
followUpQuestions: ['Which debate format are you using?'],
90+
uncertainties: [],
91+
},
92+
}),
93+
});
94+
});
95+
96+
await page.goto('/app/ask', { waitUntil: 'domcontentloaded' });
97+
await page.getByRole('button', { name: 'Clarify' }).click();
98+
for (const name of ['Citations', 'Web search', 'Pros/Cons', 'Arg Map']) {
99+
await page.getByRole('switch', { name }).click();
100+
}
101+
await page.getByPlaceholder('Ask your debate coach...').fill('Which assumptions should I define?');
102+
await page.getByRole('button', { name: 'Send message' }).click();
103+
104+
await expect(page.getByText('Clarification contract response')).toBeVisible();
105+
expect(streamCalls).toBe(0);
106+
expect(requestBody).toMatchObject({
107+
feature: 'ask',
108+
input: {
109+
message: 'Which assumptions should I define?',
110+
mode: 'clarify',
111+
citationMode: true,
112+
webSearch: true,
113+
prosConsMode: true,
114+
argumentMapMode: true,
115+
},
116+
});
117+
});

‎src/lib/ai/streaming.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export type ParsedSSEEvent = {
2626
export function isAskStreamingSupported(config: ProviderConfig, options: AskStreamOptions = {}) {
2727
const provider = config.provider === 'lovable' ? 'openai' : config.provider;
2828
const advancedStructuredMode = Boolean(
29-
options.mode === 'clarify' ||
29+
(options.mode ?? 'general') !== 'general' ||
3030
options.citationMode ||
3131
options.webSearch ||
3232
options.prosConsMode ||

‎src/test/ai/streaming.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ describe('AI streaming helpers', () => {
114114
{ provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 },
115115
{ mode: 'general' }
116116
)).toBe(true);
117+
for (const mode of ['clarify', 'summarize', 'proscons', 'argumentmap']) {
118+
expect(isAskStreamingSupported(
119+
{ provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 },
120+
{ mode }
121+
)).toBe(false);
122+
}
117123
});
118124

119125
describe('telemetry', () => {

‎supabase/functions/_shared/control-enforcement.test.ts‎

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,96 @@ import {
44
enforceBrainstormControls,
55
enforceGenerateControls,
66
isAskStreamingModeAllowed,
7+
validateControlInput,
78
} from "./control-enforcement.ts";
89

10+
const ENUM_CONTROLS = {
11+
ask: {
12+
mode: ["general", "clarify", "summarize", "proscons", "argumentmap"],
13+
},
14+
attack: {
15+
aggressiveness: ["mild", "moderate", "ruthless"],
16+
judgeParadigm: ["logic", "values", "policy"],
17+
},
18+
brainstorm: {
19+
side: ["pro", "con", "both"],
20+
outputMode: ["mindmap", "buckets", "topfive"],
21+
},
22+
factoids: {
23+
direction: ["for", "against"],
24+
sourceType: ["academic", "government", "news", "any"],
25+
},
26+
generate: {
27+
side: ["pro", "con"],
28+
format: ["custom", "bp", "policy", "classroom"],
29+
role: ["intro", "r1", "r2", "conc", "general"],
30+
length: ["brief", "standard", "comprehensive"],
31+
audience: ["beginner", "intermediate", "advanced"],
32+
tone: ["formal", "conversational", "aggressive"],
33+
},
34+
} as const;
35+
36+
const BOOLEAN_CONTROLS = {
37+
ask: ["citationMode", "webSearch", "prosConsMode", "argumentMapMode"],
38+
attack: ["detectFallacies", "analyzeWeighing"],
39+
} as const;
40+
41+
Deno.test("control validator accepts every enum value and omitted defaults", () => {
42+
for (const [feature, controls] of Object.entries(ENUM_CONTROLS)) {
43+
if (validateControlInput(feature, {}) !== null) throw new Error(`${feature} rejected omitted controls`);
44+
for (const [control, values] of Object.entries(controls)) {
45+
for (const value of values) {
46+
const error = validateControlInput(feature, { [control]: value });
47+
if (error !== null) throw new Error(`${feature}.${control} rejected ${value}: ${error}`);
48+
}
49+
}
50+
}
51+
});
52+
53+
Deno.test("control validator rejects every unsupported enum", () => {
54+
for (const [feature, controls] of Object.entries(ENUM_CONTROLS)) {
55+
for (const control of Object.keys(controls)) {
56+
const error = validateControlInput(feature, { [control]: "unsupported" });
57+
if (!error?.includes(`${feature}.${control}`)) throw new Error(`${feature}.${control} accepted an unsupported value`);
58+
}
59+
}
60+
});
61+
62+
Deno.test("control validator accepts booleans and rejects wrong boolean types", () => {
63+
for (const [feature, controls] of Object.entries(BOOLEAN_CONTROLS)) {
64+
for (const control of controls) {
65+
for (const value of [true, false]) {
66+
if (validateControlInput(feature, { [control]: value }) !== null) {
67+
throw new Error(`${feature}.${control} rejected ${value}`);
68+
}
69+
}
70+
const error = validateControlInput(feature, { [control]: "false" });
71+
if (!error?.includes(`${feature}.${control}`)) throw new Error(`${feature}.${control} accepted a string boolean`);
72+
}
73+
}
74+
});
75+
76+
Deno.test("factoids typed controls enforce their existing boundaries", () => {
77+
for (const sourceCount of [3, 5, 15]) {
78+
if (validateControlInput("factoids", { sourceCount }) !== null) throw new Error(`rejected sourceCount ${sourceCount}`);
79+
}
80+
if (validateControlInput("factoids", { timeframe: "2020-2026" }) !== null) throw new Error("rejected string timeframe");
81+
82+
for (const input of [
83+
{ sourceCount: "5" },
84+
{ sourceCount: Number.NaN },
85+
{ sourceCount: Number.POSITIVE_INFINITY },
86+
{ sourceCount: 2 },
87+
{ sourceCount: 16 },
88+
{ timeframe: 2026 },
89+
]) {
90+
if (validateControlInput("factoids", input) === null) {
91+
throw new Error(`accepted invalid factoids control ${JSON.stringify(input)}`);
92+
}
93+
}
94+
if (validateControlInput("unknown", { mode: "unsupported" }) !== null) throw new Error("unknown feature must be ignored");
95+
});
96+
997
Deno.test("ask pros/cons and argument map toggles deterministically shape the output", () => {
1098
const output: Record<string, unknown> = {
1199
response: "r",
@@ -48,8 +136,10 @@ Deno.test("clarify mode guarantees a visible uncertainty note when no follow-up
48136
if ((good.uncertainties as string[]).length !== 0) throw new Error("clarify with follow-ups must not add noise");
49137
});
50138

51-
Deno.test("clarify mode is excluded from streaming while general mode remains eligible", () => {
52-
if (isAskStreamingModeAllowed("clarify")) throw new Error("clarify mode must not stream");
139+
Deno.test("only general ask mode is eligible for streaming", () => {
140+
for (const mode of ["clarify", "summarize", "proscons", "argumentmap", "unsupported", undefined]) {
141+
if (isAskStreamingModeAllowed(mode)) throw new Error(`${mode} mode must not stream`);
142+
}
53143
if (!isAskStreamingModeAllowed("general")) throw new Error("general mode should remain streamable");
54144
});
55145

‎supabase/functions/_shared/control-enforcement.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,65 @@ export interface AskControls {
1111
}
1212

1313
export function isAskStreamingModeAllowed(mode: unknown): boolean {
14-
return mode !== "clarify";
14+
return mode === "general";
15+
}
16+
17+
const ENUM_CONTROLS: Record<string, Record<string, readonly string[]>> = {
18+
ask: {
19+
mode: ["general", "clarify", "summarize", "proscons", "argumentmap"],
20+
},
21+
attack: {
22+
aggressiveness: ["mild", "moderate", "ruthless"],
23+
judgeParadigm: ["logic", "values", "policy"],
24+
},
25+
brainstorm: {
26+
side: ["pro", "con", "both"],
27+
outputMode: ["mindmap", "buckets", "topfive"],
28+
},
29+
factoids: {
30+
direction: ["for", "against"],
31+
sourceType: ["academic", "government", "news", "any"],
32+
},
33+
generate: {
34+
side: ["pro", "con"],
35+
format: ["custom", "bp", "policy", "classroom"],
36+
role: ["intro", "r1", "r2", "conc", "general"],
37+
length: ["brief", "standard", "comprehensive"],
38+
audience: ["beginner", "intermediate", "advanced"],
39+
tone: ["formal", "conversational", "aggressive"],
40+
},
41+
};
42+
43+
const BOOLEAN_CONTROLS: Record<string, readonly string[]> = {
44+
ask: ["citationMode", "webSearch", "prosConsMode", "argumentMapMode"],
45+
attack: ["detectFallacies", "analyzeWeighing"],
46+
};
47+
48+
/** Returns a safe client-facing error for malformed, present AI controls. */
49+
export function validateControlInput(feature: string, input: Record<string, unknown>): string | null {
50+
for (const [name, allowed] of Object.entries(ENUM_CONTROLS[feature] ?? {})) {
51+
if (Object.hasOwn(input, name) && (typeof input[name] !== "string" || !allowed.includes(input[name]))) {
52+
return `${feature}.${name} is invalid`;
53+
}
54+
}
55+
56+
for (const name of BOOLEAN_CONTROLS[feature] ?? []) {
57+
if (Object.hasOwn(input, name) && typeof input[name] !== "boolean") {
58+
return `${feature}.${name} must be a boolean`;
59+
}
60+
}
61+
62+
if (feature === "factoids") {
63+
if (Object.hasOwn(input, "timeframe") && typeof input.timeframe !== "string") {
64+
return "factoids.timeframe must be a string";
65+
}
66+
if (Object.hasOwn(input, "sourceCount") &&
67+
(typeof input.sourceCount !== "number" || !Number.isFinite(input.sourceCount) || input.sourceCount < 3 || input.sourceCount > 15)) {
68+
return "factoids.sourceCount must be a number from 3 to 15";
69+
}
70+
}
71+
72+
return null;
1573
}
1674

1775
const CLARIFY_NOTE = "Clarify mode was requested, but no clarifying questions were returned.";

‎supabase/functions/ai-stream/index.ts‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ function getCorsHeaders(origin: string | null) {
2323

2424
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
2525
import { getAskModeGuidance } from "../ai/prompt-builder.ts";
26-
import { isAskStreamingModeAllowed } from "../_shared/control-enforcement.ts";
26+
import { isAskStreamingModeAllowed, validateControlInput } from "../_shared/control-enforcement.ts";
2727
import {
2828
calculateRequestCost,
2929
type CostResult,
@@ -312,7 +312,7 @@ async function logUsage(
312312
}
313313
}
314314

315-
Deno.serve(async (req) => {
315+
export async function handleRequest(req: Request): Promise<Response> {
316316
const corsHeaders = getCorsHeaders(req.headers.get("Origin"));
317317

318318
if (req.method === "OPTIONS") {
@@ -341,16 +341,20 @@ Deno.serve(async (req) => {
341341
return jsonResponse({ error: "Invalid JSON body" }, 400, corsHeaders);
342342
}
343343

344-
const model = sanitizeText(body.model, 80) || "gpt-5.4-mini";
345-
const input = body.input && typeof body.input === "object" ? body.input as Record<string, unknown> : {};
346-
const mode = sanitizeText(input.mode, 40) || "general";
347-
348344
if (body.feature !== "ask") {
349345
return jsonResponse({ error: "Streaming currently supports Ask only" }, 400, corsHeaders);
350346
}
351347

348+
const model = sanitizeText(body.model, 80) || "gpt-5.4-mini";
349+
const input = body.input && typeof body.input === "object" && !Array.isArray(body.input)
350+
? body.input as Record<string, unknown>
351+
: {};
352+
const controlError = validateControlInput("ask", input);
353+
if (controlError) return jsonResponse({ error: controlError }, 400, corsHeaders);
354+
355+
const mode = sanitizeText(input.mode, 40) || "general";
352356
if (!isAskStreamingModeAllowed(mode)) {
353-
return jsonResponse({ error: "Streaming is not available for clarify mode" }, 400, corsHeaders);
357+
return jsonResponse({ error: `Streaming is not available for ${mode} mode` }, 400, corsHeaders);
354358
}
355359

356360
const advancedMode = Boolean(input.citationMode || input.webSearch || input.prosConsMode || input.argumentMapMode);
@@ -597,4 +601,8 @@ Deno.serve(async (req) => {
597601
"Cache-Control": "no-cache, no-transform",
598602
},
599603
});
600-
});
604+
}
605+
606+
if (import.meta.main) {
607+
Deno.serve(handleRequest);
608+
}

‎supabase/functions/ai/control-matrix.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,9 @@ Deno.test("ask toggles and modes appear verbatim in the request prompt", () => {
154154
assert(Array.isArray(stripped.citations) && stripped.citations.length === 0, "ask citations off clears list");
155155
});
156156

157-
Deno.test("ask clarify mode is not eligible for streaming", () => {
158-
assert(!isAskStreamingModeAllowed("clarify"), "ask.clarify streaming bypass");
157+
Deno.test("only plain general ask mode is eligible for streaming", () => {
158+
for (const mode of ["clarify", "summarize", "proscons", "argumentmap"]) {
159+
assert(!isAskStreamingModeAllowed(mode), `ask.${mode} streaming bypass`);
160+
}
159161
assert(isAskStreamingModeAllowed("general"), "ask.general streaming eligibility");
160162
});

‎supabase/functions/ai/handler-research.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,57 @@ const environment = {
1616
for (const [key, value] of Object.entries(environment)) Deno.env.set(key, value);
1717

1818
const { handleRequest } = await import("./index.ts");
19+
const { handleRequest: handleStreamRequest } = await import("../ai-stream/index.ts");
20+
21+
Deno.test("AI handlers reject malformed controls before quota or provider work", async () => {
22+
const originalFetch = globalThis.fetch;
23+
const calls: string[] = [];
24+
25+
globalThis.fetch = (async (input: Request | URL | string) => {
26+
const url = new URL(input instanceof Request ? input.url : String(input));
27+
calls.push(url.href);
28+
if (url.origin === "https://supabase.test" && url.pathname === "/auth/v1/user") {
29+
return jsonResponse({ id: "user-1", aud: "authenticated" });
30+
}
31+
throw new Error(`Malformed controls reached unexpected work: ${url}`);
32+
}) as typeof fetch;
33+
34+
try {
35+
const response = await handleRequest(new Request("http://127.0.0.1/functions/v1/ai", {
36+
method: "POST",
37+
headers: { "Content-Type": "application/json" },
38+
body: JSON.stringify({
39+
feature: "ask",
40+
model: "gpt-5.4-mini",
41+
input: { message: "Question", mode: "general", citationMode: "false" },
42+
}),
43+
}));
44+
if (response.status !== 400) throw new Error(`non-streaming handler returned ${response.status}`);
45+
if (!(await response.text()).includes("ask.citationMode")) throw new Error("non-streaming error hid the invalid control");
46+
if (calls.length !== 0) throw new Error(`non-streaming validation performed remote work: ${calls.join(", ")}`);
47+
48+
const streamResponse = await handleStreamRequest(new Request("http://127.0.0.1/functions/v1/ai-stream", {
49+
method: "POST",
50+
headers: {
51+
Authorization: "Bearer test-user-token",
52+
"Content-Type": "application/json",
53+
},
54+
body: JSON.stringify({
55+
feature: "ask",
56+
model: "gpt-5.4-mini",
57+
input: { message: "Question", mode: "general", citationMode: "false" },
58+
}),
59+
}));
60+
if (streamResponse.status !== 400) throw new Error(`streaming handler returned ${streamResponse.status}`);
61+
if (!(await streamResponse.text()).includes("ask.citationMode")) throw new Error("streaming error hid the invalid control");
62+
const observedCalls: string[] = Array.from(calls);
63+
if (observedCalls.length !== 1 || !observedCalls[0].includes("/auth/v1/user")) {
64+
throw new Error(`streaming validation performed quota or provider work: ${observedCalls.join(", ")}`);
65+
}
66+
} finally {
67+
globalThis.fetch = originalFetch;
68+
}
69+
});
1970

2071
Deno.test("factoids handler carries Exa filters and consulted-source metadata across the request boundary", async () => {
2172
const originalFetch = globalThis.fetch;

0 commit comments

Comments
 (0)