fix(api): validate request bodies with Zod in 4 routes — restores the t06 gate (#9779)

The release-green verdict (#9737) lists check:route-validation:t06 as a HARD
failure and it is STILL red on the current tip: four routes call
request.json() and hand-roll `typeof x === "string"` checks instead of using
Zod, which Hard Rule #7 requires and the gate enforces (it scans source and
has no allowlist).

- src/app/api/plugins/marketplace/install (#9445): InstallBodySchema; the
  400 'Missing or invalid name field' response is preserved verbatim.
- src/app/api/services/dario/admin/accounts (#8523): DeleteAccountBodySchema
  for the optional { alias } DELETE body; query-param path untouched.
- src/app/api/services/dario/admin/login-start (#8523): LoginStartBodySchema;
  trimming now happens in the schema, so the forward body is unchanged.
- src/app/api/services/dario/admin/import-from-omniroute (#8523):
  ImportBodySchema for connectionId/alias; invalid shapes fall back to the
  same 'connectionId is required' 400 as before.

All four keep their exact status codes and messages — this is a validation
mechanism swap, not a contract change (plugins route suite still 33/33).

Adds tests/unit/route-body-validation-t06.test.ts, which runs the gate's own
rule inside the unit suite so the next such route fails on ITS OWN PR instead
of surfacing weeks later in a base-red sweep. Guard verified by mutation:
renaming .safeParse( in one route makes it fail (1 fail), restored from a
pre-probe copy.

Gates: route-validation:t06, file-size, test-discovery, mutation-test-coverage,
dead-code exit 0; typecheck:core clean; eslint clean.

Refs #9737

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-08 10:35:18 -03:00
committed by GitHub
parent 63cf354129
commit 24bdae29ca
6 changed files with 94 additions and 19 deletions

View File

@@ -1,9 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { installMarketplacePlugin } from "@/lib/plugins/marketplace";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
const InstallBodySchema = z.object({
name: z.string().trim().min(1),
});
export async function OPTIONS() {
return handleCorsOptions();
}
@@ -15,15 +20,14 @@ export async function POST(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const body = await request.json();
const { name } = body as { name?: string };
if (!name || typeof name !== "string") {
const parsed = InstallBodySchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(buildErrorBody(400, "Missing or invalid 'name' field"), {
status: 400,
headers: CORS_HEADERS,
});
}
const result = await installMarketplacePlugin(name);
const result = await installMarketplacePlugin(parsed.data.name);
return NextResponse.json(result, { status: 201, headers: CORS_HEADERS });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to install marketplace plugin";

View File

@@ -11,9 +11,15 @@
* and never reaches the browser.
*/
import { z } from "zod";
import { forwardToDarioAdmin, requireAdminAuth } from "../_lib";
import { createErrorResponse } from "@/lib/api/errorResponse";
const DeleteAccountBodySchema = z.object({
alias: z.string().trim().min(1).optional(),
});
export async function GET(request: Request): Promise<Response> {
const authResponse = await requireAdminAuth(request);
if (authResponse) return authResponse;
@@ -29,9 +35,9 @@ export async function DELETE(request: Request): Promise<Response> {
if (!alias && request.body !== null) {
try {
const parsed = await request.json();
if (parsed && typeof parsed === "object" && typeof (parsed as { alias?: unknown }).alias === "string") {
alias = (parsed as { alias: string }).alias.trim();
const parsed = DeleteAccountBodySchema.safeParse(await request.json());
if (parsed.success && parsed.data.alias) {
alias = parsed.data.alias;
}
} catch {
/* fall through to the missing-alias error below */

View File

@@ -28,6 +28,7 @@
* pickup, rather than relying on any undocumented hot-reload behavior.
*/
import { z } from "zod";
import { NextResponse } from "next/server";
import fs from "node:fs";
import path from "node:path";
@@ -39,6 +40,11 @@ import { getDarioHomeDir } from "@/lib/services/installers/dario";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const ImportBodySchema = z.object({
connectionId: z.string().trim().min(1).optional(),
alias: z.string().trim().min(1).optional(),
});
const ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_\-.]{0,63}$/;
function safeAliasFromSource(email: string | null | undefined, connectionId: string): string {
@@ -82,15 +88,16 @@ export async function POST(request: Request): Promise<Response> {
const authResponse = await requireAdminAuth(request);
if (authResponse) return authResponse;
let body: unknown;
let raw: unknown;
try {
body = await request.json();
raw = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const b = (body || {}) as Record<string, unknown>;
const connectionId = typeof b.connectionId === "string" ? b.connectionId : null;
const parsed = ImportBodySchema.safeParse(raw ?? {});
const b = parsed.success ? parsed.data : {};
const connectionId = b.connectionId ?? null;
if (!connectionId) {
return createErrorResponse({ status: 400, message: "connectionId is required" });
}
@@ -112,10 +119,7 @@ export async function POST(request: Request): Promise<Response> {
});
}
let alias =
typeof b.alias === "string" && b.alias.trim()
? b.alias.trim()
: safeAliasFromSource(conn.email as string | null, connectionId);
let alias = b.alias || safeAliasFromSource(conn.email as string | null, connectionId);
if (!ALIAS_PATTERN.test(alias)) {
alias = safeAliasFromSource(conn.email as string | null, connectionId);
}

View File

@@ -8,23 +8,29 @@
* posts the displayed code to /login-complete.
*/
import { z } from "zod";
import { forwardToDarioAdmin, requireAdminAuth } from "../_lib";
import { createErrorResponse } from "@/lib/api/errorResponse";
const LoginStartBodySchema = z.object({
alias: z.string().trim().min(1).optional(),
});
type LoginStartBody = z.infer<typeof LoginStartBodySchema>;
export async function POST(request: Request): Promise<Response> {
const authResponse = await requireAdminAuth(request);
if (authResponse) return authResponse;
let body: { alias?: string } = {};
let body: LoginStartBody = {};
try {
if (request.body !== null) {
const parsed = await request.json();
if (parsed && typeof parsed === "object") body = parsed as { alias?: string };
const parsed = LoginStartBodySchema.safeParse(await request.json());
if (parsed.success) body = parsed.data;
}
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
}
const forwardBody = typeof body.alias === "string" && body.alias.trim() ? { alias: body.alias.trim() } : {};
const forwardBody = body.alias ? { alias: body.alias } : {};
return forwardToDarioAdmin({ method: "POST", path: "/admin/login/start", body: forwardBody });
}