Formalize CreateFlowState + validate draft/publish API payloads
This commit is contained in:
+21
-3
@@ -7,6 +7,24 @@ async function parseJson<T>(response: Response): Promise<T> {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Supports legacy `{ error: string }` and `{ error: { message: string } }` from API routes. */
|
||||
function readApiErrorMessage(data: unknown): string {
|
||||
if (!data || typeof data !== "object" || !("error" in data)) {
|
||||
return "Request failed";
|
||||
}
|
||||
const err = (data as { error: unknown }).error;
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (err && typeof err === "object" && "message" in err) {
|
||||
const m = (err as { message: unknown }).message;
|
||||
if (typeof m === "string") {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return "Request failed";
|
||||
}
|
||||
|
||||
export async function fetchAuthSession(): Promise<{
|
||||
user: { id: string; email: string } | null;
|
||||
}> {
|
||||
@@ -28,7 +46,7 @@ export async function requestOtp(email: string): Promise<{ ok: true } | { error:
|
||||
});
|
||||
const data = await parseJson<{ error?: string }>(res);
|
||||
if (!res.ok) {
|
||||
return { error: data.error ?? "Request failed" };
|
||||
return { error: readApiErrorMessage(data) };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -50,7 +68,7 @@ export async function verifyOtp(
|
||||
user?: { id: string; email: string };
|
||||
}>(res);
|
||||
if (!res.ok || !data.user) {
|
||||
return { error: data.error ?? "Verification failed" };
|
||||
return { error: readApiErrorMessage(data) };
|
||||
}
|
||||
return { ok: true, user: data.user };
|
||||
}
|
||||
@@ -106,7 +124,7 @@ export async function publishRule(input: {
|
||||
rule?: { id: string; title: string };
|
||||
}>(res);
|
||||
if (!res.ok || !data.rule) {
|
||||
return { error: data.error ?? "Publish failed" };
|
||||
return { error: readApiErrorMessage(data) };
|
||||
}
|
||||
return { ok: true, id: data.rule.id, title: data.rule.title };
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 },
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user