Formalize CreateFlowState + validate draft/publish API payloads
This commit is contained in:
+15
-16
@@ -1,8 +1,12 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../../lib/server/db";
|
import { prisma } from "../../../../lib/server/db";
|
||||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||||
import { dbUnavailable } from "../../../../lib/server/responses";
|
import { dbUnavailable } from "../../../../lib/server/responses";
|
||||||
import { getSessionUser } from "../../../../lib/server/session";
|
import { getSessionUser } from "../../../../lib/server/session";
|
||||||
|
import { putDraftBodySchema } from "../../../../lib/server/validation/createFlowSchemas";
|
||||||
|
import { readLimitedJson } from "../../../../lib/server/validation/requestBody";
|
||||||
|
import { jsonFromZodError } from "../../../../lib/server/validation/zodHttp";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
@@ -33,33 +37,28 @@ export async function PUT(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: unknown;
|
const parsedBody = await readLimitedJson(request);
|
||||||
try {
|
if (parsedBody.ok === false) {
|
||||||
body = await request.json();
|
return parsedBody.response;
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!body || typeof body !== "object" || !("payload" in body)) {
|
const validated = putDraftBodySchema.safeParse(parsedBody.value);
|
||||||
return NextResponse.json({ error: "payload required" }, { status: 400 });
|
if (!validated.success) {
|
||||||
|
return jsonFromZodError(validated.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (body as { payload: unknown }).payload;
|
const { payload } = validated.data;
|
||||||
if (payload === undefined || typeof payload !== "object" || payload === null) {
|
|
||||||
return NextResponse.json(
|
const jsonPayload = payload as Prisma.InputJsonValue;
|
||||||
{ error: "payload must be a JSON object" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const draft = await prisma.ruleDraft.upsert({
|
const draft = await prisma.ruleDraft.upsert({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
create: {
|
create: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
payload: payload as object,
|
payload: jsonPayload,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
payload: payload as object,
|
payload: jsonPayload,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+14
-29
@@ -1,8 +1,12 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../lib/server/db";
|
import { prisma } from "../../../lib/server/db";
|
||||||
import { isDatabaseConfigured } from "../../../lib/server/env";
|
import { isDatabaseConfigured } from "../../../lib/server/env";
|
||||||
import { dbUnavailable } from "../../../lib/server/responses";
|
import { dbUnavailable } from "../../../lib/server/responses";
|
||||||
import { getSessionUser } from "../../../lib/server/session";
|
import { getSessionUser } from "../../../lib/server/session";
|
||||||
|
import { publishRuleBodySchema } from "../../../lib/server/validation/createFlowSchemas";
|
||||||
|
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||||
|
import { jsonFromZodError } from "../../../lib/server/validation/zodHttp";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
@@ -37,43 +41,24 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: unknown;
|
const parsedBody = await readLimitedJson(request);
|
||||||
try {
|
if (parsedBody.ok === false) {
|
||||||
body = await request.json();
|
return parsedBody.response;
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!body || typeof body !== "object") {
|
const validated = publishRuleBodySchema.safeParse(parsedBody.value);
|
||||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
if (!validated.success) {
|
||||||
|
return jsonFromZodError(validated.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, summary, document } = body as {
|
const { title, summary, document } = validated.data;
|
||||||
title?: unknown;
|
|
||||||
summary?: unknown;
|
|
||||||
document?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof title !== "string" || title.trim().length === 0) {
|
|
||||||
return NextResponse.json({ error: "title required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document === undefined || typeof document !== "object" || document === null) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "document must be a JSON object" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rule = await prisma.publishedRule.create({
|
const rule = await prisma.publishedRule.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
title: title.trim(),
|
title,
|
||||||
summary:
|
summary,
|
||||||
typeof summary === "string" && summary.trim().length > 0
|
document: document as Prisma.InputJsonValue,
|
||||||
? summary.trim()
|
|
||||||
: null,
|
|
||||||
document: document as object,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -21,11 +21,19 @@ export type CreateFlowStep =
|
|||||||
| "completed";
|
| "completed";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flow state interface for storing user inputs across all steps
|
* Flow state for inputs across create-flow steps.
|
||||||
* Will be expanded in CR-56 with specific field definitions
|
* Validated on `PUT /api/drafts/me` via `createFlowStateSchema` (Zod + JSON safety checks).
|
||||||
|
* Additional string keys are allowed at runtime for forward-compatible step data.
|
||||||
*/
|
*/
|
||||||
export interface CreateFlowState {
|
export interface CreateFlowState {
|
||||||
// Placeholder structure - will be expanded in CR-56
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
currentStep?: CreateFlowStep;
|
||||||
|
/** Section drafts; structure will tighten as steps persist real shapes. */
|
||||||
|
sections?: Record<string, unknown>[];
|
||||||
|
/** Stakeholder placeholders until the confirm-stakeholders step defines a schema. */
|
||||||
|
stakeholders?: Record<string, unknown>[];
|
||||||
|
/** Extra step-specific fields (must be JSON-serializable for server draft sync). */
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,11 +101,13 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
|||||||
|
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
|
|
||||||
- [ ] TypeScript reflects the real shape of `CreateFlowState` (no unnecessary `unknown` for known keys).
|
- [x] TypeScript reflects the real shape of `CreateFlowState` (no unnecessary `unknown` for known keys).
|
||||||
- [ ] Invalid draft/publish requests return 400, not 500.
|
- [x] Invalid draft/publish requests return 400, not 500.
|
||||||
- [ ] Unit tests for schemas (Vitest) or route tests with MSW.
|
- [x] Unit tests for schemas (Vitest) or route tests with MSW.
|
||||||
|
|
||||||
**Files:** [app/create/types.ts](app/create/types.ts), [app/api/drafts/me/route.ts](app/api/drafts/me/route.ts), [app/api/rules/route.ts](app/api/rules/route.ts), new `lib/server/validation/` or `lib/validation/createFlow.ts`, [package.json](package.json) if adding `zod`.
|
**Status:** [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) **Done**.
|
||||||
|
|
||||||
|
**Files:** [app/create/types.ts](app/create/types.ts), [app/api/drafts/me/route.ts](app/api/drafts/me/route.ts), [app/api/rules/route.ts](app/api/rules/route.ts), [lib/server/validation/](lib/server/validation/) (Zod + plain-JSON checks), [package.json](package.json) (`zod`).
|
||||||
|
|
||||||
**Note:** Repo-wide **API error JSON shape** and **request-id logging** are **Ticket 13 / CR-84**—coordinate 400 response bodies with that issue so validation errors match the agreed `{ error: { code, message } }` pattern.
|
**Note:** Repo-wide **API error JSON shape** and **request-id logging** are **Ticket 13 / CR-84**—coordinate 400 response bodies with that issue so validation errors match the agreed `{ error: { code, message } }` pattern.
|
||||||
|
|
||||||
@@ -175,15 +177,16 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
|
|||||||
|
|
||||||
1. **Hydration:** Show a non-blocking “Loading your saved progress…” until first session + draft fetch completes (only when sync enabled).
|
1. **Hydration:** Show a non-blocking “Loading your saved progress…” until first session + draft fetch completes (only when sync enabled).
|
||||||
2. **Conflict:** If `localStorage` has non-empty state and server returns non-empty draft, pick a policy: prefer server with confirm modal, or prefer newer `updatedAt` (requires storing timestamp client-side). Document choice in code comment.
|
2. **Conflict:** If `localStorage` has non-empty state and server returns non-empty draft, pick a policy: prefer server with confirm modal, or prefer newer `updatedAt` (requires storing timestamp client-side). Document choice in code comment.
|
||||||
3. **Save failures:** If `PUT /api/drafts/me` fails, show toast/banner; optionally retry with backoff.
|
3. **Save failures (API surface):** Change [saveDraftToServer](lib/create/api.ts) from `Promise<boolean>` to a result type such as `{ ok: true } | { ok: false; message: string; status?: number }`, parsing the response body with [readApiErrorMessage](lib/create/api.ts) so both legacy `{ error: string }` and CR-73 validation `{ error: { message } }` (and 413 `payload_too_large`) produce a useful `message`. Update [CreateFlowBackendSync](app/create/context/CreateFlowBackendSync.tsx) to branch on that result.
|
||||||
4. **Tests:** Component test or Playwright scenario with sync flag on (may require test DB or route mocks).
|
4. **Save failures (UX):** On `ok: false`, show toast/banner (include `message`); optionally retry with backoff.
|
||||||
|
5. **Tests:** Component test or Playwright scenario with sync flag on (may require test DB or route mocks).
|
||||||
|
|
||||||
**Acceptance criteria:**
|
**Acceptance criteria:**
|
||||||
|
|
||||||
- [ ] No silent data loss when server save fails.
|
- [ ] No silent data loss when server save fails.
|
||||||
- [ ] User understands when server draft replaced local state (if applicable).
|
- [ ] User understands when server draft replaced local state (if applicable).
|
||||||
|
|
||||||
**Files:** [app/create/context/CreateFlowBackendSync.tsx](app/create/context/CreateFlowBackendSync.tsx), possibly [CreateFlowContext](app/create/context/CreateFlowContext.tsx), tests under `tests/`.
|
**Files:** [lib/create/api.ts](lib/create/api.ts), [app/create/context/CreateFlowBackendSync.tsx](app/create/context/CreateFlowBackendSync.tsx), possibly [CreateFlowContext](app/create/context/CreateFlowContext.tsx), tests under `tests/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+21
-3
@@ -7,6 +7,24 @@ async function parseJson<T>(response: Response): Promise<T> {
|
|||||||
return data;
|
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<{
|
export async function fetchAuthSession(): Promise<{
|
||||||
user: { id: string; email: string } | null;
|
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);
|
const data = await parseJson<{ error?: string }>(res);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { error: data.error ?? "Request failed" };
|
return { error: readApiErrorMessage(data) };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -50,7 +68,7 @@ export async function verifyOtp(
|
|||||||
user?: { id: string; email: string };
|
user?: { id: string; email: string };
|
||||||
}>(res);
|
}>(res);
|
||||||
if (!res.ok || !data.user) {
|
if (!res.ok || !data.user) {
|
||||||
return { error: data.error ?? "Verification failed" };
|
return { error: readApiErrorMessage(data) };
|
||||||
}
|
}
|
||||||
return { ok: true, user: data.user };
|
return { ok: true, user: data.user };
|
||||||
}
|
}
|
||||||
@@ -106,7 +124,7 @@ export async function publishRule(input: {
|
|||||||
rule?: { id: string; title: string };
|
rule?: { id: string; title: string };
|
||||||
}>(res);
|
}>(res);
|
||||||
if (!res.ok || !data.rule) {
|
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 };
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -20,7 +20,8 @@
|
|||||||
"next-intl": "^3.26.5",
|
"next-intl": "^3.26.5",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.10.2",
|
"@axe-core/playwright": "^4.10.2",
|
||||||
@@ -23129,7 +23130,6 @@
|
|||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
+3
-2
@@ -49,15 +49,16 @@
|
|||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@next/mdx": "^16.0.0",
|
"@next/mdx": "^16.0.0",
|
||||||
|
"@prisma/client": "^6.19.0",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
"critters": "^0.0.23",
|
"critters": "^0.0.23",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"next-intl": "^3.26.5",
|
"next-intl": "^3.26.5",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"@prisma/client": "^6.19.0",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.10.2",
|
"@axe-core/playwright": "^4.10.2",
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
assertPlainJsonValue,
|
||||||
|
DEFAULT_PLAIN_JSON_LIMITS,
|
||||||
|
} from "../../lib/server/validation/plainJson";
|
||||||
|
import {
|
||||||
|
createFlowStateSchema,
|
||||||
|
publishRuleBodySchema,
|
||||||
|
putDraftBodySchema,
|
||||||
|
} from "../../lib/server/validation/createFlowSchemas";
|
||||||
|
|
||||||
|
describe("assertPlainJsonValue", () => {
|
||||||
|
it("accepts plain JSON structures", () => {
|
||||||
|
expect(
|
||||||
|
assertPlainJsonValue(
|
||||||
|
{ a: [1, "x", { b: null }], c: true },
|
||||||
|
0,
|
||||||
|
DEFAULT_PLAIN_JSON_LIMITS,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects __proto__ keys", () => {
|
||||||
|
const obj = JSON.parse('{"__proto__": {"x": 1}}') as Record<string, unknown>;
|
||||||
|
expect(assertPlainJsonValue(obj, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
|
||||||
|
"Unsafe object key",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-finite numbers", () => {
|
||||||
|
expect(assertPlainJsonValue(Number.NaN, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
|
||||||
|
"Invalid number value",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects excessive depth", () => {
|
||||||
|
let v: unknown = 1;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
v = { x: v };
|
||||||
|
}
|
||||||
|
expect(assertPlainJsonValue(v, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
|
||||||
|
"Maximum nesting depth exceeded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createFlowStateSchema", () => {
|
||||||
|
it("accepts empty object", () => {
|
||||||
|
const r = createFlowStateSchema.safeParse({});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts known fields and passthrough keys", () => {
|
||||||
|
const r = createFlowStateSchema.safeParse({
|
||||||
|
title: "My rule",
|
||||||
|
currentStep: "cards",
|
||||||
|
customField: { nested: [1, 2] },
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid currentStep", () => {
|
||||||
|
const r = createFlowStateSchema.safeParse({ currentStep: "not-a-step" });
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects title that is too long", () => {
|
||||||
|
const r = createFlowStateSchema.safeParse({ title: "x".repeat(600) });
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("putDraftBodySchema", () => {
|
||||||
|
it("requires payload object", () => {
|
||||||
|
expect(putDraftBodySchema.safeParse({}).success).toBe(false);
|
||||||
|
expect(putDraftBodySchema.safeParse({ payload: {} }).success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("publishRuleBodySchema", () => {
|
||||||
|
it("accepts minimal valid body", () => {
|
||||||
|
const r = publishRuleBodySchema.safeParse({
|
||||||
|
title: " Hello ",
|
||||||
|
document: { body: "text" },
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
if (r.success) {
|
||||||
|
expect(r.data.title).toBe("Hello");
|
||||||
|
expect(r.data.summary).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims summary and maps empty to null", () => {
|
||||||
|
const r = publishRuleBodySchema.safeParse({
|
||||||
|
title: "T",
|
||||||
|
summary: " ",
|
||||||
|
document: {},
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
if (r.success) {
|
||||||
|
expect(r.data.summary).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty title", () => {
|
||||||
|
const r = publishRuleBodySchema.safeParse({
|
||||||
|
title: " ",
|
||||||
|
document: {},
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-object document", () => {
|
||||||
|
const r = publishRuleBodySchema.safeParse({
|
||||||
|
title: "Ok",
|
||||||
|
document: "nope",
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user