From 5181acddc9abad722bbdef3aa87dcb7e74b2a88e Mon Sep 17 00:00:00 2001 From: Charles Howard <96023061+charlesrhoward@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:14:23 -0400 Subject: [PATCH 1/2] fix: harden production route integrity --- app/api/commands/route.ts | 24 +- app/api/control/chat/_lib/messages.ts | 87 ++++- app/api/invites/[token]/route.ts | 322 +++++++++++------- app/api/repos/[id]/models/route.ts | 106 ++++-- app/api/repos/[id]/monorepo/route.ts | 57 +++- app/api/rules/route.ts | 47 ++- app/api/sandbox/[id]/extend/route.ts | 43 ++- app/api/skills/route.ts | 54 ++- app/api/teams/[teamId]/invites/route.ts | 56 ++- app/api/triggers/route.ts | 61 +++- ...0260830190000_atomic_invite_acceptance.sql | 98 ++++++ ...0260830190000_atomic_invite_acceptance.sql | 98 ++++++ tests/db/atomic-invite-acceptance.test.ts | 163 +++++++++ tests/unit/control-chat-contract.test.ts | 41 +-- tests/unit/control-chat-validation.test.ts | 22 ++ tests/unit/invite-acceptance-route.test.ts | 116 +++++++ tests/unit/prod-route-integrity.test.ts | 76 +++++ tests/unit/repo-models-route.test.ts | 24 ++ tests/unit/triggers-route.test.ts | 82 +++++ 19 files changed, 1341 insertions(+), 236 deletions(-) create mode 100644 neon/migrations/20260830190000_atomic_invite_acceptance.sql create mode 100644 supabase/migrations/20260830190000_atomic_invite_acceptance.sql create mode 100644 tests/db/atomic-invite-acceptance.test.ts create mode 100644 tests/unit/control-chat-validation.test.ts create mode 100644 tests/unit/invite-acceptance-route.test.ts create mode 100644 tests/unit/prod-route-integrity.test.ts diff --git a/app/api/commands/route.ts b/app/api/commands/route.ts index 47227094..709771a9 100644 --- a/app/api/commands/route.ts +++ b/app/api/commands/route.ts @@ -2,6 +2,19 @@ import { NextResponse } from "next/server"; import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireUserId } from "@/lib/auth"; +export function pickCommandCreateFields(input: unknown) { + const fields: Record = {}; + if (!input || typeof input !== "object" || Array.isArray(input)) + return fields; + const body = input as Record; + if (typeof body.name === "string") fields.name = body.name; + if (typeof body.description === "string") { + fields.description = body.description; + } + if (typeof body.template === "string") fields.template = body.template; + return fields; +} + export async function GET() { const userId = await requireUserId(); if (userId instanceof Response) return userId; @@ -21,10 +34,17 @@ export async function POST(req: Request) { const userId = await requireUserId(); if (userId instanceof Response) return userId; - const body = await req.json(); + const body = await req.json().catch(() => null); + const fields = pickCommandCreateFields(body); + if (typeof fields.name !== "string" || typeof fields.template !== "string") { + return NextResponse.json( + { error: "name and template are required" }, + { status: 400 } + ); + } const { data, error } = await supabaseAdmin .from("custom_commands") - .insert({ ...body, user_id: userId }) + .insert({ ...fields, user_id: userId }) .select() .single(); diff --git a/app/api/control/chat/_lib/messages.ts b/app/api/control/chat/_lib/messages.ts index 03001169..9ed61220 100644 --- a/app/api/control/chat/_lib/messages.ts +++ b/app/api/control/chat/_lib/messages.ts @@ -7,7 +7,15 @@ import type { type NormalizedControlChatMessage = Omit; const MAX_CONTROL_FILE_DATA_URL_CHARS = 5_600_000; +const MAX_CONTROL_FILE_BYTES = 4 * 1024 * 1024; const MAX_CONTROL_FILE_PARTS = 5; +const MAX_CONTROL_TOTAL_FILE_BYTES = + MAX_CONTROL_FILE_BYTES * MAX_CONTROL_FILE_PARTS; + +type ControlFileBudget = { + count: number; + bytes: number; +}; export class ControlChatValidationError extends Error { constructor(message: string) { @@ -16,7 +24,47 @@ export class ControlChatValidationError extends Error { } } -function normalizeFilePart(part: ControlChatRequestPart): FileUIPart { +function readFilePartBytes(part: ControlChatRequestPart): number { + const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/.exec( + part.type === "file" ? part.url : "" + ); + if ( + !match || + part.type !== "file" || + match[1]?.toLowerCase() !== part.mediaType.toLowerCase() + ) { + throw new ControlChatValidationError( + "Invalid control chat file attachment." + ); + } + const decodedBytes = Buffer.byteLength(match[2] ?? "", "base64"); + if (decodedBytes > MAX_CONTROL_FILE_BYTES) { + throw new ControlChatValidationError( + "Control chat file attachment exceeds the size limit." + ); + } + return decodedBytes; +} + +function applyFileBudget(budget: ControlFileBudget, decodedBytes: number) { + budget.count += 1; + if (budget.count > MAX_CONTROL_FILE_PARTS) { + throw new ControlChatValidationError( + `Control chat supports up to ${MAX_CONTROL_FILE_PARTS} file attachments.` + ); + } + budget.bytes += decodedBytes; + if (budget.bytes > MAX_CONTROL_TOTAL_FILE_BYTES) { + throw new ControlChatValidationError( + "Control chat file attachments exceed the total size limit." + ); + } +} + +function normalizeFilePart( + part: ControlChatRequestPart, + budget: ControlFileBudget +): FileUIPart { if ( part.type !== "file" || typeof part.mediaType !== "string" || @@ -37,6 +85,7 @@ function normalizeFilePart(part: ControlChatRequestPart): FileUIPart { "Control chat file attachment exceeds the size limit." ); } + applyFileBudget(budget, readFilePartBytes(part)); return { type: "file" as const, mediaType: part.mediaType, @@ -52,12 +101,21 @@ export function normalizeControlChatMessages( throw new ControlChatValidationError("Invalid control chat messages."); } + const fileBudget: ControlFileBudget = { count: 0, bytes: 0 }; return (messages as unknown[]).map((message) => { if (typeof message !== "object" || message === null) { throw new ControlChatValidationError("Invalid control chat message."); } const controlMessage = message as ControlChatRequestMessage; - let filePartCount = 0; + if ( + controlMessage.role !== "user" && + controlMessage.role !== "assistant" && + controlMessage.role !== "system" + ) { + throw new ControlChatValidationError( + "Invalid control chat message role." + ); + } const parts = controlMessage.parts ?? (typeof controlMessage.content === "string" @@ -69,19 +127,28 @@ export function normalizeControlChatMessages( return { role: controlMessage.role as "user" | "assistant" | "system", - parts: parts.flatMap( + parts: (parts as unknown[]).flatMap( (part): Array => { - if (part.type === "text") { - return [{ type: "text" as const, text: part.text ?? "" }]; + if ( + typeof part !== "object" || + part === null || + Array.isArray(part) + ) { + throw new ControlChatValidationError( + "Invalid control chat message part." + ); } - if (part.type === "file") { - filePartCount += 1; - if (filePartCount > MAX_CONTROL_FILE_PARTS) { + const controlPart = part as ControlChatRequestPart; + if (controlPart.type === "text") { + if (typeof controlPart.text !== "string") { throw new ControlChatValidationError( - `Control chat supports up to ${MAX_CONTROL_FILE_PARTS} file attachments.` + "Invalid control chat text part." ); } - return [normalizeFilePart(part)]; + return [{ type: "text" as const, text: controlPart.text }]; + } + if (controlPart.type === "file") { + return [normalizeFilePart(controlPart, fileBudget)]; } return []; } diff --git a/app/api/invites/[token]/route.ts b/app/api/invites/[token]/route.ts index 5a52e51a..1f9c6646 100644 --- a/app/api/invites/[token]/route.ts +++ b/app/api/invites/[token]/route.ts @@ -18,183 +18,265 @@ export type InviteLookupResponse = { currentEmail: string | null; }; -async function lookupInvite(token: string) { +type InviteLookupRow = { + id: string; + team_id: string; + email: string; + role: "admin" | "developer" | "viewer"; + expires_at: string; + accepted_at: string | null; + invited_by_user_id: string | null; +}; + +type InviteGetDeps = { + requireProfileId: typeof requireProfileId; + lookupInvite: (token: string) => Promise; + loadTeam: (teamId: string) => Promise<{ name: string; slug: string } | null>; + getInviterName: (profileId: string | null) => Promise; + getProfileEmail: (profileId: string) => Promise; +}; + +async function lookupInvite(token: string): Promise { // Service-role: the recipient is authed but not yet a team member, so RLS // (team_invites_admin policy) would block reading the invite row otherwise. - const { data } = await supabaseAdmin + const { data, error } = await supabaseAdmin .from("team_invites") .select( "id, team_id, email, role, expires_at, accepted_at, invited_by_user_id" ) .eq("token", token) .maybeSingle(); - return data; + if (error) throw new Error("Failed to load invite", { cause: error }); + return data as InviteLookupRow | null; +} + +async function loadTeam(teamId: string) { + const { data, error } = await supabaseAdmin + .from("teams") + .select("name, slug") + .eq("id", teamId) + .maybeSingle(); + if (error) throw new Error("Failed to load team", { cause: error }); + return data as { name: string; slug: string } | null; } async function getInviterName(profileId: string | null) { if (!profileId) return null; - const { data } = await supabaseAdmin + const { data, error } = await supabaseAdmin .from("profiles") .select("name, username") .eq("id", profileId) .maybeSingle(); + if (error) throw new Error("Failed to load inviter", { cause: error }); return ( (data?.name as string | null) || (data?.username as string | null) || null ); } async function getProfileEmail(profileId: string) { - const { data } = await supabaseAdmin + const { data, error } = await supabaseAdmin .from("profiles") .select("email") .eq("id", profileId) .maybeSingle(); + if (error) throw new Error("Failed to load profile", { cause: error }); return (data?.email as string | null) || null; } -export async function GET( - _request: Request, - context: { params: Promise<{ token: string }> } -) { - const profileId = await requireProfileId(); - if (profileId instanceof Response) return profileId; +const defaultInviteGetDeps: InviteGetDeps = { + requireProfileId, + lookupInvite, + loadTeam, + getInviterName, + getProfileEmail, +}; - const { token } = await context.params; - const invite = await lookupInvite(token); - if (!invite) { - return NextResponse.json({ error: "Invite not found" }, { status: 404 }); - } +export function createInviteGetHandler(overrides: Partial = {}) { + const deps: InviteGetDeps = { ...defaultInviteGetDeps, ...overrides }; - const { data: team } = await supabaseAdmin - .from("teams") - .select("name, slug") - .eq("id", invite.team_id) - .single(); + return async function GET( + _request: Request, + context: { params: Promise<{ token: string }> } + ) { + const profileId = await deps.requireProfileId(); + if (profileId instanceof Response) return profileId; - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } + try { + const { token } = await context.params; + const invite = await deps.lookupInvite(token); + if (!invite) { + return NextResponse.json( + { error: "Invite not found" }, + { status: 404 } + ); + } - const [inviterName, currentEmail] = await Promise.all([ - getInviterName((invite.invited_by_user_id as string | null) ?? null), - getProfileEmail(profileId), - ]); - - const expired = new Date(invite.expires_at as string).getTime() < Date.now(); - const alreadyAccepted = Boolean(invite.accepted_at); - const inviteEmail = (invite.email as string).toLowerCase(); - const userEmail = currentEmail?.toLowerCase() ?? null; - const emailMatch = userEmail !== null && userEmail === inviteEmail; - - const body: InviteLookupResponse = { - invite: { - teamName: team.name as string, - teamSlug: team.slug as string, - inviterName, - role: invite.role as "admin" | "developer" | "viewer", - expiresAt: invite.expires_at as string, - expired, - alreadyAccepted, - }, - emailMatch, - inviteEmail, - currentEmail, - }; + const team = await deps.loadTeam(invite.team_id); + if (!team) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }); + } + + const [inviterName, currentEmail] = await Promise.all([ + deps.getInviterName(invite.invited_by_user_id), + deps.getProfileEmail(profileId), + ]); - return NextResponse.json(body); + const expired = new Date(invite.expires_at).getTime() < Date.now(); + const alreadyAccepted = Boolean(invite.accepted_at); + const inviteEmail = invite.email.toLowerCase(); + const userEmail = currentEmail?.toLowerCase() ?? null; + const emailMatch = userEmail !== null && userEmail === inviteEmail; + + const body: InviteLookupResponse = { + invite: { + teamName: team.name, + teamSlug: team.slug, + inviterName, + role: invite.role, + expiresAt: invite.expires_at, + expired, + alreadyAccepted, + }, + emailMatch, + inviteEmail, + currentEmail, + }; + + return NextResponse.json(body); + } catch { + return NextResponse.json( + { error: "Failed to load invite" }, + { status: 500 } + ); + } + }; } +export const GET = createInviteGetHandler(); + export type AcceptInviteResponse = { team: { id: string; slug: string }; }; -export async function POST( - request: Request, - context: { params: Promise<{ token: string }> } -) { - const profileId = await requireProfileId(); - if (profileId instanceof Response) return profileId; +type AtomicInviteAcceptance = { + invite_id: string; + team_id: string; + team_slug: string; + invite_email: string; + invite_role: "admin" | "developer" | "viewer"; + email_match: boolean; +}; - const { token } = await context.params; +type AcceptInviteDeps = { + requireProfileId: typeof requireProfileId; + acceptInvite: (input: { + token: string; + profileId: string; + confirmMismatch: boolean; + }) => Promise<{ + data: AtomicInviteAcceptance | null; + error: { message: string } | null; + }>; + recordTeamAuditEvent: typeof recordTeamAuditEvent; +}; - let body: { confirmMismatch?: unknown }; - try { - body = (await request.json().catch(() => ({}))) as typeof body; - } catch { - body = {}; - } - const confirmMismatch = body.confirmMismatch === true; +const defaultAcceptInviteDeps: AcceptInviteDeps = { + requireProfileId, + async acceptInvite(input) { + const { data, error } = await supabaseAdmin + .rpc("accept_team_invite", { + p_token: input.token, + p_profile_id: input.profileId, + p_confirm_mismatch: input.confirmMismatch, + }) + .maybeSingle(); + return { + data: data as AtomicInviteAcceptance | null, + error: error ? { message: error.message } : null, + }; + }, + recordTeamAuditEvent, +}; - const invite = await lookupInvite(token); - if (!invite) { +function inviteAcceptanceErrorResponse(message: string) { + if (message.includes("invite_not_found")) { return NextResponse.json({ error: "Invite not found" }, { status: 404 }); } - if (invite.accepted_at) { + if (message.includes("already_accepted")) { return NextResponse.json({ error: "already_accepted" }, { status: 410 }); } - if (new Date(invite.expires_at as string).getTime() < Date.now()) { + if (message.includes("expired")) { return NextResponse.json({ error: "expired" }, { status: 410 }); } - - const currentEmail = await getProfileEmail(profileId); - const inviteEmail = (invite.email as string).toLowerCase(); - const emailMatch = - currentEmail !== null && currentEmail.toLowerCase() === inviteEmail; - if (!emailMatch && !confirmMismatch) { + if (message.includes("mismatch_unconfirmed")) { return NextResponse.json( { error: "mismatch_unconfirmed" }, { status: 409 } ); } - - // Insert membership + mark invite accepted. Service-role bypasses the - // is_team_admin() check on team_members_write — the user is the new member, - // not an admin yet. - const { error: memberError } = await supabaseAdmin - .from("team_members") - .insert({ - team_id: invite.team_id, - user_id: profileId, - role: invite.role, - invited_by_user_id: invite.invited_by_user_id, - }); - - if (memberError && memberError.code !== "23505") { - return NextResponse.json( - { error: memberError.message || "Failed to join team" }, - { status: 500 } - ); + if (message.includes("profile_not_found")) { + return NextResponse.json({ error: "Profile not found" }, { status: 404 }); + } + if (message.includes("team_not_found")) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }); } + return NextResponse.json( + { error: "Failed to accept invite" }, + { status: 500 } + ); +} - await supabaseAdmin - .from("team_invites") - .update({ accepted_at: new Date().toISOString() }) - .eq("id", invite.id); +export function createAcceptInviteHandler( + overrides: Partial = {} +) { + const deps: AcceptInviteDeps = { + ...defaultAcceptInviteDeps, + ...overrides, + }; - const { data: team } = await supabaseAdmin - .from("teams") - .select("id, slug") - .eq("id", invite.team_id) - .single(); + return async function POST( + request: Request, + context: { params: Promise<{ token: string }> } + ) { + const profileId = await deps.requireProfileId(); + if (profileId instanceof Response) return profileId; - if (!team) { - return NextResponse.json({ error: "Team not found" }, { status: 404 }); - } + const { token } = await context.params; + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const { data, error } = await deps.acceptInvite({ + token, + profileId, + confirmMismatch: + (body as Record).confirmMismatch === true, + }); + if (error) return inviteAcceptanceErrorResponse(error.message); + if (!data) { + return NextResponse.json( + { error: "Failed to accept invite" }, + { status: 500 } + ); + } + + await deps.recordTeamAuditEvent({ + productTeamId: data.team_id, + actorUserId: profileId, + action: "invite.accepted", + targetType: "invite", + targetId: data.invite_id, + payload: { + email: data.invite_email, + role: data.invite_role, + email_match: data.email_match, + }, + }); - await recordTeamAuditEvent({ - productTeamId: invite.team_id as string, - actorUserId: profileId, - action: "invite.accepted", - targetType: "invite", - targetId: invite.id as string, - payload: { - email: invite.email as string, - role: invite.role as string, - email_match: emailMatch, - }, - }); - - return NextResponse.json({ - team: { id: team.id as string, slug: team.slug as string }, - }); + return NextResponse.json({ + team: { id: data.team_id, slug: data.team_slug }, + }); + }; } + +export const POST = createAcceptInviteHandler(); diff --git a/app/api/repos/[id]/models/route.ts b/app/api/repos/[id]/models/route.ts index 63682f6f..7b7509e7 100644 --- a/app/api/repos/[id]/models/route.ts +++ b/app/api/repos/[id]/models/route.ts @@ -58,6 +58,19 @@ type RepoModelsGetDeps = { }>; }; +type RepoModelsPostDeps = { + requireUserId: typeof requireUserId; + getOwnedRepo: typeof getOwnedRepo; + upsertRepoModelOverride: (input: { + repoId: string; + modelId: string; + }) => Promise<{ error: { message: string } | null }>; + deleteRepoModelOverride: (input: { + repoId: string; + modelId: string; + }) => Promise<{ error: { message: string } | null }>; +}; + const defaultRepoModelsGetDeps: RepoModelsGetDeps = { requireUserId, getOwnedRepo, @@ -110,6 +123,28 @@ const defaultRepoModelsGetDeps: RepoModelsGetDeps = { }, }; +const defaultRepoModelsPostDeps: RepoModelsPostDeps = { + requireUserId, + getOwnedRepo, + async upsertRepoModelOverride(input) { + const { error } = await supabaseAdmin + .from("repo_model_overrides") + .upsert( + { repo_id: input.repoId, model_id: input.modelId, excluded: true }, + { onConflict: "repo_id,model_id" } + ); + return { error: error ? { message: error.message } : null }; + }, + async deleteRepoModelOverride(input) { + const { error } = await supabaseAdmin + .from("repo_model_overrides") + .delete() + .eq("repo_id", input.repoId) + .eq("model_id", input.modelId); + return { error: error ? { message: error.message } : null }; + }, +}; + function firstLoadError( results: ReadonlyArray<{ error: { message: string } | null }> ): NextResponse | null { @@ -195,39 +230,46 @@ function resolveRepoModels( export const GET = createRepoModelsGetHandler(); /** Exclude/include a model for this repo */ -export async function POST(req: NextRequest, ctx: RouteContext) { - const { id: repoId } = await ctx.params; - const userId = await requireUserId(); - if (userId instanceof Response) return userId; - const repo = await getOwnedRepo(repoId, userId); - if (!repo) - return NextResponse.json({ error: "Repo not found" }, { status: 404 }); - - const { model_id, excluded } = await req.json(); - if (!model_id || typeof excluded !== "boolean") { - return NextResponse.json( - { error: "model_id and excluded required" }, - { status: 400 } - ); - } +export function createRepoModelsPostHandler( + overrides: Partial = {} +) { + const deps: RepoModelsPostDeps = { + ...defaultRepoModelsPostDeps, + ...overrides, + }; - if (excluded) { - const { error } = await supabaseAdmin - .from("repo_model_overrides") - .upsert( - { repo_id: repoId, model_id, excluded }, - { onConflict: "repo_id,model_id" } + return async function POST(req: NextRequest, ctx: RouteContext) { + const { id: repoId } = await ctx.params; + const userId = await deps.requireUserId(); + if (userId instanceof Response) return userId; + const repo = await deps.getOwnedRepo(repoId, userId); + if (!repo) { + return NextResponse.json({ error: "Repo not found" }, { status: 404 }); + } + + const body = await req.json().catch(() => null); + const modelId = + body && typeof body.model_id === "string" ? body.model_id.trim() : ""; + const excluded = body?.excluded; + if (!modelId || typeof excluded !== "boolean") { + return NextResponse.json( + { error: "model_id and excluded required" }, + { status: 400 } ); - if (error) - return NextResponse.json({ error: error.message }, { status: 500 }); - } else { - // Remove the override (un-exclude) - await supabaseAdmin - .from("repo_model_overrides") - .delete() - .eq("repo_id", repoId) - .eq("model_id", model_id); - } + } - return NextResponse.json({ ok: true }); + const result = excluded + ? await deps.upsertRepoModelOverride({ repoId, modelId }) + : await deps.deleteRepoModelOverride({ repoId, modelId }); + if (result.error) { + return NextResponse.json( + { error: result.error.message }, + { status: 500 } + ); + } + + return NextResponse.json({ ok: true }); + }; } + +export const POST = createRepoModelsPostHandler(); diff --git a/app/api/repos/[id]/monorepo/route.ts b/app/api/repos/[id]/monorepo/route.ts index 292330fd..77e0048c 100644 --- a/app/api/repos/[id]/monorepo/route.ts +++ b/app/api/repos/[id]/monorepo/route.ts @@ -4,6 +4,28 @@ import { requireUserId } from "@/lib/auth"; import { detectMonorepoStructure } from "@/lib/monorepo-detection"; import { getOAuthToken } from "@/lib/oauth-tokens"; +type PersistenceResult = { error: { message: string } | null }; + +async function updateMonorepoFlag(repoId: string): Promise { + const { error } = await supabaseAdmin + .from("repos") + .update({ is_monorepo: true }) + .eq("id", repoId); + return { error: error ? { message: error.message } : null }; +} + +export async function persistMonorepoDetection( + repoId: string, + update: (repoId: string) => Promise = updateMonorepoFlag +) { + const { error } = await update(repoId); + if (error) { + throw new Error("Failed to save detected repository structure", { + cause: error, + }); + } +} + export async function GET( _req: Request, { params }: { params: Promise<{ id: string }> } @@ -17,9 +39,15 @@ export async function GET( .select("id, full_name, default_branch, github_id") .eq("id", id) .eq("user_id", userId) - .single(); + .maybeSingle(); - if (error || !repo) { + if (error) { + return NextResponse.json( + { error: "Failed to load repository" }, + { status: 500 } + ); + } + if (!repo) { return NextResponse.json({ error: "Repo not found" }, { status: 404 }); } @@ -39,19 +67,34 @@ export async function GET( ); if (structure.is_monorepo) { - await supabaseAdmin - .from("repos") - .update({ is_monorepo: true }) - .eq("id", id); + try { + await persistMonorepoDetection(id); + } catch (persistError) { + return NextResponse.json( + { + error: + persistError instanceof Error + ? persistError.message + : "Failed to save detected repository structure", + }, + { status: 500 } + ); + } } // Include which paths already have spaces - const { data: existing } = await supabaseAdmin + const { data: existing, error: existingError } = await supabaseAdmin .from("repos") .select("root_directory") .eq("user_id", userId) .eq("github_id", repo.github_id) .not("root_directory", "is", null); + if (existingError) { + return NextResponse.json( + { error: "Failed to load existing repository paths" }, + { status: 500 } + ); + } const existingPaths = new Set( (existing || []).map((r) => r.root_directory as string) diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts index 0a23bd29..e3d651d7 100644 --- a/app/api/rules/route.ts +++ b/app/api/rules/route.ts @@ -2,6 +2,17 @@ import { NextResponse } from "next/server"; import { supabaseAdmin } from "@/lib/supabase/admin"; import { requireUserId } from "@/lib/auth"; +export function pickRuleWriteFields(input: unknown) { + const fields: Record = {}; + if (!input || typeof input !== "object" || Array.isArray(input)) + return fields; + const body = input as Record; + if (typeof body.name === "string") fields.name = body.name; + if (typeof body.content === "string") fields.content = body.content; + if (typeof body.type === "string") fields.type = body.type; + return fields; +} + export async function GET(req: Request) { const userId = await requireUserId(); if (userId instanceof Response) return userId; @@ -26,9 +37,18 @@ export async function POST(req: Request) { const userId = await requireUserId(); if (userId instanceof Response) return userId; - const body = await req.json(); - const table = body.table === "agent_skills" ? "agent_skills" : "agent_rules"; - const { table: _, ...fields } = body; + const body = await req.json().catch(() => null); + const table = + body && + typeof body === "object" && + !Array.isArray(body) && + (body as Record).table === "agent_skills" + ? "agent_skills" + : "agent_rules"; + const fields = pickRuleWriteFields(body); + if (typeof fields.name !== "string") { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } const { data, error } = await supabaseAdmin .from(table) @@ -45,9 +65,24 @@ export async function PUT(req: Request) { const userId = await requireUserId(); if (userId instanceof Response) return userId; - const body = await req.json(); - const table = body.table === "agent_skills" ? "agent_skills" : "agent_rules"; - const { id, table: _, ...updates } = body; + const body = await req.json().catch(() => null); + const record = + body && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : null; + const table = + record?.table === "agent_skills" ? "agent_skills" : "agent_rules"; + const id = record?.id; + if (typeof id !== "string" || !id.trim()) { + return NextResponse.json({ error: "Invalid rule id" }, { status: 400 }); + } + const updates = pickRuleWriteFields(record); + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { error: "No valid fields to update" }, + { status: 400 } + ); + } const { data, error } = await supabaseAdmin .from(table) diff --git a/app/api/sandbox/[id]/extend/route.ts b/app/api/sandbox/[id]/extend/route.ts index f3fe5641..a0e9a5db 100644 --- a/app/api/sandbox/[id]/extend/route.ts +++ b/app/api/sandbox/[id]/extend/route.ts @@ -20,12 +20,35 @@ type ExtendSandboxRecord = { const MIN_EXTEND_MINUTES = 1; const MAX_EXTEND_MINUTES = 300; +type PersistenceResult = { error: { message: string } | null }; + +async function touchExtendedSandbox(id: string): Promise { + const { error } = await supabaseAdmin + .from("sandboxes") + .update({ last_active_at: new Date().toISOString() }) + .eq("id", id); + return { error: error ? { message: error.message } : null }; +} + +export async function persistSandboxExtensionActivity( + id: string, + touch: (id: string) => Promise = touchExtendedSandbox +) { + const { error } = await touch(id); + if (error) { + throw new Error("Failed to record sandbox activity", { cause: error }); + } +} + export async function POST( request: Request, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; - const body = await request.json(); + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } const minutes = Number(body.minutes); if ( !Number.isFinite(minutes) || @@ -67,11 +90,19 @@ export async function POST( const durationMs = minutes * 60 * 1000; await extendSandboxTimeout(sandboxData.sandbox, durationMs); - // Touch last_active_at to reflect the extension - await supabaseAdmin - .from("sandboxes") - .update({ last_active_at: new Date().toISOString() }) - .eq("id", id); + try { + await persistSandboxExtensionActivity(id); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to record sandbox activity", + }, + { status: 500 } + ); + } return NextResponse.json({ ok: true, extendedByMs: durationMs }); } diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts index ede73588..03fe8b20 100644 --- a/app/api/skills/route.ts +++ b/app/api/skills/route.ts @@ -17,6 +17,32 @@ export function createScopedSkillResponse(skill: T | null) { return NextResponse.json(withGlobalScope(skill)); } +const SKILL_TYPES = new Set(["runbook", "tool", "prompt", "workflow"]); + +export function pickSkillWriteFields(input: unknown) { + const fields: Record = {}; + if (!input || typeof input !== "object" || Array.isArray(input)) + return fields; + const body = input as Record; + if (typeof body.name === "string") fields.name = body.name; + if (body.description === null || typeof body.description === "string") { + fields.description = body.description; + } + if (typeof body.content === "string") fields.content = body.content; + if (typeof body.type === "string" && SKILL_TYPES.has(body.type)) { + fields.type = body.type; + } + if (typeof body.model === "string") fields.model = body.model; + if (typeof body.is_public === "boolean") fields.is_public = body.is_public; + if ( + Array.isArray(body.tags) && + body.tags.every((tag) => typeof tag === "string") + ) { + fields.tags = body.tags; + } + return fields; +} + export async function GET(req: Request) { const userId = await requireUserId(); if (userId instanceof Response) return userId; @@ -45,10 +71,17 @@ export async function POST(req: Request) { const userId = await requireUserId(); if (userId instanceof Response) return userId; - const body = await req.json(); + const body = await req.json().catch(() => null); + const fields = pickSkillWriteFields(body); + if (typeof fields.name !== "string" || typeof fields.content !== "string") { + return NextResponse.json( + { error: "name and content are required" }, + { status: 400 } + ); + } const { data, error } = await supabaseAdmin .from("skills") - .insert({ ...body, user_id: userId }) + .insert({ ...fields, user_id: userId }) .select() .single(); @@ -61,8 +94,21 @@ export async function PUT(req: Request) { const userId = await requireUserId(); if (userId instanceof Response) return userId; - const body = await req.json(); - const { id, ...updates } = body; + const body = await req.json().catch(() => null); + const id = + body && typeof body === "object" && !Array.isArray(body) + ? (body as Record).id + : null; + if (typeof id !== "string" || !id.trim()) { + return NextResponse.json({ error: "Invalid skill id" }, { status: 400 }); + } + const updates = pickSkillWriteFields(body); + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { error: "No valid fields to update" }, + { status: 400 } + ); + } const { data, error } = await supabaseAdmin .from("skills") diff --git a/app/api/teams/[teamId]/invites/route.ts b/app/api/teams/[teamId]/invites/route.ts index 802cb152..f52113c8 100644 --- a/app/api/teams/[teamId]/invites/route.ts +++ b/app/api/teams/[teamId]/invites/route.ts @@ -30,12 +30,20 @@ export async function POST( const { teamId } = await context.params; - let body: { email?: unknown; role?: unknown }; + let parsedBody: unknown; try { - body = await request.json(); + parsedBody = await request.json(); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } + if ( + !parsedBody || + typeof parsedBody !== "object" || + Array.isArray(parsedBody) + ) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + const body = parsedBody as Record; const emailRaw = typeof body.email === "string" ? body.email.trim() : ""; const email = emailRaw.toLowerCase(); @@ -65,18 +73,31 @@ export async function POST( // Skip when this email is already a team member (joined via prior invite or // direct add). The DB doesn't enforce email→member uniqueness because we key // on user_id; check explicitly so the inviter sees a clear error. - const { data: existing } = await supabaseAdmin + const { data: existing, error: profileLookupError } = await supabaseAdmin .from("profiles") .select("id") .eq("email", email) .maybeSingle(); + if (profileLookupError) { + return NextResponse.json( + { error: "Failed to check existing team membership" }, + { status: 500 } + ); + } if (existing) { - const { data: alreadyMember } = await supabaseAdmin - .from("team_members") - .select("team_id") - .eq("team_id", teamId) - .eq("user_id", existing.id) - .maybeSingle(); + const { data: alreadyMember, error: memberLookupError } = + await supabaseAdmin + .from("team_members") + .select("team_id") + .eq("team_id", teamId) + .eq("user_id", existing.id) + .maybeSingle(); + if (memberLookupError) { + return NextResponse.json( + { error: "Failed to check existing team membership" }, + { status: 500 } + ); + } if (alreadyMember) { return NextResponse.json( { error: "That user is already a team member" }, @@ -90,17 +111,26 @@ export async function POST( .from("teams") .select("id, name, slug") .eq("id", teamId) - .single(), + .maybeSingle(), supabaseAdmin .from("profiles") .select("name, username") .eq("id", profileId) - .single(), + .maybeSingle(), ]); - if (teamResult.error || !teamResult.data) { + if (teamResult.error) { + return NextResponse.json({ error: "Failed to load team" }, { status: 500 }); + } + if (!teamResult.data) { return NextResponse.json({ error: "Team not found" }, { status: 404 }); } + if (inviterResult.error) { + return NextResponse.json( + { error: "Failed to load inviter" }, + { status: 500 } + ); + } const token = generateInviteToken(); const { data: invite, error } = await supabaseAdmin @@ -133,7 +163,7 @@ export async function POST( inviterName, role: role as InviteRole, token, - }); + }).catch(() => ({ ok: false as const, reason: "resend_error" as const })); const delivery: CreateInviteResponse["delivery"] = sendResult.ok ? sendResult.channel diff --git a/app/api/triggers/route.ts b/app/api/triggers/route.ts index 306acd32..1ee4214d 100644 --- a/app/api/triggers/route.ts +++ b/app/api/triggers/route.ts @@ -35,30 +35,41 @@ function slugify(name: string): string { const defaultTriggerAgentDeps = { async loadOwnedAgent(agentId: string, userId: string) { - const { data } = await supabaseAdmin + const { data, error } = await supabaseAdmin .from("agents") .select("id, name, slug") .eq("id", agentId) .eq("user_id", userId) .maybeSingle(); + if (error) throw new Error("Failed to load agent", { cause: error }); return data; }, async updateAgentSlug(agentId: string, slug: string) { - await supabaseAdmin.from("agents").update({ slug }).eq("id", agentId); + const { error } = await supabaseAdmin + .from("agents") + .update({ slug }) + .eq("id", agentId); + + if (error) { + throw new Error("Failed to update agent slug", { cause: error }); + } }, }; const defaultTriggerPostDeps: TriggerPostDeps = { requireUserId, async loadOwnedInstallation(installationId, userId) { - const { data } = await supabaseAdmin + const { data, error } = await supabaseAdmin .from("github_installations") .select("id") .eq("user_id", userId) .eq("installation_id", installationId) .maybeSingle(); + if (error) { + throw new Error("Failed to load installation", { cause: error }); + } return data; }, ...defaultTriggerAgentDeps, @@ -81,13 +92,28 @@ async function resolveOwnedTriggerAgent( ); } - const agent = await deps.loadOwnedAgent(agentId, userId); + let agent: Awaited>; + try { + agent = await deps.loadOwnedAgent(agentId, userId); + } catch { + return NextResponse.json( + { error: "Failed to load agent" }, + { status: 500 } + ); + } if (!agent) { return NextResponse.json({ error: "Agent not found" }, { status: 404 }); } if (!agent.slug) { - await deps.updateAgentSlug(agent.id, slugify(agent.name)); + try { + await deps.updateAgentSlug(agent.id, slugify(agent.name)); + } catch { + return NextResponse.json( + { error: "Failed to prepare agent" }, + { status: 500 } + ); + } } return agent.id; @@ -171,7 +197,10 @@ export function createTriggersPostHandler( const userId = await deps.requireUserId(); if (userId instanceof Response) return userId; - const body = await request.json(); + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } const { installation_id, agent_id, event, is_default } = body; if (!installation_id || !agent_id || !event) { @@ -181,10 +210,17 @@ export function createTriggersPostHandler( ); } - const installation = await deps.loadOwnedInstallation( - installation_id, - userId - ); + let installation: Awaited< + ReturnType + >; + try { + installation = await deps.loadOwnedInstallation(installation_id, userId); + } catch { + return NextResponse.json( + { error: "Failed to load installation" }, + { status: 500 } + ); + } if (!installation) { return NextResponse.json( { error: "Installation not found" }, @@ -232,7 +268,10 @@ export function createTriggersPutHandler( const userId = await deps.requireUserId(); if (userId instanceof Response) return userId; - const body = await request.json(); + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } const { id, ...updates } = body; if (!id) diff --git a/neon/migrations/20260830190000_atomic_invite_acceptance.sql b/neon/migrations/20260830190000_atomic_invite_acceptance.sql new file mode 100644 index 00000000..ebccbd27 --- /dev/null +++ b/neon/migrations/20260830190000_atomic_invite_acceptance.sql @@ -0,0 +1,98 @@ +drop function if exists public.accept_team_invite(text, uuid, boolean); + +create function public.accept_team_invite( + p_token text, + p_profile_id uuid, + p_confirm_mismatch boolean default false +) returns table ( + invite_id uuid, + team_id uuid, + team_slug text, + invite_email text, + invite_role text, + email_match boolean +) +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_invite public.team_invites%rowtype; + v_profile_email text; + v_team_slug text; +begin + if p_token is null or btrim(p_token) = '' then + raise exception using errcode = 'P0002', message = 'invite_not_found'; + end if; + + select candidate.* + into v_invite + from public.team_invites as candidate + where candidate.token = p_token + for update; + + if not found then + raise exception using errcode = 'P0002', message = 'invite_not_found'; + end if; + if v_invite.accepted_at is not null then + raise exception using errcode = 'P0001', message = 'already_accepted'; + end if; + if v_invite.expires_at < clock_timestamp() then + raise exception using errcode = 'P0001', message = 'expired'; + end if; + + select profile.email + into v_profile_email + from public.profiles as profile + where profile.id = p_profile_id; + + if not found then + raise exception using errcode = 'P0002', message = 'profile_not_found'; + end if; + + email_match := + v_profile_email is not null + and lower(v_profile_email) = lower(v_invite.email); + if not email_match and not coalesce(p_confirm_mismatch, false) then + raise exception using errcode = 'P0001', message = 'mismatch_unconfirmed'; + end if; + + insert into public.team_members ( + team_id, + user_id, + role, + invited_by_user_id + ) values ( + v_invite.team_id, + p_profile_id, + v_invite.role, + v_invite.invited_by_user_id + ) + on conflict on constraint team_members_pkey do nothing; + + update public.team_invites as claimed + set accepted_at = clock_timestamp() + where claimed.id = v_invite.id; + + select team_record.slug + into v_team_slug + from public.teams as team_record + where team_record.id = v_invite.team_id; + + if not found then + raise exception using errcode = 'P0002', message = 'team_not_found'; + end if; + + invite_id := v_invite.id; + team_id := v_invite.team_id; + team_slug := v_team_slug; + invite_email := v_invite.email; + invite_role := v_invite.role; + return next; +end; +$$; + +revoke all on function public.accept_team_invite(text, uuid, boolean) + from public, anon, authenticated; +grant execute on function public.accept_team_invite(text, uuid, boolean) + to service_role; diff --git a/supabase/migrations/20260830190000_atomic_invite_acceptance.sql b/supabase/migrations/20260830190000_atomic_invite_acceptance.sql new file mode 100644 index 00000000..ebccbd27 --- /dev/null +++ b/supabase/migrations/20260830190000_atomic_invite_acceptance.sql @@ -0,0 +1,98 @@ +drop function if exists public.accept_team_invite(text, uuid, boolean); + +create function public.accept_team_invite( + p_token text, + p_profile_id uuid, + p_confirm_mismatch boolean default false +) returns table ( + invite_id uuid, + team_id uuid, + team_slug text, + invite_email text, + invite_role text, + email_match boolean +) +language plpgsql +security definer +set search_path = pg_catalog, public +as $$ +declare + v_invite public.team_invites%rowtype; + v_profile_email text; + v_team_slug text; +begin + if p_token is null or btrim(p_token) = '' then + raise exception using errcode = 'P0002', message = 'invite_not_found'; + end if; + + select candidate.* + into v_invite + from public.team_invites as candidate + where candidate.token = p_token + for update; + + if not found then + raise exception using errcode = 'P0002', message = 'invite_not_found'; + end if; + if v_invite.accepted_at is not null then + raise exception using errcode = 'P0001', message = 'already_accepted'; + end if; + if v_invite.expires_at < clock_timestamp() then + raise exception using errcode = 'P0001', message = 'expired'; + end if; + + select profile.email + into v_profile_email + from public.profiles as profile + where profile.id = p_profile_id; + + if not found then + raise exception using errcode = 'P0002', message = 'profile_not_found'; + end if; + + email_match := + v_profile_email is not null + and lower(v_profile_email) = lower(v_invite.email); + if not email_match and not coalesce(p_confirm_mismatch, false) then + raise exception using errcode = 'P0001', message = 'mismatch_unconfirmed'; + end if; + + insert into public.team_members ( + team_id, + user_id, + role, + invited_by_user_id + ) values ( + v_invite.team_id, + p_profile_id, + v_invite.role, + v_invite.invited_by_user_id + ) + on conflict on constraint team_members_pkey do nothing; + + update public.team_invites as claimed + set accepted_at = clock_timestamp() + where claimed.id = v_invite.id; + + select team_record.slug + into v_team_slug + from public.teams as team_record + where team_record.id = v_invite.team_id; + + if not found then + raise exception using errcode = 'P0002', message = 'team_not_found'; + end if; + + invite_id := v_invite.id; + team_id := v_invite.team_id; + team_slug := v_team_slug; + invite_email := v_invite.email; + invite_role := v_invite.role; + return next; +end; +$$; + +revoke all on function public.accept_team_invite(text, uuid, boolean) + from public, anon, authenticated; +grant execute on function public.accept_team_invite(text, uuid, boolean) + to service_role; diff --git a/tests/db/atomic-invite-acceptance.test.ts b/tests/db/atomic-invite-acceptance.test.ts new file mode 100644 index 00000000..810cf564 --- /dev/null +++ b/tests/db/atomic-invite-acceptance.test.ts @@ -0,0 +1,163 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { PGlite } from "@electric-sql/pglite"; +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "..", ".."); +const MIGRATION_NAME = "20260830190000_atomic_invite_acceptance.sql"; +const TEAM_ID = "00000000-0000-4000-8000-000000000001"; +const INVITER_ID = "00000000-0000-4000-8000-000000000002"; +const FIRST_USER_ID = "00000000-0000-4000-8000-000000000003"; +const SECOND_USER_ID = "00000000-0000-4000-8000-000000000004"; + +async function createSeededDb() { + const db = new PGlite(); + await db.exec(` + create role anon; + create role authenticated; + create role service_role; + + create table public.profiles ( + id uuid primary key, + email text + ); + create table public.teams ( + id uuid primary key, + slug text not null + ); + create table public.team_members ( + team_id uuid not null references public.teams(id) on delete cascade, + user_id uuid not null references public.profiles(id) on delete cascade, + role text not null, + invited_by_user_id uuid references public.profiles(id) on delete set null, + primary key (team_id, user_id) + ); + create table public.team_invites ( + id uuid primary key, + team_id uuid not null references public.teams(id) on delete cascade, + email text not null, + role text not null, + token text not null unique, + invited_by_user_id uuid references public.profiles(id) on delete set null, + expires_at timestamptz not null, + accepted_at timestamptz + ); + + insert into public.profiles (id, email) values + ('${INVITER_ID}', 'owner@example.com'), + ('${FIRST_USER_ID}', 'first@example.com'), + ('${SECOND_USER_ID}', 'second@example.com'); + insert into public.teams (id, slug) values ('${TEAM_ID}', 'builders'); + `); + + const sql = await readFile( + path.join(REPO_ROOT, "supabase/migrations", MIGRATION_NAME), + "utf8" + ); + await db.exec(sql); + return db; +} + +async function insertInvite(db: PGlite, input: { id: string; token: string }) { + await db.exec(` + insert into public.team_invites ( + id, team_id, email, role, token, invited_by_user_id, expires_at + ) values ( + '${input.id}', '${TEAM_ID}', 'recipient@example.com', 'developer', + '${input.token}', '${INVITER_ID}', now() + interval '1 day' + ) + `); +} + +describe("atomic invite acceptance migration", () => { + it("keeps the production migration ledgers identical", async () => { + const [neon, supabase] = await Promise.all([ + readFile(path.join(REPO_ROOT, "neon/migrations", MIGRATION_NAME), "utf8"), + readFile( + path.join(REPO_ROOT, "supabase/migrations", MIGRATION_NAME), + "utf8" + ), + ]); + expect(neon).toBe(supabase); + }); + + it("allows exactly one confirmed mismatch claimant", async () => { + const db = await createSeededDb(); + try { + await insertInvite(db, { + id: "00000000-0000-4000-8000-000000000010", + token: "single-use-token", + }); + + const attempts = await Promise.allSettled([ + db.query(`select * from public.accept_team_invite($1, $2, $3)`, [ + "single-use-token", + FIRST_USER_ID, + true, + ]), + db.query(`select * from public.accept_team_invite($1, $2, $3)`, [ + "single-use-token", + SECOND_USER_ID, + true, + ]), + ]); + + expect( + attempts.filter((attempt) => attempt.status === "fulfilled") + ).toHaveLength(1); + expect( + attempts.filter((attempt) => attempt.status === "rejected") + ).toHaveLength(1); + + const members = await db.query<{ user_id: string }>(` + select user_id from public.team_members where team_id = '${TEAM_ID}' + `); + expect(members.rows).toHaveLength(1); + const invite = await db.query<{ accepted_at: string | null }>(` + select accepted_at from public.team_invites where token = 'single-use-token' + `); + expect(invite.rows[0]?.accepted_at).not.toBeNull(); + } finally { + await db.close(); + } + }); + + it("rolls back the invite claim when membership creation fails", async () => { + const db = await createSeededDb(); + try { + await insertInvite(db, { + id: "00000000-0000-4000-8000-000000000011", + token: "rollback-token", + }); + await db.exec(` + create function public.reject_test_member() + returns trigger language plpgsql as $$ + begin + if new.user_id = '${FIRST_USER_ID}' then + raise exception 'membership rejected'; + end if; + return new; + end; + $$; + create trigger reject_test_member + before insert on public.team_members + for each row execute function public.reject_test_member(); + `); + + await expect( + db.query(`select * from public.accept_team_invite($1, $2, $3)`, [ + "rollback-token", + FIRST_USER_ID, + true, + ]) + ).rejects.toThrow(/membership rejected/); + + const invite = await db.query<{ accepted_at: string | null }>(` + select accepted_at from public.team_invites where token = 'rollback-token' + `); + expect(invite.rows[0]?.accepted_at).toBeNull(); + } finally { + await db.close(); + } + }); +}); diff --git a/tests/unit/control-chat-contract.test.ts b/tests/unit/control-chat-contract.test.ts index da066fe3..8483fde7 100644 --- a/tests/unit/control-chat-contract.test.ts +++ b/tests/unit/control-chat-contract.test.ts @@ -160,31 +160,22 @@ test("control chat normalization caps file parts per request", () => { ); }); -test("control chat normalization allows capped file parts across message history", () => { - const messages = normalizeControlChatMessages([ - { - role: "user", - parts: Array.from({ length: 3 }, (_, index) => ({ - type: "file" as const, - filename: `prior-${index}.txt`, - mediaType: "text/plain", - url: "data:text/plain;base64,cGxhbg==", - })), - }, - { - role: "user", - parts: Array.from({ length: 3 }, (_, index) => ({ - type: "file" as const, - filename: `current-${index}.txt`, - mediaType: "text/plain", - url: "data:text/plain;base64,cGxhbg==", - })), - }, - ]); - - assert.equal(messages.length, 2); - assert.equal(messages[0]?.parts.length, 3); - assert.equal(messages[1]?.parts.length, 3); +test("control chat caps file parts across the full request history", () => { + assert.throws( + () => + normalizeControlChatMessages( + Array.from({ length: 3 }, (_, messageIndex) => ({ + role: "user", + parts: Array.from({ length: 2 }, (_, partIndex) => ({ + type: "file" as const, + filename: `attachment-${messageIndex}-${partIndex}.txt`, + mediaType: "text/plain", + url: "data:text/plain;base64,cGxhbg==", + })), + })) + ), + /supports up to 5 file attachments/ + ); }); test("control prompt sandbox context comes from an owned server record", async () => { diff --git a/tests/unit/control-chat-validation.test.ts b/tests/unit/control-chat-validation.test.ts new file mode 100644 index 00000000..ae47f395 --- /dev/null +++ b/tests/unit/control-chat-validation.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeControlChatMessages } from "../../app/api/control/chat/_lib/messages"; + +test("control chat rejects malformed roles and parts", () => { + assert.throws( + () => normalizeControlChatMessages([{ role: "owner", parts: [] } as never]), + /Invalid control chat message role/ + ); + assert.throws( + () => + normalizeControlChatMessages([{ role: "user", parts: [null as never] }]), + /Invalid control chat message part/ + ); + assert.throws( + () => + normalizeControlChatMessages([ + { role: "user", parts: [{ type: "text" }] }, + ]), + /Invalid control chat text part/ + ); +}); diff --git a/tests/unit/invite-acceptance-route.test.ts b/tests/unit/invite-acceptance-route.test.ts new file mode 100644 index 00000000..cdda517b --- /dev/null +++ b/tests/unit/invite-acceptance-route.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +async function loadInviteRoute() { + process.env.NEXT_PUBLIC_SUPABASE_URL ||= "https://example.supabase.co"; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= "test-anon-key"; + process.env.SUPABASE_SERVICE_ROLE_KEY ||= "test-service-role-key"; + return import("../../app/api/invites/[token]/route"); +} + +test("invite lookup reports database failures instead of a false 404", async () => { + const { createInviteGetHandler } = await loadInviteRoute(); + const handler = createInviteGetHandler({ + requireProfileId: async () => "user-1", + lookupInvite: async () => { + throw new Error("database unavailable"); + }, + }); + + const response = await handler( + new Request("http://localhost/api/invites/token-1"), + { params: Promise.resolve({ token: "token-1" }) } + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "Failed to load invite" }); +}); + +test("invite acceptance maps atomic claim failures without writing an audit event", async () => { + const { createAcceptInviteHandler } = await loadInviteRoute(); + let auditWrites = 0; + const handler = createAcceptInviteHandler({ + requireProfileId: async () => "user-1", + acceptInvite: async () => ({ + data: null, + error: { message: "already_accepted" }, + }), + recordTeamAuditEvent: async () => { + auditWrites += 1; + return { ok: true }; + }, + }); + + const response = await handler( + new Request("http://localhost/api/invites/token-1", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirmMismatch: true }), + }), + { params: Promise.resolve({ token: "token-1" }) } + ); + + assert.equal(response.status, 410); + assert.deepEqual(await response.json(), { error: "already_accepted" }); + assert.equal(auditWrites, 0); +}); + +test("invite acceptance returns the atomically claimed team", async () => { + const { createAcceptInviteHandler } = await loadInviteRoute(); + const handler = createAcceptInviteHandler({ + requireProfileId: async () => "user-1", + acceptInvite: async () => ({ + data: { + invite_id: "invite-1", + team_id: "team-1", + team_slug: "builders", + invite_email: "dev@example.com", + invite_role: "developer", + email_match: true, + }, + error: null, + }), + recordTeamAuditEvent: async () => ({ ok: true }), + }); + + const response = await handler( + new Request("http://localhost/api/invites/token-1", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + { params: Promise.resolve({ token: "token-1" }) } + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + team: { id: "team-1", slug: "builders" }, + }); +}); + +test("invite acceptance rejects non-object JSON before claiming the token", async () => { + const { createAcceptInviteHandler } = await loadInviteRoute(); + let acceptanceCalls = 0; + const handler = createAcceptInviteHandler({ + requireProfileId: async () => "user-1", + acceptInvite: async () => { + acceptanceCalls += 1; + return { data: null, error: null }; + }, + }); + + for (const body of [null, [], "confirm", 1]) { + const response = await handler( + new Request("http://localhost/api/invites/token-1", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ token: "token-1" }) } + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "Invalid JSON body" }); + } + assert.equal(acceptanceCalls, 0); +}); diff --git a/tests/unit/prod-route-integrity.test.ts b/tests/unit/prod-route-integrity.test.ts new file mode 100644 index 00000000..41e88bcc --- /dev/null +++ b/tests/unit/prod-route-integrity.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +function prepareRouteEnv() { + process.env.NEXT_PUBLIC_SUPABASE_URL ||= "https://example.supabase.co"; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= "test-anon-key"; + process.env.SUPABASE_SERVICE_ROLE_KEY ||= "test-service-role-key"; +} + +test("service-role content routes allowlist caller-controlled writes", async () => { + prepareRouteEnv(); + const [{ pickSkillWriteFields }, { pickRuleWriteFields }, commands] = + await Promise.all([ + import("../../app/api/skills/route"), + import("../../app/api/rules/route"), + import("../../app/api/commands/route"), + ]); + + const hostile = { + id: "chosen-id", + user_id: "victim-user", + created_at: "2000-01-01T00:00:00.000Z", + updated_at: "2000-01-01T00:00:00.000Z", + usage_count: 999, + name: "Review", + description: "Review changes", + content: "Be precise", + template: "Review $ARGS", + type: "prompt", + model: "openai/gpt-5.6-sol", + is_public: true, + tags: ["review"], + }; + + assert.deepEqual(pickSkillWriteFields(hostile), { + name: "Review", + description: "Review changes", + content: "Be precise", + type: "prompt", + model: "openai/gpt-5.6-sol", + is_public: true, + tags: ["review"], + }); + assert.deepEqual(pickRuleWriteFields(hostile), { + name: "Review", + content: "Be precise", + type: "prompt", + }); + assert.deepEqual(commands.pickCommandCreateFields(hostile), { + name: "Review", + description: "Review changes", + template: "Review $ARGS", + }); +}); + +test("derived state persistence rejects database write failures", async () => { + prepareRouteEnv(); + const [{ persistMonorepoDetection }, { persistSandboxExtensionActivity }] = + await Promise.all([ + import("../../app/api/repos/[id]/monorepo/route"), + import("../../app/api/sandbox/[id]/extend/route"), + ]); + + await assert.rejects( + persistMonorepoDetection("repo-1", async () => ({ + error: { message: "write failed" }, + })), + /Failed to save detected repository structure/ + ); + await assert.rejects( + persistSandboxExtensionActivity("sandbox-1", async () => ({ + error: { message: "write failed" }, + })), + /Failed to record sandbox activity/ + ); +}); diff --git a/tests/unit/repo-models-route.test.ts b/tests/unit/repo-models-route.test.ts index 87b4223e..181ae408 100644 --- a/tests/unit/repo-models-route.test.ts +++ b/tests/unit/repo-models-route.test.ts @@ -234,3 +234,27 @@ test("GET /api/repos/[id]/models returns 500 when the profile settings load fail assert.equal(response.status, 500); assert.equal((await response.json()).error, "profile read failed"); }); + +test("POST /api/repos/[id]/models reports a failed unexclude write", async () => { + const { createRepoModelsPostHandler } = await loadRepoModelsRoute(); + const handler = createRepoModelsPostHandler({ + requireUserId: async () => "user-123", + getOwnedRepo: async () => ({ id: "repo-123" }) as T, + upsertRepoModelOverride: async () => ({ error: null }), + deleteRepoModelOverride: async () => ({ + error: { message: "delete failed" }, + }), + }); + + const response = await handler( + new Request("http://localhost/api/repos/repo-123/models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model_id: "openai/gpt-5.6-sol", excluded: false }), + }) as never, + { params: Promise.resolve({ id: "repo-123" }) } + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "delete failed" }); +}); diff --git a/tests/unit/triggers-route.test.ts b/tests/unit/triggers-route.test.ts index 131a7030..4e7285c2 100644 --- a/tests/unit/triggers-route.test.ts +++ b/tests/unit/triggers-route.test.ts @@ -35,3 +35,85 @@ test("PUT /api/triggers rejects agent ids the caller does not own", async () => assert.deepEqual(await response.json(), { error: "Agent not found" }); assert.equal(slugUpdates, 0); }); + +test("PUT /api/triggers reports a failed agent slug write", async () => { + const { createTriggersPutHandler } = await loadTriggersRoute(); + const handler = createTriggersPutHandler({ + requireUserId: async () => "user-123", + loadOwnedAgent: async () => ({ + id: "agent-123", + name: "Review Pull Requests", + slug: null, + }), + updateAgentSlug: async () => { + throw new Error("slug write failed"); + }, + }); + + const response = await handler( + new Request("http://localhost/api/triggers", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "trigger-123", + agent_id: "agent-123", + }), + }) + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { + error: "Failed to prepare agent", + }); +}); + +test("PUT /api/triggers reports a failed agent ownership lookup", async () => { + const { createTriggersPutHandler } = await loadTriggersRoute(); + const handler = createTriggersPutHandler({ + requireUserId: async () => "user-123", + loadOwnedAgent: async () => { + throw new Error("database unavailable"); + }, + }); + + const response = await handler( + new Request("http://localhost/api/triggers", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "trigger-123", + agent_id: "agent-123", + }), + }) + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "Failed to load agent" }); +}); + +test("POST /api/triggers reports a failed installation ownership lookup", async () => { + const { createTriggersPostHandler } = await loadTriggersRoute(); + const handler = createTriggersPostHandler({ + requireUserId: async () => "user-123", + loadOwnedInstallation: async () => { + throw new Error("database unavailable"); + }, + }); + + const response = await handler( + new Request("http://localhost/api/triggers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + installation_id: 123, + agent_id: "agent-123", + event: "push", + }), + }) + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { + error: "Failed to load installation", + }); +}); From b5b1b0e13a03cb06a88880efe8d01dea94c9b082 Mon Sep 17 00:00:00 2001 From: Charles Howard <96023061+charlesrhoward@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:18:37 -0400 Subject: [PATCH 2/2] Validate control attachment base64 canonically --- app/api/control/chat/_lib/messages.ts | 14 +++++++++++++- tests/unit/control-chat-validation.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/api/control/chat/_lib/messages.ts b/app/api/control/chat/_lib/messages.ts index 9ed61220..be3a3a1d 100644 --- a/app/api/control/chat/_lib/messages.ts +++ b/app/api/control/chat/_lib/messages.ts @@ -37,7 +37,19 @@ function readFilePartBytes(part: ControlChatRequestPart): number { "Invalid control chat file attachment." ); } - const decodedBytes = Buffer.byteLength(match[2] ?? "", "base64"); + const encodedData = match[2] ?? ""; + if (encodedData.length % 4 !== 0) { + throw new ControlChatValidationError( + "Invalid control chat file attachment." + ); + } + const decodedData = Buffer.from(encodedData, "base64"); + if (decodedData.toString("base64") !== encodedData) { + throw new ControlChatValidationError( + "Invalid control chat file attachment." + ); + } + const decodedBytes = decodedData.byteLength; if (decodedBytes > MAX_CONTROL_FILE_BYTES) { throw new ControlChatValidationError( "Control chat file attachment exceeds the size limit." diff --git a/tests/unit/control-chat-validation.test.ts b/tests/unit/control-chat-validation.test.ts index ae47f395..3a378056 100644 --- a/tests/unit/control-chat-validation.test.ts +++ b/tests/unit/control-chat-validation.test.ts @@ -20,3 +20,25 @@ test("control chat rejects malformed roles and parts", () => { /Invalid control chat text part/ ); }); + +test("control chat rejects malformed base64 attachments", () => { + for (const encodedData of ["a", "abcde", "ab=="]) { + assert.throws( + () => + normalizeControlChatMessages([ + { + role: "user", + parts: [ + { + type: "file", + filename: "malformed.txt", + mediaType: "text/plain", + url: `data:text/plain;base64,${encodedData}`, + }, + ], + }, + ]), + /Invalid control chat file attachment/ + ); + } +});