Formalize CreateFlowState + validate draft/publish API payloads

This commit is contained in:
adilallo
2026-04-04 22:37:46 -06:00
parent c8e930552b
commit c4b600e944
12 changed files with 409 additions and 62 deletions
@@ -0,0 +1,75 @@
import { z } from "zod";
import { FLOW_STEP_ORDER } from "../../../app/create/utils/flowSteps";
import {
assertPlainJsonValue,
DEFAULT_PLAIN_JSON_LIMITS,
} from "./plainJson";
const flowStepTuple = FLOW_STEP_ORDER as unknown as [
string,
...string[],
];
const createFlowStepSchema = z.enum(flowStepTuple);
/**
* Published rule `document` column: arbitrary JSON object with safety bounds.
*/
export const publishedRuleDocumentSchema = z
.record(z.string(), z.unknown())
.superRefine((doc, ctx) => {
const err = assertPlainJsonValue(doc, 0, DEFAULT_PLAIN_JSON_LIMITS);
if (err) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: err,
});
}
});
/**
* Create-flow draft payload: known optional fields plus passthrough for future steps.
* Full tree must satisfy {@link assertPlainJsonValue}.
*/
export const createFlowStateSchema = z
.object({
title: z.string().max(500).optional(),
summary: z.string().max(8000).optional(),
currentStep: createFlowStepSchema.optional(),
sections: z.array(z.unknown()).optional(),
stakeholders: z.array(z.unknown()).optional(),
})
.passthrough()
.superRefine((data, ctx) => {
const err = assertPlainJsonValue(data, 0, DEFAULT_PLAIN_JSON_LIMITS);
if (err) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: err });
}
});
export const publishRuleBodySchema = z
.object({
title: z
.string()
.max(500)
.transform((s) => s.trim())
.refine((s) => s.length > 0, { message: "title required" }),
summary: z
.union([z.string().max(8000), z.null()])
.optional()
.transform((val) => {
if (val === undefined || val === null) {
return null;
}
const t = val.trim();
return t.length > 0 ? t : null;
}),
document: publishedRuleDocumentSchema,
});
export type PublishRuleBody = z.infer<typeof publishRuleBodySchema>;
export const putDraftBodySchema = z.object({
payload: createFlowStateSchema,
});
export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>;
+73
View File
@@ -0,0 +1,73 @@
/**
* Validates that a value is JSON-like (finite numbers, plain objects, no prototype tricks).
* Used after JSON.parse for defense in depth against odd clients.
*/
export const DEFAULT_PLAIN_JSON_LIMITS = {
maxDepth: 40,
maxStringLength: 50_000,
maxArrayLength: 5_000,
maxObjectKeys: 500,
} as const;
export type PlainJsonLimits = typeof DEFAULT_PLAIN_JSON_LIMITS;
/**
* @returns `null` if valid, otherwise a short error message for API responses.
*/
export function assertPlainJsonValue(
val: unknown,
depth: number,
limits: PlainJsonLimits = DEFAULT_PLAIN_JSON_LIMITS,
): string | null {
if (depth > limits.maxDepth) {
return "Maximum nesting depth exceeded";
}
if (val === null) {
return null;
}
const t = typeof val;
if (t === "string") {
const s = val as string;
return s.length > limits.maxStringLength ? "String value too long" : null;
}
if (t === "number") {
return Number.isFinite(val as number) ? null : "Invalid number value";
}
if (t === "boolean") {
return null;
}
if (t === "bigint" || t === "function" || t === "symbol") {
return "Invalid value type";
}
if (Array.isArray(val)) {
if (val.length > limits.maxArrayLength) {
return "Array too long";
}
for (let i = 0; i < val.length; i++) {
const inner = assertPlainJsonValue(val[i], depth + 1, limits);
if (inner) {
return inner;
}
}
return null;
}
if (t === "object") {
const o = val as Record<string, unknown>;
const keys = Object.keys(o);
if (keys.length > limits.maxObjectKeys) {
return "Object has too many keys";
}
for (const k of keys) {
if (k === "__proto__" || k === "constructor" || k === "prototype") {
return "Unsafe object key";
}
const inner = assertPlainJsonValue(o[k], depth + 1, limits);
if (inner) {
return inner;
}
}
return null;
}
return "Invalid value type";
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
export const MAX_JSON_BODY_BYTES = 512 * 1024;
export type LimitedJsonResult =
| { ok: true; value: unknown }
| { ok: false; response: NextResponse };
/**
* Read the body as text (bounded by maxBytes), then JSON.parse.
* Returns 413 when over limit; 400 when JSON is invalid.
*/
export async function readLimitedJson(
request: NextRequest,
maxBytes: number = MAX_JSON_BODY_BYTES,
): Promise<LimitedJsonResult> {
const text = await request.text();
if (text.length > maxBytes) {
return {
ok: false,
response: NextResponse.json(
{
error: {
code: "payload_too_large",
message: `Request body must be at most ${maxBytes} bytes`,
},
},
{ status: 413 },
),
};
}
try {
return { ok: true, value: JSON.parse(text) as unknown };
} catch {
return {
ok: false,
response: NextResponse.json(
{
error: {
code: "invalid_json",
message: "Invalid JSON",
},
},
{ status: 400 },
),
};
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import type { ZodError } from "zod";
export function jsonFromZodError(error: ZodError): NextResponse {
const issue = error.issues[0];
const message = issue?.message ?? "Validation failed";
return NextResponse.json(
{
error: {
code: "validation_error",
message,
},
details: error.flatten(),
},
{ status: 400 },
);
}