From bb27f506d7bc3a12a363cbcb5012359eca2c670f Mon Sep 17 00:00:00 2001 From: aqib-rx Date: Mon, 1 Jun 2026 17:14:21 +0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(schema):=20add=20mutateManifest=20(Lay?= =?UTF-8?q?er-4=20validated)=20(D3=20=C2=A74.12.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domains/schema/ui-schema/mutate.test.ts | 88 +++++++++++++++++++ .../src/domains/schema/ui-schema/mutate.ts | 72 +++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 packages/nextly/src/domains/schema/ui-schema/mutate.test.ts create mode 100644 packages/nextly/src/domains/schema/ui-schema/mutate.ts diff --git a/packages/nextly/src/domains/schema/ui-schema/mutate.test.ts b/packages/nextly/src/domains/schema/ui-schema/mutate.test.ts new file mode 100644 index 0000000..ca54ad9 --- /dev/null +++ b/packages/nextly/src/domains/schema/ui-schema/mutate.test.ts @@ -0,0 +1,88 @@ +/** + * @module domains/schema/ui-schema/mutate.test + * @since v0.0.3-alpha (Plan D3) + */ +import { describe, expect, it } from "vitest"; + +import { uiSchemaManifest } from "../../../schemas/_zod/ui-schema"; + +import { mutateManifest } from "./mutate"; + +const base = uiSchemaManifest.parse({ + collections: [{ slug: "events", fields: [{ name: "title", type: "text" }] }], +}); + +describe("mutateManifest", () => { + it("upserts a new collection (append)", () => { + const next = mutateManifest(base, { + type: "upsert", + kind: "collections", + entity: { slug: "venues", fields: [{ name: "name", type: "text" }] }, + }); + expect(next.collections.map(c => c.slug)).toEqual(["events", "venues"]); + }); + + it("upserts an existing collection (replace by slug)", () => { + const next = mutateManifest(base, { + type: "upsert", + kind: "collections", + entity: { + slug: "events", + fields: [ + { name: "title", type: "text" }, + { name: "venue", type: "text" }, + ], + }, + }); + expect(next.collections).toHaveLength(1); + expect(next.collections[0].fields.map(f => f.name)).toEqual([ + "title", + "venue", + ]); + }); + + it("deletes a collection by slug", () => { + const next = mutateManifest(base, { + type: "delete", + kind: "collections", + slug: "events", + }); + expect(next.collections).toEqual([]); + }); + + it("delete is idempotent when the slug is absent", () => { + const next = mutateManifest(base, { + type: "delete", + kind: "collections", + slug: "ghost", + }); + expect(next.collections.map(c => c.slug)).toEqual(["events"]); + }); + + it("throws NEXTLY_UI_SCHEMA_INVALID when the upsert is invalid", () => { + expect(() => + mutateManifest(base, { + type: "upsert", + kind: "collections", + entity: { slug: "Bad Slug", fields: [] }, + }) + ).toThrowError( + expect.objectContaining({ code: "NEXTLY_UI_SCHEMA_INVALID" }) + ); + }); + + it("upserts a single and a component", () => { + const withSingle = mutateManifest(base, { + type: "upsert", + kind: "singles", + entity: { slug: "home", fields: [{ name: "hero", type: "text" }] }, + }); + expect(withSingle.singles.map(s => s.slug)).toEqual(["home"]); + const withComponent = mutateManifest(withSingle, { + type: "upsert", + kind: "components", + entity: { slug: "seo", fields: [{ name: "meta_title", type: "text" }] }, + }); + expect(withComponent.components.map(c => c.slug)).toEqual(["seo"]); + }); +}); diff --git a/packages/nextly/src/domains/schema/ui-schema/mutate.ts b/packages/nextly/src/domains/schema/ui-schema/mutate.ts new file mode 100644 index 0000000..97a6e2c --- /dev/null +++ b/packages/nextly/src/domains/schema/ui-schema/mutate.ts @@ -0,0 +1,72 @@ +/** + * In-memory `ui-schema.json` mutations for the dev write API (spec §4.12.3). + * + * `mutateManifest` applies one upsert/delete by slug and re-validates the whole + * manifest through the shared Zod schema (validation Layer 4) — throwing + * NEXTLY_UI_SCHEMA_INVALID before the caller writes anything. Pure: no HTTP, no + * filesystem. The HTTP handler (route-handler/dev-schema-handler.ts) loads the + * current manifest, calls this, then serializes + writes the result. + * + * @module domains/schema/ui-schema/mutate + * @since v0.0.3-alpha (Plan D3) + */ +import { NextlyError } from "../../../errors"; +import { + parseUiSchema, + type UiSchemaManifest, +} from "../../../schemas/_zod/ui-schema"; + +export type ManifestKind = "collections" | "singles" | "components"; + +export type ManifestMutation = + | { type: "upsert"; kind: ManifestKind; entity: unknown } + | { type: "delete"; kind: ManifestKind; slug: string }; + +function slugOf(entity: unknown): string | undefined { + const s = (entity as { slug?: unknown }).slug; + return typeof s === "string" ? s : undefined; +} + +/** + * Apply a mutation to a validated manifest and re-validate the result. + * Throws NEXTLY_UI_SCHEMA_INVALID (400) when the change would make the file + * invalid — the caller then leaves the file untouched. + */ +export function mutateManifest( + current: UiSchemaManifest, + mutation: ManifestMutation +): UiSchemaManifest { + const draft: Record = { + $schema: current.$schema, + version: current.version, + collections: [...current.collections], + singles: [...current.singles], + components: [...current.components], + }; + + const list = [...(draft[mutation.kind] as unknown[])]; + if (mutation.type === "upsert") { + const slug = slugOf(mutation.entity); + const idx = + slug === undefined ? -1 : list.findIndex(e => slugOf(e) === slug); + if (idx >= 0) list[idx] = mutation.entity; + else list.push(mutation.entity); + } else { + const filtered = list.filter(e => slugOf(e) !== mutation.slug); + list.length = 0; + list.push(...filtered); + } + draft[mutation.kind] = list; + + const result = parseUiSchema(draft); + if (!result.success) { + const issues = result.error.issues + .map(i => `${i.path.join(".") || "(root)"}: ${i.message}`) + .join("; "); + throw new NextlyError({ + code: "NEXTLY_UI_SCHEMA_INVALID", + publicMessage: `ui-schema change rejected: ${issues}`, + }); + } + return result.data; +} From 1ee2ce7a7211406b5dbf7283954fda92f947d269 Mon Sep 17 00:00:00 2001 From: aqib-rx Date: Mon, 1 Jun 2026 17:16:38 +0500 Subject: [PATCH 2/3] =?UTF-8?q?feat(route-handler):=20add=20dev-only=20ui-?= =?UTF-8?q?schema=20write=20API=20(D3=20=C2=A74.12.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../route-handler/dev-schema-handler.test.ts | 120 ++++++++++++++++++ .../src/route-handler/dev-schema-handler.ts | 105 +++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 packages/nextly/src/route-handler/dev-schema-handler.test.ts create mode 100644 packages/nextly/src/route-handler/dev-schema-handler.ts diff --git a/packages/nextly/src/route-handler/dev-schema-handler.test.ts b/packages/nextly/src/route-handler/dev-schema-handler.test.ts new file mode 100644 index 0000000..eecda9c --- /dev/null +++ b/packages/nextly/src/route-handler/dev-schema-handler.test.ts @@ -0,0 +1,120 @@ +/** + * @module route-handler/dev-schema-handler.test + * @since v0.0.3-alpha (Plan D3) + */ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { setHandlerConfig } from "./auth-handler"; +import { handleDevSchemaRequest } from "./dev-schema-handler"; + +let dir: string; +const ORIGINAL_ENV = process.env.NODE_ENV; + +function req(method: string, body?: unknown): Request { + return new Request("http://x/admin/api/_dev/schema", { + method, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "dev-schema-")); + vi.spyOn(process, "cwd").mockReturnValue(dir); + process.env.NODE_ENV = "development"; + setHandlerConfig({ + db: { uiSchemaFile: "./ui-schema.json" }, + } as unknown as Parameters[0]); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.env.NODE_ENV = ORIGINAL_ENV; +}); + +describe("handleDevSchemaRequest", () => { + it("POST collection writes the manifest and returns 200", async () => { + const res = await handleDevSchemaRequest( + req("POST", { + slug: "events", + fields: [{ name: "title", type: "text" }], + }), + ["_dev", "schema", "collection"], + "POST" + ); + expect(res.status).toBe(200); + const written = JSON.parse( + await readFile(join(dir, "ui-schema.json"), "utf-8") + ); + expect(written.collections[0].slug).toBe("events"); + }); + + it("rejects an invalid entity with 400 and leaves the file untouched", async () => { + await writeFile( + join(dir, "ui-schema.json"), + JSON.stringify({ + version: 1, + collections: [], + singles: [], + components: [], + }) + ); + const before = await readFile(join(dir, "ui-schema.json"), "utf-8"); + const res = await handleDevSchemaRequest( + req("POST", { slug: "Bad Slug", fields: [] }), + ["_dev", "schema", "collection"], + "POST" + ); + expect(res.status).toBe(400); + expect(await readFile(join(dir, "ui-schema.json"), "utf-8")).toBe(before); + }); + + it("DELETE removes a collection by slug", async () => { + await writeFile( + join(dir, "ui-schema.json"), + JSON.stringify({ + version: 1, + collections: [ + { slug: "events", fields: [{ name: "title", type: "text" }] }, + ], + singles: [], + components: [], + }) + ); + const res = await handleDevSchemaRequest( + req("DELETE"), + ["_dev", "schema", "collection", "events"], + "DELETE" + ); + expect(res.status).toBe(200); + const written = JSON.parse( + await readFile(join(dir, "ui-schema.json"), "utf-8") + ); + expect(written.collections).toEqual([]); + }); + + it("404s an unknown kind", async () => { + const res = await handleDevSchemaRequest( + req("POST", {}), + ["_dev", "schema", "widget"], + "POST" + ); + expect(res.status).toBe(404); + }); + + it("404s when NODE_ENV is not development (defense in depth)", async () => { + process.env.NODE_ENV = "production"; + const res = await handleDevSchemaRequest( + req("POST", { + slug: "events", + fields: [{ name: "title", type: "text" }], + }), + ["_dev", "schema", "collection"], + "POST" + ); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/nextly/src/route-handler/dev-schema-handler.ts b/packages/nextly/src/route-handler/dev-schema-handler.ts new file mode 100644 index 0000000..e91a436 --- /dev/null +++ b/packages/nextly/src/route-handler/dev-schema-handler.ts @@ -0,0 +1,105 @@ +/** + * Dev-only write API for `ui-schema.json` (spec §4.12.3). + * + * Endpoints (mounted only when NODE_ENV === "development"; see routeHandler): + * POST /admin/api/_dev/schema/collection upsert a collection + * DELETE /admin/api/_dev/schema/collection/:slug remove a collection + * POST /admin/api/_dev/schema/single upsert a single + * DELETE /admin/api/_dev/schema/single/:slug remove a single + * POST /admin/api/_dev/schema/component upsert a component + * DELETE /admin/api/_dev/schema/component/:slug remove a component + * + * Each write loads the current manifest, applies the change, re-validates the + * whole manifest (Layer 4 — `mutateManifest` throws NEXTLY_UI_SCHEMA_INVALID on + * failure), then serializes via the deterministic writer and writes the file. + * + * @module route-handler/dev-schema-handler + * @since v0.0.3-alpha (Plan D3) + */ +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { respondAction } from "../api/response-shapes"; +import { withErrorHandler } from "../api/with-error-handler"; +import { serializeUiSchema } from "../cli/utils/ui-schema-writer"; +import { loadUiSchema } from "../domains/schema/ui-schema/loader"; +import { + mutateManifest, + type ManifestKind, +} from "../domains/schema/ui-schema/mutate"; +import { NextlyError } from "../errors"; + +import { getHandlerConfig } from "./auth-handler"; + +const KIND_BY_SEGMENT: Record = { + collection: "collections", + single: "singles", + component: "components", +}; + +function notFound(): Response { + return new Response(JSON.stringify({ error: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); +} + +/** + * Handle `/admin/api/_dev/schema/*`. `params` is the route-handler segment + * array, e.g. `["_dev", "schema", "collection", "events"]`. + */ +export const handleDevSchemaRequest = withErrorHandler( + async ( + req: Request, + params: string[], + method: "POST" | "DELETE" + ): Promise => { + // Defense in depth — the routeHandler branch already gates on NODE_ENV. + if (process.env.NODE_ENV !== "development") return notFound(); + if (params[1] !== "schema") return notFound(); + + const kind = KIND_BY_SEGMENT[params[2] ?? ""]; + if (!kind) return notFound(); + + const config = getHandlerConfig(); + if (!config) { + throw new NextlyError({ + code: "INTERNAL_ERROR", + publicMessage: "Nextly config not initialized.", + }); + } + const projectRoot = process.cwd(); + const uiSchemaFile = config.db.uiSchemaFile; + const current = await loadUiSchema({ projectRoot, uiSchemaFile }); + + let next; + if (method === "POST" && params[3] === undefined) { + let body: unknown; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + throw new NextlyError({ + code: "VALIDATION_ERROR", + publicMessage: "Invalid JSON in request body.", + }); + } + next = mutateManifest(current, { type: "upsert", kind, entity: body }); + } else if (method === "DELETE" && params[3] !== undefined) { + next = mutateManifest(current, { + type: "delete", + kind, + slug: params[3], + }); + } else { + return notFound(); + } + + await writeFile( + resolve(projectRoot, uiSchemaFile), + serializeUiSchema(next), + "utf-8" + ); + return respondAction("ui-schema updated", { kind }); + } +); From 05d9c297822f3bc6ab14ffdb7f24e19e7c709815 Mon Sep 17 00:00:00 2001 From: aqib-rx Date: Mon, 1 Jun 2026 17:18:22 +0500 Subject: [PATCH 3/3] =?UTF-8?q?feat(route-handler):=20dispatch=20/admin/ap?= =?UTF-8?q?i/=5Fdev/schema=20in=20dev=20(D3=20=C2=A74.12.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/nextly/src/routeHandler.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/nextly/src/routeHandler.ts b/packages/nextly/src/routeHandler.ts index 67bb274..0e0381c 100644 --- a/packages/nextly/src/routeHandler.ts +++ b/packages/nextly/src/routeHandler.ts @@ -76,6 +76,7 @@ import { setHandlerConfig, getHandlerConfig, } from "./route-handler"; +import { handleDevSchemaRequest } from "./route-handler/dev-schema-handler"; import type { CollectionsHandler } from "./services/collections-handler"; import type { GeneralSettingsService } from "./services/general-settings/general-settings-service"; import { @@ -823,11 +824,7 @@ async function handleServiceRequest( // beyond `PATCH ... 500`, making 5xx triage essentially impossible. // Skip logging for benign expected errors (NOT_FOUND, RATE_LIMITED, // AUTH_REQUIRED) so the log doesn't get flooded by unauth probes. - const benignCodes = new Set([ - "NOT_FOUND", - "RATE_LIMITED", - "AUTH_REQUIRED", - ]); + const benignCodes = new Set(["NOT_FOUND", "RATE_LIMITED", "AUTH_REQUIRED"]); if (!benignCodes.has(String(nextlyErr.code))) { try { // Lazy-import the logger to avoid pulling it onto the cold path @@ -1120,6 +1117,9 @@ async function handleGet(req: Request, params: string[]) { } async function handlePost(req: Request, params: string[]) { + if (params[0] === "_dev" && process.env.NODE_ENV === "development") { + return handleDevSchemaRequest(req, params, "POST"); + } return handleServiceRequest(req, params, "POST"); } @@ -1135,6 +1135,9 @@ async function handlePatch(req: Request, params: string[]) { } async function handleDelete(req: Request, params: string[]) { + if (params[0] === "_dev" && process.env.NODE_ENV === "development") { + return handleDevSchemaRequest(req, params, "DELETE"); + } return handleServiceRequest(req, params, "DELETE"); } @@ -1204,8 +1207,7 @@ export function createDynamicHandlers(options?: { ...(rateLimitConfig ?? {}), trustProxy: rateLimitConfig?.trustProxy ?? securityConfig?.trustProxy ?? false, - trustedProxyIps: - rateLimitConfig?.trustedProxyIps ?? trustedProxyIpsFromEnv, + trustedProxyIps: rateLimitConfig?.trustedProxyIps ?? trustedProxyIpsFromEnv, }); /** @@ -1377,7 +1379,6 @@ export function getCollectionsHandler(): CollectionsHandler | undefined { return undefined; } - export const _handleAdminMetaRequestForTest = handleAdminMetaRequest; export const _handleAdminMetaSidebarGroupsForTest = handleAdminMetaSidebarGroups;