;base64,`
+ * prefix and embedded whitespace — models send both. Throws on invalid base64.
+ */
+export function decodeBase64Image(input: string): Uint8Array {
+ const normalized = input.replace(/^data:[^,]*;base64,/, "").replace(/\s+/g, "");
+ const binary = atob(normalized);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
diff --git a/cod-server/src/lib/tool-output-schema.ts b/cod-server/src/lib/tool-output-schema.ts
new file mode 100644
index 0000000..a9d6cea
--- /dev/null
+++ b/cod-server/src/lib/tool-output-schema.ts
@@ -0,0 +1,36 @@
+/**
+ * Output-schema envelope for MCP tools.
+ *
+ * Every tool in every ai-tools domain returns one of exactly two shapes:
+ * • { success: true, ...payload } — the call's data
+ * • { success: false, error } — a handled failure the model can act on
+ *
+ * toolOutput() builds the Zod union describing both, so the advertised
+ * outputSchema (tools/list) and the structuredContent validation (tools/call,
+ * see src/mcp/execute-tool.ts) share one definition — same drift-proof
+ * invariant as the input schemas.
+ *
+ * Payload objects are LOOSE (additionalProperties allowed): declared fields
+ * are validated and documented for the model; undeclared fields pass through
+ * instead of failing the whole result. Entity passthroughs (DB rows) should
+ * declare only fields whose presence is guaranteed; uncertain fields stay
+ * undeclared or optional so a row-shape change can never silently strip a
+ * tool's structured output.
+ */
+import { z } from "zod";
+
+export function toolOutput(payload: P) {
+ return z.union([
+ z.looseObject({
+ success: z.literal(true).describe("The call succeeded"),
+ ...payload,
+ }),
+ z.looseObject({
+ success: z.literal(false).describe("The call failed"),
+ error: z.string().describe("What went wrong and how to recover — fix the inputs and retry when possible"),
+ }),
+ ]);
+}
+
+/** ISO-8601 timestamp string (all dates in tool results are stored as such). */
+export const timestampSchema = z.string().describe("ISO-8601 timestamp");
diff --git a/cod-server/src/types/env.ts b/cod-server/src/types/env.ts
index 8323369..41dc376 100644
--- a/cod-server/src/types/env.ts
+++ b/cod-server/src/types/env.ts
@@ -47,12 +47,6 @@ export interface Env {
* being accepted against tenant B.
*/
WORKER_SELF_URL: string;
- /**
- * HMAC key (>= 32 bytes) sealing the MCP `requestState` used by tool
- * confirmation. Optional: when missing or too short, dangerous MCP tools fail
- * closed. Set via `wrangler secret put` in production, `.dev.vars` locally.
- */
- MCP_REQUEST_STATE_KEY?: string;
/**
* HMAC secret (>= 32 bytes) shared with the Astro dashboard for the MCP OAuth
* login tickets minted after dashboard sign-in. Optional: when missing or too
@@ -74,4 +68,9 @@ export interface Env {
* Fires CAPI Purchase events at order delivery — decoupled from status handler.
*/
CAPI_WORKFLOW: Workflow;
+ /**
+ * Cloudflare Workflow binding for CodLandingPageImageUploadWorkflow.
+ * Durable background upload of MCP-agent images into the landing page stack.
+ */
+ LP_IMAGE_UPLOAD_WORKFLOW: Workflow;
}
diff --git a/cod-server/src/workflows/landing-page-image-upload.test.ts b/cod-server/src/workflows/landing-page-image-upload.test.ts
new file mode 100644
index 0000000..edf3803
--- /dev/null
+++ b/cod-server/src/workflows/landing-page-image-upload.test.ts
@@ -0,0 +1,518 @@
+/**
+ * CodLandingPageImageUploadWorkflow — payload validation and full run()
+ * sequencing.
+ *
+ * The fake step executor runs each callback immediately (no caching) and
+ * records step names in order; the environment (R2 bucket, fetch, D1 via
+ * mocked modules) is injected through the test-stub WorkflowEntrypoint
+ * constructor. Sniffing/dimension parsing run for real.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+const sharedQueries = vi.hoisted(() => ({
+ getLandingPageById: vi.fn(),
+ getLandingPageImages: vi.fn(),
+ addLandingPageImage: vi.fn(),
+}));
+
+vi.mock("../../../cod-shared/queries/landing-pages", () => sharedQueries);
+
+const activity = vi.hoisted(() => ({
+ logActivity: vi.fn(async () => {}),
+ ACTIONS: { LANDING_PAGE_UPDATED: "landing_page.updated" },
+}));
+
+vi.mock("@/lib/activity", () => activity);
+
+vi.mock("@/db", () => ({ getDb: () => ({} as never) }));
+
+import {
+ CodLandingPageImageUploadParamsSchema,
+ CodLandingPageImageUploadWorkflow,
+} from "./landing-page-image-upload";
+import { NonRetryableError } from "cloudflare:workflows";
+
+/** Real 1x1 px PNG. */
+const REAL_1X1_PNG = new Uint8Array(
+ Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
+ "base64",
+ ),
+);
+
+const UUID = "6f0c9b8e-3d2a-4e5f-8a7b-9c1d2e3f4a5b";
+const KEY_HEX = "a".repeat(32);
+const R2_KEY = `landing/${KEY_HEX}.png`;
+
+function urlPayload(overrides: Record = {}) {
+ return {
+ kind: "url",
+ landingPageId: UUID,
+ r2Key: R2_KEY,
+ imageUrl: "https://images.openai.example/generated.png",
+ contentType: "image/png",
+ altText: "Hero shot",
+ actor: { id: "user-1", name: "Ada", role: "staff" },
+ ...overrides,
+ };
+}
+
+function bytesPayload(overrides: Record = {}) {
+ return {
+ kind: "bytes",
+ landingPageId: UUID,
+ r2Key: R2_KEY,
+ contentType: "image/png",
+ actor: { id: "user-1", name: "Ada", role: "staff" },
+ ...overrides,
+ };
+}
+
+const STACK_ROW = {
+ id: "img-row-1",
+ landingPageId: UUID,
+ r2Key: R2_KEY,
+ src: `https://media.example.com/${R2_KEY}`,
+ altText: null,
+ source: "ai",
+ position: 1,
+ width: 1,
+ height: 1,
+ createdAt: "2026-01-01T00:00:00.000Z",
+};
+
+const LANDING_PAGE = { id: UUID, slug: "lp-abc12345", name: "Zinc page", images: [] };
+
+interface FakeStepCall {
+ name: string;
+ config: unknown;
+}
+
+function makeFakeStep() {
+ const calls: FakeStepCall[] = [];
+ const step = {
+ calls,
+ async do(name: string, configOrCallback: unknown, maybeCallback?: unknown) {
+ const callback =
+ typeof configOrCallback === "function" ? configOrCallback : maybeCallback;
+ const config = typeof configOrCallback === "function" ? undefined : configOrCallback;
+ calls.push({ name, config });
+ return (callback as () => unknown)();
+ },
+ };
+ return step;
+}
+
+function makeWorkflow(envOverrides: Record = {}) {
+ const put = vi.fn(async () => undefined);
+ const get = vi.fn(
+ async (): Promise<{ arrayBuffer: () => Promise } | null> => ({
+ arrayBuffer: async () => REAL_1X1_PNG.buffer.slice(0) as ArrayBuffer,
+ }),
+ );
+ const env = {
+ IMAGES: { put, get },
+ MEDIA_DOMAIN: "media.example.com",
+ DB: {} as never,
+ ...envOverrides,
+ };
+ const workflow = new (CodLandingPageImageUploadWorkflow as unknown as new (
+ ctx: unknown,
+ env: unknown,
+ ) => InstanceType)({}, env);
+ return { workflow, put, get, env };
+}
+
+function okFetch(body: Uint8Array, headers: Record = {}) {
+ return async () =>
+ new Response(body as unknown as BodyInit, { status: 200, headers });
+}
+
+describe("CodLandingPageImageUploadParamsSchema", () => {
+ it("accepts a valid url payload", () => {
+ expect(CodLandingPageImageUploadParamsSchema.safeParse(urlPayload()).success).toBe(true);
+ });
+
+ it("accepts a valid bytes payload", () => {
+ expect(CodLandingPageImageUploadParamsSchema.safeParse(bytesPayload()).success).toBe(true);
+ });
+
+ it("rejects a url payload missing imageUrl", () => {
+ const { imageUrl: _omitted, ...withoutUrl } = urlPayload();
+ expect(CodLandingPageImageUploadParamsSchema.safeParse(withoutUrl).success).toBe(false);
+ });
+
+ it("rejects unknown fields", () => {
+ expect(CodLandingPageImageUploadParamsSchema.safeParse(urlPayload({ force: true })).success).toBe(false);
+ });
+
+ it("rejects keys outside the landing/ namespace or with traversal", () => {
+ expect(
+ CodLandingPageImageUploadParamsSchema.safeParse(urlPayload({ r2Key: "../etc/passwd" })).success,
+ ).toBe(false);
+ expect(
+ CodLandingPageImageUploadParamsSchema.safeParse(
+ urlPayload({ r2Key: `products/${KEY_HEX}.png` }),
+ ).success,
+ ).toBe(false);
+ });
+
+ it("rejects content types outside the platform whitelist", () => {
+ expect(
+ CodLandingPageImageUploadParamsSchema.safeParse(urlPayload({ contentType: "image/svg+xml" })).success,
+ ).toBe(false);
+ });
+
+ it("rejects malformed urls and actors", () => {
+ expect(CodLandingPageImageUploadParamsSchema.safeParse(urlPayload({ imageUrl: "not-a-url" })).success).toBe(false);
+ expect(
+ CodLandingPageImageUploadParamsSchema.safeParse(
+ urlPayload({ actor: { id: "u", role: "superadmin" } }),
+ ).success,
+ ).toBe(false);
+ });
+});
+
+describe("CodLandingPageImageUploadWorkflow.run — url path", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(okFetch(REAL_1X1_PNG, { "content-type": "image/png", "content-length": "70" })),
+ );
+ sharedQueries.getLandingPageById.mockResolvedValue(LANDING_PAGE);
+ sharedQueries.getLandingPageImages.mockResolvedValue([]);
+ sharedQueries.addLandingPageImage.mockResolvedValue([STACK_ROW]);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("fetches, stores to R2 with immutable metadata, inserts an ai-source row, and audits", async () => {
+ const { workflow, put } = makeWorkflow();
+ const step = makeFakeStep();
+
+ const result = await workflow.run(
+ { payload: urlPayload(), instanceId: "lpimg-test", timestamp: new Date() } as never,
+ step as never,
+ );
+
+ expect(put).toHaveBeenCalledWith(
+ R2_KEY,
+ expect.any(Uint8Array),
+ {
+ httpMetadata: {
+ contentType: "image/png",
+ cacheControl: "public, max-age=31536000, immutable",
+ },
+ customMetadata: { source: "ai", uploadedAt: expect.any(String) },
+ },
+ );
+
+ expect(sharedQueries.addLandingPageImage).toHaveBeenCalledWith(
+ expect.anything(),
+ UUID,
+ expect.objectContaining({ r2Key: R2_KEY, src: `https://media.example.com/${R2_KEY}`, source: "ai" }),
+ );
+
+ expect(activity.logActivity).toHaveBeenCalledWith(
+ expect.anything(),
+ { id: "user-1", name: "Ada", role: "staff" },
+ "landing_page.updated",
+ { type: "landing_page", id: UUID, label: "Zinc page" },
+ expect.objectContaining({ via: "mcp", action: "image_added", source: "ai", uploadJobId: "lpimg-test" }),
+ );
+
+ expect(result).toEqual({
+ imageId: "img-row-1",
+ r2Key: R2_KEY,
+ src: `https://media.example.com/${R2_KEY}`,
+ position: 1,
+ width: 1,
+ height: 1,
+ altText: null,
+ });
+
+ expect(step.calls.map((c) => c.name)).toEqual([
+ "fetch-and-store-image",
+ "insert-image-record",
+ "audit-image-added",
+ ]);
+ expect(step.calls[0].config).toEqual({
+ retries: { limit: 3, delay: "5 seconds", backoff: "exponential" },
+ timeout: "2 minutes",
+ });
+ });
+
+ it("maps a permanently unavailable URL to a NonRetryableError with recovery advice", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 404 })));
+ const { workflow } = makeWorkflow();
+ const step = makeFakeStep();
+
+ await expect(
+ workflow.run({ payload: urlPayload(), instanceId: "lpimg-test" } as never, step as never),
+ ).rejects.toMatchObject({
+ name: "NonRetryableError",
+ message: expect.stringContaining("not publicly fetchable"),
+ });
+ expect(sharedQueries.addLandingPageImage).not.toHaveBeenCalled();
+ });
+
+ it("maps a transient server failure to a retryable error (not NonRetryable)", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 502 })));
+ const { workflow } = makeWorkflow();
+ const step = makeFakeStep();
+
+ await expect(
+ workflow.run({ payload: urlPayload(), instanceId: "lpimg-test" } as never, step as never),
+ ).rejects.toMatchObject({
+ name: expect.not.stringContaining("NonRetryableError"),
+ message: expect.stringContaining("502"),
+ });
+ });
+
+ it("rejects a content-type mismatch between the URL and the claim", async () => {
+ const { workflow } = makeWorkflow();
+ const step = makeFakeStep();
+
+ await expect(
+ workflow.run(
+ { payload: urlPayload({ contentType: "image/jpeg" }), instanceId: "lpimg-test" } as never,
+ step as never,
+ ),
+ ).rejects.toMatchObject({
+ name: "NonRetryableError",
+ message: expect.stringContaining("Content mismatch"),
+ });
+ expect(sharedQueries.addLandingPageImage).not.toHaveBeenCalled();
+ });
+
+ it("rejects an oversized image before buffering it", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () =>
+ new Response(null, { status: 200, headers: { "content-length": String(9 * 1024 * 1024) } }),
+ ),
+ );
+ const { workflow } = makeWorkflow();
+ const step = makeFakeStep();
+
+ await expect(
+ workflow.run({ payload: urlPayload(), instanceId: "lpimg-test" } as never, step as never),
+ ).rejects.toMatchObject({
+ name: "NonRetryableError",
+ message: expect.stringContaining("8 MB cap"),
+ });
+ });
+
+ it("refuses to insert when the landing page disappeared mid-flight", async () => {
+ sharedQueries.getLandingPageById.mockResolvedValue(null);
+ const { workflow } = makeWorkflow();
+ const step = makeFakeStep();
+
+ await expect(
+ workflow.run({ payload: urlPayload(), instanceId: "lpimg-test" } as never, step as never),
+ ).rejects.toMatchObject({
+ name: "NonRetryableError",
+ message: expect.stringContaining("no longer exists"),
+ });
+ expect(sharedQueries.addLandingPageImage).not.toHaveBeenCalled();
+ });
+
+ it("is idempotent on r2Key: a re-run insert step finds the existing row instead of duplicating", async () => {
+ sharedQueries.getLandingPageImages.mockResolvedValue([STACK_ROW]);
+ const { workflow, put } = makeWorkflow();
+ const step = makeFakeStep();
+
+ const result = await workflow.run(
+ { payload: urlPayload(), instanceId: "lpimg-test" } as never,
+ step as never,
+ );
+
+ expect(put).toHaveBeenCalled();
+ expect(sharedQueries.addLandingPageImage).not.toHaveBeenCalled();
+ expect(result.imageId).toBe("img-row-1");
+ });
+});
+
+describe("CodLandingPageImageUploadWorkflow.run — bytes path", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sharedQueries.getLandingPageById.mockResolvedValue(LANDING_PAGE);
+ sharedQueries.getLandingPageImages.mockResolvedValue([]);
+ sharedQueries.addLandingPageImage.mockResolvedValue([STACK_ROW]);
+ });
+
+ it("verifies the stored object, inserts the record, and audits", async () => {
+ const { workflow, get, put } = makeWorkflow();
+ const step = makeFakeStep();
+
+ const result = await workflow.run(
+ { payload: bytesPayload(), instanceId: "lpimg-test2" } as never,
+ step as never,
+ );
+
+ expect(get).toHaveBeenCalledWith(R2_KEY);
+ expect(put).not.toHaveBeenCalled();
+ expect(sharedQueries.addLandingPageImage).toHaveBeenCalledWith(
+ expect.anything(),
+ UUID,
+ expect.objectContaining({ source: "ai", width: 1, height: 1 }),
+ );
+ expect(activity.logActivity).toHaveBeenCalled();
+ expect(result.imageId).toBe("img-row-1");
+ expect(step.calls.map((c) => c.name)).toEqual([
+ "read-and-measure-object",
+ "insert-image-record",
+ "audit-image-added",
+ ]);
+ });
+
+ it("fails non-retryably when the tool's direct R2 write is missing", async () => {
+ const { workflow, get } = makeWorkflow();
+ get.mockResolvedValue(null);
+ const step = makeFakeStep();
+
+ await expect(
+ workflow.run({ payload: bytesPayload(), instanceId: "lpimg-test2" } as never, step as never),
+ ).rejects.toMatchObject({
+ name: "NonRetryableError",
+ message: expect.stringContaining("missing"),
+ });
+ });
+});
+
+describe("CodLandingPageImageUploadWorkflow.run — payload gate", () => {
+ it("throws NonRetryableError before any step runs on a malformed payload", async () => {
+ const { workflow } = makeWorkflow();
+ const step = makeFakeStep();
+
+ await expect(
+ workflow.run(
+ { payload: { kind: "url", landingPageId: "nope" }, instanceId: "lpimg-x" } as never,
+ step as never,
+ ),
+ ).rejects.toBeInstanceOf(NonRetryableError);
+
+ expect(step.calls).toHaveLength(0);
+ expect(sharedQueries.addLandingPageImage).not.toHaveBeenCalled();
+ });
+});
+
+describe("CodLandingPageImageUploadWorkflow.run — dimension fallbacks", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(okFetch(REAL_1X1_PNG, { "content-type": "image/png", "content-length": "70" })),
+ );
+ sharedQueries.getLandingPageById.mockResolvedValue(LANDING_PAGE);
+ sharedQueries.getLandingPageImages.mockResolvedValue([]);
+ sharedQueries.addLandingPageImage.mockResolvedValue([STACK_ROW]);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("server-measured dimensions win over client-declared ones", async () => {
+ const { workflow } = makeWorkflow();
+ const step = makeFakeStep();
+
+ await workflow.run(
+ { payload: urlPayload({ width: 999, height: 999 }), instanceId: "lpimg-t" } as never,
+ step as never,
+ );
+
+ expect(sharedQueries.addLandingPageImage).toHaveBeenCalledWith(
+ expect.anything(),
+ UUID,
+ expect.objectContaining({ width: 1, height: 1 }),
+ );
+ });
+
+ it("client-declared dimensions are the fail-open fallback when header parsing fails", async () => {
+ // PNG signature + garbage: sniffs as PNG (8-byte signature match) but the
+ // IHDR chunk is invalid, so parseImageDimensions returns null.
+ const corrupt = new Uint8Array(40);
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).forEach((b: number, i: number) => {
+ corrupt[i] = b;
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(okFetch(corrupt, { "content-type": "image/png" })),
+ );
+ sharedQueries.addLandingPageImage.mockResolvedValue([
+ { ...STACK_ROW, width: 1080, height: 1350 },
+ ]);
+
+ const { workflow } = makeWorkflow();
+ const step = makeFakeStep();
+
+ await workflow.run(
+ { payload: urlPayload({ width: 1080, height: 1350 }), instanceId: "lpimg-t" } as never,
+ step as never,
+ );
+
+ expect(sharedQueries.addLandingPageImage).toHaveBeenCalledWith(
+ expect.anything(),
+ UUID,
+ expect.objectContaining({ width: 1080, height: 1350 }),
+ );
+ });
+});
+
+describe("CodLandingPageImageUploadWorkflow.run — fetch hardening", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sharedQueries.getLandingPageById.mockResolvedValue(LANDING_PAGE);
+ sharedQueries.getLandingPageImages.mockResolvedValue([]);
+ sharedQueries.addLandingPageImage.mockResolvedValue([STACK_ROW]);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("aborts a stream that exceeds the cap when content-length is absent", async () => {
+ // 9 MB of bytes with a PNG signature — no content-length header, so the
+ // capped reader must abort mid-stream before any sniff or R2 write.
+ const big = new Uint8Array(9 * 1024 * 1024);
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).forEach((b: number, i: number) => {
+ big[i] = b;
+ });
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(big as unknown as BodyInit, { status: 200 })));
+
+ const { workflow, put } = makeWorkflow();
+ const step = makeFakeStep();
+
+ await expect(
+ workflow.run({ payload: urlPayload(), instanceId: "lpimg-t" } as never, step as never),
+ ).rejects.toMatchObject({
+ name: "NonRetryableError",
+ message: expect.stringContaining("8 MB cap"),
+ });
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it("rejects a non-http(s) imageUrl at the payload gate", async () => {
+ await expect(
+ (async () => {
+ const { workflow } = makeWorkflow();
+ const step = makeFakeStep();
+ await workflow.run(
+ {
+ payload: {
+ ...urlPayload(),
+ imageUrl: "file:///etc/passwd",
+ },
+ instanceId: "lpimg-t",
+ } as never,
+ step as never,
+ );
+ })(),
+ ).rejects.toMatchObject({ name: "NonRetryableError" });
+ });
+});
diff --git a/cod-server/src/workflows/landing-page-image-upload.ts b/cod-server/src/workflows/landing-page-image-upload.ts
new file mode 100644
index 0000000..4b8c962
--- /dev/null
+++ b/cod-server/src/workflows/landing-page-image-upload.ts
@@ -0,0 +1,371 @@
+/**
+ * CodLandingPageImageUploadWorkflow — durable background upload of a landing
+ * page image from an MCP agent (ChatGPT, Claude, ...).
+ *
+ * Two entry shapes share one tail:
+ * kind "url" — the agent supplies a fetchable http(s) image URL; this
+ * workflow downloads it (retried, size-capped, magic-byte-
+ * verified) and writes the object to R2. Image bytes can
+ * never travel through workflow params (1 MiB payload cap),
+ * so the URL is fetched here, inside a durable step.
+ * kind "bytes" — the tool already wrote the decoded base64 bytes to R2 via
+ * the bucket binding; this workflow verifies the object and
+ * continues.
+ *
+ * Both kinds then measure intrinsic dimensions (fail-open; client-declared
+ * width/height are the fallback), insert the landing_page_images row with
+ * source "ai" (idempotent on r2Key), audit the addition, and return the image
+ * record as the instance output that getLandingPageImageUploadStatus polls.
+ *
+ * Instance ID: `lpimg-<32 hex>` — minted by the upload tool, unique per
+ * upload attempt (Workflows instance IDs are unique forever).
+ */
+
+import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
+import { NonRetryableError } from "cloudflare:workflows";
+import { z } from "zod";
+import type { Env } from "@/types/env";
+import { getDb } from "@/db";
+import { parseImageDimensions, sniffImageType } from "@/lib/image-dimensions";
+import {
+ canonicalImageContentType,
+ IMAGE_CONTENT_TYPES,
+ LANDING_IMAGE_R2_KEY_PATTERN,
+ MAX_IMAGE_BYTES,
+} from "@/lib/landing-image-upload";
+import {
+ addLandingPageImage,
+ getLandingPageById,
+ getLandingPageImages,
+} from "../../../cod-shared/queries/landing-pages";
+import { ACTIONS, logActivity } from "@/lib/activity";
+
+const actorSchema = z.strictObject({
+ id: z.string().min(1),
+ name: z.string().min(1),
+ role: z.enum(["admin", "staff"]),
+});
+
+const baseShape = {
+ landingPageId: z.string().uuid(),
+ r2Key: z.string().regex(LANDING_IMAGE_R2_KEY_PATTERN),
+ contentType: z.enum(IMAGE_CONTENT_TYPES),
+ altText: z.string().max(1000).nullable().optional(),
+ position: z.number().int().min(1).optional(),
+ /** Client-declared intrinsic size — used only when server-side header
+ * parsing fails (fail-open; the storefront renders without dims either way). */
+ width: z.number().int().min(1).max(20000).optional(),
+ height: z.number().int().min(1).max(20000).optional(),
+ actor: actorSchema,
+};
+
+export const CodLandingPageImageUploadParamsSchema = z.discriminatedUnion("kind", [
+ z.strictObject({
+ ...baseShape,
+ kind: z.literal("url"),
+ imageUrl: z.url({ protocol: /^https?$/, message: "imageUrl must be an http(s) URL" }),
+ }),
+ z.strictObject({
+ ...baseShape,
+ kind: z.literal("bytes"),
+ }),
+]);
+
+export type CodLandingPageImageUploadParams = z.infer<
+ typeof CodLandingPageImageUploadParamsSchema
+>;
+
+export type CodLandingPageImageUploadOutput = {
+ imageId: string | null;
+ r2Key: string;
+ src: string;
+ position: number | null;
+ width: number | null;
+ height: number | null;
+ altText: string | null;
+};
+
+interface StoredImageMeta {
+ size: number;
+ width: number | null;
+ height: number | null;
+}
+
+function formatIssues(error: z.ZodError): string {
+ return error.issues
+ .map((issue) =>
+ issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message,
+ )
+ .join("; ");
+}
+
+/**
+ * Read a response body up to capBytes, aborting the stream the moment the
+ * cap is exceeded — an unbounded arrayBuffer() on a lying/large response
+ * would buffer the whole payload in Worker memory before any check runs.
+ */
+async function readBodyWithCap(response: Response, capBytes: number): Promise {
+ if (!response.body) {
+ const buffer = new Uint8Array(await response.arrayBuffer());
+ if (buffer.byteLength > capBytes) {
+ throw new NonRetryableError(`Image exceeds the 8 MB cap (${buffer.byteLength} bytes).`);
+ }
+ return buffer;
+ }
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (!value) continue;
+ total += value.byteLength;
+ if (total > capBytes) {
+ await reader.cancel().catch(() => {});
+ throw new NonRetryableError(
+ `Image exceeds the 8 MB cap (${total} bytes read so far).`,
+ );
+ }
+ chunks.push(value);
+ }
+ const out = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ out.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return out;
+}
+
+/**
+ * Download the image URL, validate it, and write the object to R2 — all inside
+ * one step because step results are capped at 1 MiB and image bytes must not
+ * cross a step boundary. Only small metadata is returned.
+ */
+async function fetchAndStoreImage(
+ env: Env,
+ params: Extract,
+): Promise {
+ const response = await fetch(params.imageUrl, { redirect: "follow" });
+ if ([401, 403, 404].includes(response.status)) {
+ throw new NonRetryableError(
+ `Image URL returned HTTP ${response.status} — the link is not publicly fetchable or has expired. ` +
+ "Re-generate the image and provide its direct download URL.",
+ );
+ }
+ if (!response.ok) {
+ throw new Error(`Image URL fetch failed with HTTP ${response.status}`);
+ }
+
+ const declaredLength = Number(response.headers.get("content-length") ?? "0");
+ if (declaredLength > MAX_IMAGE_BYTES) {
+ throw new NonRetryableError(
+ `Image exceeds the 8 MB cap (content-length: ${declaredLength} bytes).`,
+ );
+ }
+
+ const bytes = await readBodyWithCap(response, MAX_IMAGE_BYTES);
+ if (bytes.byteLength === 0) {
+ throw new NonRetryableError("Image URL returned an empty body.");
+ }
+
+ const sniffed = sniffImageType(bytes);
+ const claimed = canonicalImageContentType(params.contentType);
+ if (!sniffed) {
+ throw new NonRetryableError(
+ "Fetched bytes are not a recognized image (png, jpeg, webp, or gif).",
+ );
+ }
+ if (sniffed !== claimed) {
+ throw new NonRetryableError(
+ `Content mismatch: the URL served ${sniffed} but contentType claimed ${params.contentType}.`,
+ );
+ }
+
+ const dimensions = parseImageDimensions(bytes);
+ try {
+ await env.IMAGES.put(params.r2Key, bytes, {
+ httpMetadata: {
+ contentType: claimed,
+ cacheControl: "public, max-age=31536000, immutable",
+ },
+ customMetadata: {
+ source: "ai",
+ uploadedAt: new Date().toISOString(),
+ },
+ });
+ } catch (error) {
+ throw new Error(
+ `R2 write failed for key ${params.r2Key}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+
+ return {
+ size: bytes.byteLength,
+ width: dimensions?.width ?? null,
+ height: dimensions?.height ?? null,
+ };
+}
+
+/** Verify the tool's direct R2 write landed and measure the stored bytes. */
+async function readAndMeasureObject(
+ env: Env,
+ params: Extract,
+): Promise {
+ const object = await env.IMAGES.get(params.r2Key);
+ if (!object) {
+ throw new NonRetryableError(
+ `R2 object ${params.r2Key} is missing — the direct upload did not land. Retry the upload.`,
+ );
+ }
+ const buffer = new Uint8Array(await object.arrayBuffer());
+ if (buffer.byteLength > MAX_IMAGE_BYTES) {
+ throw new NonRetryableError(
+ `Stored image exceeds the 8 MB cap (${buffer.byteLength} bytes).`,
+ );
+ }
+ const sniffed = sniffImageType(buffer);
+ const claimed = canonicalImageContentType(params.contentType);
+ if (!sniffed) {
+ throw new NonRetryableError(
+ "Stored bytes are not a recognized image (png, jpeg, webp, or gif).",
+ );
+ }
+ if (sniffed !== claimed) {
+ throw new NonRetryableError(
+ `Content mismatch: stored bytes are ${sniffed} but contentType claimed ${params.contentType}.`,
+ );
+ }
+ const dimensions = parseImageDimensions(buffer);
+ return {
+ size: buffer.byteLength,
+ width: dimensions?.width ?? null,
+ height: dimensions?.height ?? null,
+ };
+}
+
+export class CodLandingPageImageUploadWorkflow extends WorkflowEntrypoint<
+ Env,
+ CodLandingPageImageUploadParams
+> {
+ async run(
+ event: WorkflowEvent,
+ step: WorkflowStep,
+ ): Promise {
+ // Step 0 — runtime schema validation (params are untrusted input).
+ const parsed = CodLandingPageImageUploadParamsSchema.safeParse(event.payload);
+ if (!parsed.success) {
+ throw new NonRetryableError(
+ `Invalid landing page image upload payload: ${formatIssues(parsed.error)}`,
+ );
+ }
+ const params = parsed.data;
+
+ // Step 1 — acquire the bytes (durable, retried).
+ // URL path: fetch + validate + R2 put in one step — image bytes must not
+ // cross a step boundary (1 MiB non-stream step-result cap).
+ const stored: StoredImageMeta =
+ params.kind === "url"
+ ? await step.do(
+ "fetch-and-store-image",
+ {
+ retries: { limit: 3, delay: "5 seconds", backoff: "exponential" },
+ timeout: "2 minutes",
+ },
+ async () => fetchAndStoreImage(this.env, params),
+ )
+ : await step.do("read-and-measure-object", async () =>
+ readAndMeasureObject(this.env, params),
+ );
+
+ // Step 2 — insert the landing_page_images row (idempotent on r2Key: an
+ // engine restart after a committed insert re-runs this step and finds
+ // the row instead of duplicating it).
+ const record = await step.do(
+ "insert-image-record",
+ { retries: { limit: 3, delay: "5 seconds", backoff: "exponential" } },
+ async () => {
+ const db = getDb(this.env.DB);
+
+ const landingPage = await getLandingPageById(db, params.landingPageId);
+ if (!landingPage) {
+ throw new NonRetryableError(
+ `Landing page ${params.landingPageId} no longer exists — nothing to attach the image to.`,
+ );
+ }
+
+ const existing = (await getLandingPageImages(db, params.landingPageId)).find(
+ (image) => image.r2Key === params.r2Key,
+ );
+ if (existing) {
+ return {
+ imageId: existing.id,
+ src: existing.src,
+ position: existing.position,
+ width: existing.width,
+ height: existing.height,
+ altText: existing.altText,
+ landingPageName: landingPage.name,
+ };
+ }
+
+ const src = `https://${this.env.MEDIA_DOMAIN}/${params.r2Key}`;
+ const images = await addLandingPageImage(db, params.landingPageId, {
+ r2Key: params.r2Key,
+ src,
+ altText: params.altText ?? null,
+ ...(params.position !== undefined ? { position: params.position } : {}),
+ width: stored.width ?? params.width ?? null,
+ height: stored.height ?? params.height ?? null,
+ source: "ai",
+ });
+ const image = images.find((row) => row.r2Key === params.r2Key);
+ if (!image) {
+ throw new Error(
+ `Inserted image row for key ${params.r2Key} not found in the resulting stack.`,
+ );
+ }
+ return {
+ imageId: image.id,
+ src: image.src,
+ position: image.position,
+ width: image.width,
+ height: image.height,
+ altText: image.altText,
+ landingPageName: landingPage.name,
+ };
+ },
+ );
+
+ // Step 3 — audit trail (best-effort: logActivity swallows its own errors,
+ // so an audit failure can never fail an upload that already landed).
+ await step.do("audit-image-added", async () => {
+ await logActivity(
+ getDb(this.env.DB),
+ params.actor,
+ ACTIONS.LANDING_PAGE_UPDATED,
+ { type: "landing_page", id: params.landingPageId, label: record.landingPageName },
+ {
+ via: "mcp",
+ action: "image_added",
+ source: "ai",
+ imageId: record.imageId,
+ r2Key: params.r2Key,
+ uploadKind: params.kind,
+ uploadJobId: event.instanceId,
+ byteSize: stored.size,
+ },
+ );
+ });
+
+ return {
+ imageId: record.imageId,
+ r2Key: params.r2Key,
+ src: record.src,
+ position: record.position,
+ width: record.width,
+ height: record.height,
+ altText: record.altText,
+ };
+ }
+}
diff --git a/cod-server/wrangler.toml.example b/cod-server/wrangler.toml.example
index d8ab221..e00b616 100644
--- a/cod-server/wrangler.toml.example
+++ b/cod-server/wrangler.toml.example
@@ -48,6 +48,13 @@ name = "capi-workflow"
binding = "CAPI_WORKFLOW"
class_name = "CodCapiWorkflow"
+# CodLandingPageImageUploadWorkflow — durable background upload of MCP-agent
+# images (ChatGPT & co.) into landing page image stacks.
+[[workflows]]
+name = "lp-image-upload-workflow"
+binding = "LP_IMAGE_UPLOAD_WORKFLOW"
+class_name = "CodLandingPageImageUploadWorkflow"
+
# The `agents` package statically imports `cloudflare:email`. Declaring this
# satisfies the module resolver even though this Worker never sends email.
# (Cloudflare's local dev runtime fails to load the Worker without it.)
@@ -116,6 +123,11 @@ name = "capi-workflow"
binding = "CAPI_WORKFLOW"
class_name = "CodCapiWorkflow"
+[[env.production.workflows]]
+name = "lp-image-upload-workflow"
+binding = "LP_IMAGE_UPLOAD_WORKFLOW"
+class_name = "CodLandingPageImageUploadWorkflow"
+
# Share local D1 state with the dashboard so both read the same SQLite file in
# dev. `persist_to` under [dev] is not a wrangler 3.x field (hence the old
# warning); the shared path is wired in via --persist-to in package.json dev +
diff --git a/cod-shared/queries/landing-pages.ts b/cod-shared/queries/landing-pages.ts
index 261665f..074bab7 100644
--- a/cod-shared/queries/landing-pages.ts
+++ b/cod-shared/queries/landing-pages.ts
@@ -64,6 +64,8 @@ export interface LandingPageImageInput {
/** Intrinsic pixel size — reserved by the storefront to prevent CLS. */
width?: number | null;
height?: number | null;
+ /** Provenance of the upload — AI agents set "ai", browser uploads default to "upload". */
+ source?: "upload" | "ai";
}
const STATS_SELECT = {
@@ -77,16 +79,24 @@ const STATS_SELECT = {
)`,
};
+/** Opt-in pagination — applied only when provided; omitted means unbounded
+ * (the dashboard list fetches everything). */
+export interface LandingPageListPagination {
+ limit?: number;
+ offset?: number;
+}
+
async function resolveListRow(
db: AppDb,
filters: { productId?: string; status?: "draft" | "published" | "archived" } = {},
+ pagination: LandingPageListPagination = {},
): Promise {
// Filters belong in SQL: stat subselects must not run for discarded rows.
const conditions = [];
if (filters.productId) conditions.push(eq(landingPages.productId, filters.productId));
if (filters.status) conditions.push(eq(landingPages.status, filters.status));
- const rows = await db
+ const baseQuery = db
.select({
id: landingPages.id,
slug: landingPages.slug,
@@ -108,8 +118,14 @@ async function resolveListRow(
.from(landingPages)
.leftJoin(products, eq(landingPages.productId, products.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
- .orderBy(desc(landingPages.createdAt))
- .all();
+ .orderBy(desc(landingPages.createdAt));
+
+ // $dynamic() because limit/offset are applied conditionally — drizzle's
+ // static builder cannot chain .limit(undefined).
+ let query = baseQuery.$dynamic();
+ if (pagination.limit !== undefined) query = query.limit(pagination.limit);
+ if (pagination.offset !== undefined) query = query.offset(pagination.offset);
+ const rows = await query.all();
return rows.map((row) => ({
...row,
@@ -123,8 +139,9 @@ async function resolveListRow(
export async function listLandingPages(
db: AppDb,
filters: { productId?: string; status?: "draft" | "published" | "archived" } = {},
+ pagination: LandingPageListPagination = {},
) {
- return resolveListRow(db, filters);
+ return resolveListRow(db, filters, pagination);
}
export async function getLandingPageStats(db: AppDb, id: string): Promise {
@@ -149,6 +166,13 @@ export function generateLandingPageSlug(): string {
return `lp-${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
}
+/**
+ * Full LP detail by id in TWO round trips (mirrors the slug path's shape):
+ * 1. the landing page row by id
+ * 2. ONE db.batch carrying images + stats + product ref — a single HTTP
+ * call to D1 instead of three parallel-but-separate queries. Used by
+ * every REST detail read and every MCP write tool's post-mutation read.
+ */
export async function getLandingPageById(db: AppDb, id: string) {
const row = await db
.select()
@@ -157,26 +181,42 @@ export async function getLandingPageById(db: AppDb, id: string) {
.get();
if (!row) return null;
- const [images, product, stats] = await Promise.all([
+ const results = await db.batch([
db
.select()
.from(landingPageImages)
.where(eq(landingPageImages.landingPageId, id))
- .orderBy(landingPageImages.position)
- .all(),
+ .orderBy(landingPageImages.position),
+ db
+ .select({
+ views: landingPages.views,
+ ...STATS_SELECT,
+ })
+ .from(landingPages)
+ .where(eq(landingPages.id, id)),
db
.select({ id: products.id, name: products.name, handle: products.handle, price: products.price })
.from(products)
- .where(eq(products.id, row.productId))
- .get(),
- getLandingPageStats(db, id),
- ]);
+ .where(eq(products.id, row.productId)),
+ ] as [BatchStatement, ...BatchStatement[]]);
+
+ const images = (results[0] as unknown as typeof landingPageImages.$inferSelect[]) ?? [];
+ const statsRow = ((results[1] as unknown as Array>) ?? [])[0];
+ const productRow = ((results[2] as unknown as Array>) ?? [])[0];
return {
...row,
images,
- product: product ?? null,
- stats: stats ?? { views: 0, orders: 0, revenue: 0 },
+ product: productRow
+ ? (productRow as unknown as { id: string; name: string; handle: string; price: number })
+ : null,
+ stats: statsRow
+ ? {
+ views: Number(statsRow.views),
+ orders: Number(statsRow.orders),
+ revenue: Number(statsRow.revenue),
+ }
+ : { views: 0, orders: 0, revenue: 0 },
};
}
@@ -350,7 +390,7 @@ export async function addLandingPageImage(
r2Key: image.r2Key,
src: image.src,
altText: image.altText ?? null,
- source: "upload",
+ source: image.source ?? "upload",
position: nextPosition,
width: image.width ?? null,
height: image.height ?? null,
From ad37030a1b89ab7879748364270c7f637ed34b5a Mon Sep 17 00:00:00 2001
From: Bilal Mansouri <124762008+bighadj22@users.noreply.github.com>
Date: Sat, 12 Sep 2026 20:54:08 +0100
Subject: [PATCH 2/6] feat(mcp): complete landing-pages toolset with async
image upload
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- uploadLandingPageImage: chat clients pass conversation images via the
OpenAI file-object contract (openai/fileParams) or a public URL;
programmatic clients via base64 — enqueues the background workflow and
returns a polling job id
- getLandingPageImageUploadStatus: polls the workflow instance and maps
every state to a model-readable status view
- stack + lifecycle tools: remove (R2 last-reference guard), reorder,
duplicate, unpublish, archive
- strict layer-2 validation on every tool (unknown fields rejected, never
stripped); publish warns on an empty image stack; existence checks fix
false-success on publish/update paths; STOREFRONT_URL fallback parity
with REST; tool descriptions written for model consumption
- REST list route gains validated limit/offset
---
.../endpoints/landing-pages/ai-tools.test.ts | 1125 +++++++++++++++++
.../src/endpoints/landing-pages/ai-tools.ts | 943 +++++++++++++-
.../src/endpoints/landing-pages/handlers.ts | 29 +-
.../landing-pages.api-e2e.test.ts | 61 +
.../src/endpoints/landing-pages/routes.ts | 13 +-
5 files changed, 2125 insertions(+), 46 deletions(-)
create mode 100644 cod-server/src/endpoints/landing-pages/ai-tools.test.ts
diff --git a/cod-server/src/endpoints/landing-pages/ai-tools.test.ts b/cod-server/src/endpoints/landing-pages/ai-tools.test.ts
new file mode 100644
index 0000000..7ee8bb9
--- /dev/null
+++ b/cod-server/src/endpoints/landing-pages/ai-tools.test.ts
@@ -0,0 +1,1125 @@
+/**
+ * Landing-pages MCP tools — unit tests for the two-layer validation contract.
+ *
+ * Proves the Slice-1 hygiene guarantees:
+ * • publishLandingPage never reports success for a nonexistent page (F4)
+ * • publishing an empty stack returns an explicit warning (F4)
+ * • unknown fields are REJECTED, never silently stripped (F5) — the model
+ * gets an actionable error it can recover from
+ * • the STOREFRONT_URL fallback flows from the registry env into every
+ * publicUrl the tools return (F7)
+ *
+ * And the Slice-3 upload contract:
+ * • uploadLandingPageImage: XOR of imageUrl/imageBase64, magic-byte sniff,
+ * 8 MB cap, data-URI tolerance, existence guard, workflow enqueue shapes
+ * • getLandingPageImageUploadStatus: full InstanceStatus mapping + the
+ * retention-expiry "unknown" path
+ *
+ * The queries module and the cod-shared URL helpers are mocked — these tests
+ * cover the tool layer's own logic, not D1 behavior (that lives in the e2e
+ * suites). Sniffing, minting, and base64 decoding run for real.
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const landingQueries = vi.hoisted(() => ({
+ listLandingPages: vi.fn(),
+ getLandingPageById: vi.fn(),
+ getLandingPageStats: vi.fn(),
+ createLandingPage: vi.fn(),
+ updateLandingPage: vi.fn(),
+ publishLandingPage: vi.fn(),
+ unpublishLandingPage: vi.fn(),
+ archiveLandingPage: vi.fn(),
+ duplicateLandingPage: vi.fn(),
+ deleteLandingPageWithGuard: vi.fn(),
+ getLandingPageImage: vi.fn(),
+ getLandingPageImages: vi.fn(),
+ deleteLandingPageImage: vi.fn(),
+ countOtherLandingPageImageReferences: vi.fn(),
+ reorderLandingPageImagesChecked: vi.fn(),
+}));
+
+vi.mock("./queries", () => landingQueries);
+
+const sharedLandingQueries = vi.hoisted(() => ({
+ resolveStorefrontBaseUrl: vi.fn(),
+ buildLandingPagePublicUrl: vi.fn(
+ (baseUrl: string | null, slug: string) => (baseUrl ? `${baseUrl}/lp/${slug}` : `/lp/${slug}`),
+ ),
+}));
+
+vi.mock("../../../../cod-shared/queries/landing-pages", () => sharedLandingQueries);
+
+import {
+ getLandingPageTools,
+ LANDING_PAGE_TOOL_OUTPUT_SCHEMAS,
+ mapUploadJobStatus,
+} from "./ai-tools";
+
+const db = {} as never;
+const ENV = { STOREFRONT_URL: "https://fallback.example.com" };
+/** Valid RFC 9562 v4 UUID — zod 4's uuid() enforces version + variant nibbles. */
+const UUID = "6f0c9b8e-3d2a-4e5f-8a7b-9c1d2e3f4a5b";
+
+type Executable = { execute?: (args: unknown, options: unknown) => Promise };
+
+const SESSION = { userId: "user-1", role: "staff" as const, name: "Ada", email: "ada@example.com" };
+
+async function call(
+ toolName: string,
+ args: unknown,
+ env: Record = ENV,
+ session: unknown = SESSION,
+): Promise> {
+ const bundle = getLandingPageTools(
+ db,
+ env as never,
+ session as never,
+ ) as unknown as Record;
+ const t = bundle[toolName];
+ if (!t?.execute) throw new Error(`tool ${toolName} has no execute`);
+ return (await t.execute(args, { toolCallId: "test" })) as Record;
+}
+
+const lpWithImages = {
+ id: UUID,
+ slug: "lp-abc12345",
+ name: "Test page",
+ status: "draft",
+ productId: "prod-1",
+ imageGap: 0,
+ views: 0,
+ publishedAt: null,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ images: [
+ {
+ id: "img-1",
+ landingPageId: UUID,
+ r2Key: "landing/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png",
+ src: "https://media.example.com/landing/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png",
+ altText: null,
+ source: "upload",
+ position: 1,
+ width: 1080,
+ height: 1350,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ },
+ {
+ id: "img-2",
+ landingPageId: UUID,
+ r2Key: "landing/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png",
+ src: "https://media.example.com/landing/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png",
+ altText: null,
+ source: "ai",
+ position: 2,
+ width: null,
+ height: null,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ },
+ ],
+ product: null,
+ stats: { views: 0, orders: 0, revenue: 0 },
+};
+
+const lpEmptyStack = { ...lpWithImages, images: [] };
+
+describe("publishLandingPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sharedLandingQueries.resolveStorefrontBaseUrl.mockResolvedValue("https://fallback.example.com");
+ });
+
+ it("returns failure for a nonexistent landing page and never publishes (F4)", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue(null);
+
+ const res = await call("publishLandingPage", { landingPageId: UUID });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("not found");
+ expect(landingQueries.publishLandingPage).not.toHaveBeenCalled();
+ });
+
+ it("publishes but warns when the image stack is empty, with the fallback URL (F4 + F7)", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue(lpEmptyStack);
+
+ const res = await call("publishLandingPage", { landingPageId: UUID });
+
+ expect(res.success).toBe(true);
+ expect(res.warning).toEqual(expect.stringContaining("EMPTY image stack"));
+ expect(res.publicUrl).toBe("https://fallback.example.com/lp/lp-abc12345");
+ expect(sharedLandingQueries.resolveStorefrontBaseUrl).toHaveBeenCalledWith(
+ db,
+ "https://fallback.example.com",
+ );
+ });
+
+ it("publishes without a warning when the stack has images", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue(lpWithImages);
+
+ const res = await call("publishLandingPage", { landingPageId: UUID });
+
+ expect(res.success).toBe(true);
+ expect(res.warning).toBeUndefined();
+ });
+
+ it("rejects unknown fields", async () => {
+ const res = await call("publishLandingPage", { landingPageId: UUID, force: true });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("force");
+ expect(landingQueries.getLandingPageById).not.toHaveBeenCalled();
+ });
+});
+
+describe("createLandingPage — strict layer-2 (F5)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sharedLandingQueries.resolveStorefrontBaseUrl.mockResolvedValue("https://fallback.example.com");
+ });
+
+ it("rejects an `images` field instead of silently stripping it", async () => {
+ const res = await call("createLandingPage", {
+ name: "My page",
+ productId: UUID,
+ images: [{ src: "https://x/y.png" }],
+ });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("images");
+ expect(landingQueries.createLandingPage).not.toHaveBeenCalled();
+ });
+
+ it("rejects a hallucinated `status` field", async () => {
+ const res = await call("createLandingPage", {
+ name: "My page",
+ productId: UUID,
+ status: "published",
+ });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("status");
+ expect(landingQueries.createLandingPage).not.toHaveBeenCalled();
+ });
+
+ it("creates a valid draft and returns the fallback publicUrl", async () => {
+ landingQueries.createLandingPage.mockResolvedValue({ id: UUID, slug: "lp-abc12345" });
+ landingQueries.getLandingPageById.mockResolvedValue(lpWithImages);
+
+ const res = await call("createLandingPage", { name: "My page", productId: UUID });
+
+ expect(res.success).toBe(true);
+ expect(res.publicUrl).toBe("https://fallback.example.com/lp/lp-abc12345");
+ expect(sharedLandingQueries.resolveStorefrontBaseUrl).toHaveBeenCalledWith(
+ db,
+ "https://fallback.example.com",
+ );
+ });
+});
+
+describe("updateLandingPage — strict layer-2 (F5)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("rejects an unknown field inside updates", async () => {
+ const res = await call("updateLandingPage", {
+ landingPageId: UUID,
+ updates: { imageGap: 10, images: ["a"] },
+ });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("images");
+ expect(landingQueries.updateLandingPage).not.toHaveBeenCalled();
+ });
+
+ it("applies a valid partial update", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue(lpWithImages);
+
+ const res = await call("updateLandingPage", {
+ landingPageId: UUID,
+ updates: { imageGap: 10 },
+ });
+
+ expect(res.success).toBe(true);
+ expect(landingQueries.updateLandingPage).toHaveBeenCalledWith(db, UUID, { imageGap: 10 });
+ });
+});
+
+describe("listLandingPages — strict layer-2 + pagination (F5, Slice 5)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sharedLandingQueries.resolveStorefrontBaseUrl.mockResolvedValue(null);
+ });
+
+ it("rejects unknown filter fields", async () => {
+ const res = await call("listLandingPages", { productId: UUID, sortBy: "views" });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("sortBy");
+ });
+
+ it("rejects an out-of-range limit", async () => {
+ const res = await call("listLandingPages", { limit: 500 });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("limit");
+ expect(landingQueries.listLandingPages).not.toHaveBeenCalled();
+ });
+
+ it("defaults to limit 50, offset 0 — the LLM context stays bounded", async () => {
+ landingQueries.listLandingPages.mockResolvedValue([]);
+
+ const res = await call("listLandingPages", {});
+
+ expect(res.success).toBe(true);
+ expect(res.count).toBe(0);
+ expect(landingQueries.listLandingPages).toHaveBeenCalledWith(db, {}, { limit: 50, offset: 0 });
+ expect(sharedLandingQueries.resolveStorefrontBaseUrl).toHaveBeenCalledWith(
+ db,
+ "https://fallback.example.com",
+ );
+ });
+
+ it("passes explicit paging through to the query layer", async () => {
+ landingQueries.listLandingPages.mockResolvedValue([]);
+
+ await call("listLandingPages", { limit: 20, offset: 40, status: "published" });
+
+ expect(landingQueries.listLandingPages).toHaveBeenCalledWith(
+ db,
+ { status: "published" },
+ { limit: 20, offset: 40 },
+ );
+ });
+});
+
+describe("deleteLandingPage — strict layer-2 (F5)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("rejects unknown fields", async () => {
+ const res = await call("deleteLandingPage", { landingPageId: UUID, force: true });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("force");
+ expect(landingQueries.getLandingPageStats).not.toHaveBeenCalled();
+ });
+});
+
+/** Real 1x1 px PNG — the sniff/decode path runs against real bytes. */
+const PNG_1x1_BASE64 =
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+
+const KEY_HEX = "a".repeat(32);
+const R2_KEY = `landing/${KEY_HEX}.png`;
+
+const LANDING_PAGE = {
+ id: UUID,
+ slug: "lp-abc12345",
+ name: "Zinc page",
+ status: "draft",
+ images: [],
+ product: null,
+ stats: { views: 0, orders: 0, revenue: 0 },
+};
+
+interface WorkflowMock {
+ create: ReturnType;
+ get: ReturnType;
+}
+
+function makeUploadEnv(statusToReturn?: Record, getThrows = false): {
+ env: Record;
+ workflow: WorkflowMock;
+ put: ReturnType;
+} {
+ const workflow = {
+ create: vi.fn(async ({ id }: { id: string }) => ({ id })),
+ get: vi.fn(async () => {
+ if (getThrows) throw new Error("instance not found");
+ return { status: async () => statusToReturn ?? { status: "running" } };
+ }),
+ };
+ const put = vi.fn(async () => undefined);
+ const env = {
+ STOREFRONT_URL: "https://fallback.example.com",
+ MEDIA_DOMAIN: "media.example.com",
+ IMAGES: { put },
+ LP_IMAGE_UPLOAD_WORKFLOW: workflow,
+ };
+ return { env, workflow, put };
+}
+
+describe("uploadLandingPageImage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ landingQueries.getLandingPageById.mockResolvedValue(LANDING_PAGE);
+ });
+
+ it("enqueues a url upload and returns the polling contract", async () => {
+ const { env, workflow, put } = makeUploadEnv();
+
+ const res = await call(
+ "uploadLandingPageImage",
+ {
+ landingPageId: UUID,
+ imageUrl: "https://images.openai.example/gen.png",
+ contentType: "image/png",
+ altText: "Hero",
+ },
+ env,
+ );
+
+ expect(res.success).toBe(true);
+ expect(res.status).toBe("processing");
+ expect(res.uploadJobId).toMatch(/^lpimg-[a-f0-9]{32}$/);
+ expect(res.r2Key).toMatch(/^landing\/[a-f0-9]{32}\.png$/);
+ expect(res.src).toBe(`https://media.example.com/${res.r2Key}`);
+ expect(res.note).toContain("getLandingPageImageUploadStatus");
+
+ expect(put).not.toHaveBeenCalled();
+ expect(workflow.create).toHaveBeenCalledTimes(1);
+ const createArgs = workflow.create.mock.calls[0][0];
+ expect(createArgs.id).toBe(res.uploadJobId);
+ expect(createArgs.params).toMatchObject({
+ kind: "url",
+ landingPageId: UUID,
+ r2Key: res.r2Key,
+ contentType: "image/png",
+ imageUrl: "https://images.openai.example/gen.png",
+ altText: "Hero",
+ actor: { id: "user-1", name: "Ada", role: "staff" },
+ });
+ });
+
+ it("writes base64 bytes to R2 then enqueues a bytes verification job", async () => {
+ const { env, workflow, put } = makeUploadEnv();
+
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageBase64: PNG_1x1_BASE64, contentType: "image/png" },
+ env,
+ );
+
+ expect(res.success).toBe(true);
+ expect(put).toHaveBeenCalledWith(
+ res.r2Key,
+ expect.any(Uint8Array),
+ {
+ httpMetadata: {
+ contentType: "image/png",
+ cacheControl: "public, max-age=31536000, immutable",
+ },
+ customMetadata: { source: "ai", uploadedAt: expect.any(String) },
+ },
+ );
+ const createArgs = workflow.create.mock.calls[0][0];
+ expect(createArgs.params).toMatchObject({ kind: "bytes", r2Key: res.r2Key });
+ expect(createArgs.params.imageUrl).toBeUndefined();
+ });
+
+ it("tolerates a data-URI prefix and whitespace in imageBase64", async () => {
+ const { env, put } = makeUploadEnv();
+ const dataUri = `data:image/png;base64,\n${PNG_1x1_BASE64.slice(0, 20)}\n${PNG_1x1_BASE64.slice(20)}`;
+
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageBase64: dataUri, contentType: "image/png" },
+ env,
+ );
+
+ expect(res.success).toBe(true);
+ expect(put).toHaveBeenCalled();
+ });
+
+ it("rejects providing both imageUrl and imageBase64", async () => {
+ const { env, workflow } = makeUploadEnv();
+ const res = await call(
+ "uploadLandingPageImage",
+ {
+ landingPageId: UUID,
+ imageUrl: "https://x/y.png",
+ imageBase64: PNG_1x1_BASE64,
+ contentType: "image/png",
+ },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("exactly one");
+ expect(workflow.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects providing neither image, imageUrl, nor imageBase64", async () => {
+ const { env } = makeUploadEnv();
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, contentType: "image/png" },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("exactly one");
+ });
+
+ describe("image file object (openai/fileParams — ChatGPT path)", () => {
+ it("enqueues a url upload using the client-provided download_url", async () => {
+ const { env, workflow, put } = makeUploadEnv();
+
+ const res = await call(
+ "uploadLandingPageImage",
+ {
+ landingPageId: UUID,
+ image: {
+ download_url: "https://files.oaiusercontent.example/file_abc123?sig=xyz",
+ file_id: "file_abc123",
+ mime_type: "image/png",
+ file_name: "generated.png",
+ },
+ contentType: "image/png",
+ },
+ env,
+ );
+
+ expect(res.success).toBe(true);
+ expect(res.uploadJobId).toMatch(/^lpimg-[a-f0-9]{32}$/);
+ expect(put).not.toHaveBeenCalled();
+ const createArgs = workflow.create.mock.calls[0][0];
+ expect(createArgs.params).toMatchObject({
+ kind: "url",
+ landingPageId: UUID,
+ r2Key: res.r2Key,
+ contentType: "image/png",
+ imageUrl: "https://files.oaiusercontent.example/file_abc123?sig=xyz",
+ });
+ });
+
+ it("rejects combining the image file object with imageUrl", async () => {
+ const { env, workflow } = makeUploadEnv();
+ const res = await call(
+ "uploadLandingPageImage",
+ {
+ landingPageId: UUID,
+ image: { download_url: "https://x/y.png", file_id: "f1" },
+ imageUrl: "https://x/y.png",
+ contentType: "image/png",
+ },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("exactly one");
+ expect(workflow.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects combining the image file object with imageBase64", async () => {
+ const { env, workflow } = makeUploadEnv();
+ const res = await call(
+ "uploadLandingPageImage",
+ {
+ landingPageId: UUID,
+ image: { download_url: "https://x/y.png", file_id: "f1" },
+ imageBase64: PNG_1x1_BASE64,
+ contentType: "image/png",
+ },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("exactly one");
+ expect(workflow.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects a file object missing download_url (schema contract)", async () => {
+ const { env, workflow } = makeUploadEnv();
+ const res = await call(
+ "uploadLandingPageImage",
+ {
+ landingPageId: UUID,
+ image: { file_id: "f1" },
+ contentType: "image/png",
+ },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("download_url");
+ expect(workflow.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects a non-http download_url with an actionable error", async () => {
+ const { env, workflow } = makeUploadEnv();
+ const res = await call(
+ "uploadLandingPageImage",
+ {
+ landingPageId: UUID,
+ image: { download_url: "ftp://x/y.png", file_id: "f1" },
+ contentType: "image/png",
+ },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("http(s)");
+ expect(workflow.create).not.toHaveBeenCalled();
+ });
+ });
+
+ it("rejects non-http(s) imageUrl", async () => {
+ const { env, workflow } = makeUploadEnv();
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageUrl: "ftp://x/y.png", contentType: "image/png" },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("http(s)");
+ expect(workflow.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects a content mismatch before writing anything", async () => {
+ const { env, workflow, put } = makeUploadEnv();
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageBase64: PNG_1x1_BASE64, contentType: "image/jpeg" },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("Content mismatch");
+ expect(put).not.toHaveBeenCalled();
+ expect(workflow.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects invalid base64", async () => {
+ const { env, put } = makeUploadEnv();
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageBase64: "!!!not-base64!!!", contentType: "image/png" },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("valid base64");
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it("rejects an oversized decoded payload", async () => {
+ const { env, put, workflow } = makeUploadEnv();
+ const oversized = Buffer.alloc(8 * 1024 * 1024 + 128, 65).toString("base64");
+
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageBase64: oversized, contentType: "image/png" },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("8 MB cap");
+ expect(put).not.toHaveBeenCalled();
+ expect(workflow.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects a disallowed contentType", async () => {
+ const { env } = makeUploadEnv();
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageUrl: "https://x/y.svg", contentType: "image/svg+xml" },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("contentType");
+ });
+
+ it("refuses to enqueue for a nonexistent landing page", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue(null);
+ const { env, workflow } = makeUploadEnv();
+
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageUrl: "https://x/y.png", contentType: "image/png" },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("not found");
+ expect(workflow.create).not.toHaveBeenCalled();
+ });
+
+ it("fails gracefully when the workflow binding is missing", async () => {
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageUrl: "https://x/y.png", contentType: "image/png" },
+ { STOREFRONT_URL: "https://fallback.example.com" },
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("not provisioned");
+ });
+
+ it("fails closed when no session identity is attached", async () => {
+ const { env, workflow } = makeUploadEnv();
+
+ const res = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageUrl: "https://x/y.png", contentType: "image/png" },
+ env,
+ null,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("session identity");
+ expect(workflow.create).not.toHaveBeenCalled();
+ });
+});
+
+describe("getLandingPageImageUploadStatus", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("maps running states to 'processing'", async () => {
+ for (const state of ["queued", "running", "waiting", "waitingForPause"]) {
+ const { env } = makeUploadEnv({ status: state });
+ const res = await call(
+ "getLandingPageImageUploadStatus",
+ { uploadJobId: "lpimg-abc" },
+ env,
+ );
+ expect(res.status).toBe("processing");
+ expect(res.success).toBe(true);
+ }
+ });
+
+ it("returns the image record on 'complete'", async () => {
+ const { env } = makeUploadEnv({
+ status: "complete",
+ output: { imageId: "img-9", r2Key: "landing/x.png", position: 1 },
+ });
+
+ const res = await call("getLandingPageImageUploadStatus", { uploadJobId: "lpimg-abc" }, env);
+
+ expect(res.status).toBe("complete");
+ expect(res.image).toMatchObject({ imageId: "img-9" });
+ expect(res.advice).toContain("publishLandingPage");
+ });
+
+ it("surfaces the failure message on 'errored'", async () => {
+ const { env } = makeUploadEnv({
+ status: "errored",
+ error: { name: "NonRetryableError", message: "Image URL returned HTTP 404 — not publicly fetchable" },
+ });
+
+ const res = await call("getLandingPageImageUploadStatus", { uploadJobId: "lpimg-abc" }, env);
+
+ expect(res.status).toBe("failed");
+ expect(res.error).toContain("not publicly fetchable");
+ });
+
+ it("maps paused/terminated to 'stopped'", async () => {
+ for (const state of ["paused", "terminated"]) {
+ const { env } = makeUploadEnv({ status: state });
+ const res = await call("getLandingPageImageUploadStatus", { uploadJobId: "lpimg-abc" }, env);
+ expect(res.status).toBe("stopped");
+ }
+ });
+
+ it("reports 'unknown' with recovery advice when the instance is gone (retention expiry)", async () => {
+ const { env } = makeUploadEnv(undefined, true);
+
+ const res = await call("getLandingPageImageUploadStatus", { uploadJobId: "lpimg-old" }, env);
+
+ expect(res.success).toBe(true);
+ expect(res.status).toBe("unknown");
+ expect(res.advice).toContain("getLandingPageDetails");
+ });
+
+ it("rejects unknown fields", async () => {
+ const { env } = makeUploadEnv();
+ const res = await call(
+ "getLandingPageImageUploadStatus",
+ { uploadJobId: "lpimg-abc", verbose: true },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("verbose");
+ });
+});
+
+describe("mapUploadJobStatus (pure)", () => {
+ it("maps every InstanceStatus literal to its view status", () => {
+ const views = [
+ "queued",
+ "running",
+ "waiting",
+ "waitingForPause",
+ "paused",
+ "terminated",
+ "errored",
+ "complete",
+ "unknown",
+ ].map((status) => mapUploadJobStatus({ status }).status);
+ expect(views).toEqual([
+ "processing",
+ "processing",
+ "processing",
+ "processing",
+ "stopped",
+ "stopped",
+ "failed",
+ "complete",
+ "unknown",
+ ]);
+ });
+
+ it("any unmapped status falls through to 'unknown'", () => {
+ expect(mapUploadJobStatus({ status: "something-new" }).status).toBe("unknown");
+ });
+});
+
+describe("removeLandingPageImage", () => {
+ const IMG_UUID = "7d1e0c9a-4b3f-4c6d-9e8a-1f2a3b4c5d6e";
+ const imageRow = { id: IMG_UUID, landingPageId: UUID, r2Key: R2_KEY, position: 1 };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("deletes the R2 object on the last reference, then the row, and returns the stack", async () => {
+ landingQueries.getLandingPageImage.mockResolvedValue(imageRow);
+ landingQueries.countOtherLandingPageImageReferences.mockResolvedValue(0);
+ landingQueries.deleteLandingPageImage.mockResolvedValue(undefined);
+ landingQueries.getLandingPageImages.mockResolvedValue([]);
+
+ const put = vi.fn();
+ const del = vi.fn(async () => undefined);
+ const env = {
+ STOREFRONT_URL: "https://fallback.example.com",
+ IMAGES: { put, delete: del },
+ };
+
+ const res = await call(
+ "removeLandingPageImage",
+ { landingPageId: UUID, imageId: IMG_UUID },
+ env,
+ );
+
+ expect(res.success).toBe(true);
+ expect(res.images).toEqual([]);
+ expect(res.count).toBe(0);
+ expect(del).toHaveBeenCalledWith(R2_KEY);
+ expect(landingQueries.deleteLandingPageImage).toHaveBeenCalledWith(db, UUID, IMG_UUID);
+ });
+
+ it("keeps the shared R2 object when another page still references it", async () => {
+ landingQueries.getLandingPageImage.mockResolvedValue(imageRow);
+ landingQueries.countOtherLandingPageImageReferences.mockResolvedValue(2);
+ landingQueries.deleteLandingPageImage.mockResolvedValue(undefined);
+ landingQueries.getLandingPageImages.mockResolvedValue([]);
+
+ const del = vi.fn();
+ const env = {
+ STOREFRONT_URL: "https://fallback.example.com",
+ IMAGES: { put: vi.fn(), delete: del },
+ };
+
+ const res = await call(
+ "removeLandingPageImage",
+ { landingPageId: UUID, imageId: IMG_UUID },
+ env,
+ );
+
+ expect(res.success).toBe(true);
+ expect(del).not.toHaveBeenCalled();
+ expect(landingQueries.deleteLandingPageImage).toHaveBeenCalled();
+ });
+
+ it("aborts before the DB delete when the R2 delete fails", async () => {
+ landingQueries.getLandingPageImage.mockResolvedValue(imageRow);
+ landingQueries.countOtherLandingPageImageReferences.mockResolvedValue(0);
+
+ const del = vi.fn(async () => {
+ throw new Error("R2 is down");
+ });
+ const env = {
+ STOREFRONT_URL: "https://fallback.example.com",
+ IMAGES: { put: vi.fn(), delete: del },
+ };
+
+ const res = await call(
+ "removeLandingPageImage",
+ { landingPageId: UUID, imageId: IMG_UUID },
+ env,
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("storage");
+ expect(landingQueries.deleteLandingPageImage).not.toHaveBeenCalled();
+ });
+
+ it("fails for an image that does not exist on that landing page", async () => {
+ landingQueries.getLandingPageImage.mockResolvedValue(null);
+
+ const res = await call("removeLandingPageImage", { landingPageId: UUID, imageId: IMG_UUID }, {
+ STOREFRONT_URL: "https://fallback.example.com",
+ IMAGES: { put: vi.fn(), delete: vi.fn() },
+ });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("not found");
+ });
+
+ it("rejects unknown fields", async () => {
+ const res = await call(
+ "removeLandingPageImage",
+ { landingPageId: UUID, imageId: IMG_UUID, force: true },
+ { STOREFRONT_URL: "https://fallback.example.com" },
+ );
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("force");
+ });
+});
+
+describe("reorderLandingPageImages", () => {
+ const ID_A = "8a2f1d3b-5c4e-4f60-8b7c-9d0e1f2a3b4c";
+ const ID_B = "9b3a2e4c-6d5f-4a71-9c8d-0e1f2a3b4c5d";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("applies the complete ordered set and returns the new stack", async () => {
+ const ordered = [
+ { id: ID_B, position: 1 },
+ { id: ID_A, position: 2 },
+ ];
+ landingQueries.reorderLandingPageImagesChecked.mockResolvedValue(ordered);
+
+ const res = await call("reorderLandingPageImages", {
+ landingPageId: UUID,
+ imageIds: [ID_B, ID_A],
+ });
+
+ expect(res.success).toBe(true);
+ expect(res.images).toEqual(ordered);
+ expect(landingQueries.reorderLandingPageImagesChecked).toHaveBeenCalledWith(db, UUID, [
+ ID_B,
+ ID_A,
+ ]);
+ });
+
+ it("surfaces the checked wrapper's validation failures", async () => {
+ landingQueries.reorderLandingPageImagesChecked.mockRejectedValue(
+ new Error("imageIds must not contain duplicates"),
+ );
+
+ const res = await call("reorderLandingPageImages", {
+ landingPageId: UUID,
+ imageIds: [ID_A, ID_A],
+ });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("duplicates");
+ });
+
+ it("rejects an empty imageIds array at the schema layer", async () => {
+ const res = await call("reorderLandingPageImages", { landingPageId: UUID, imageIds: [] });
+
+ expect(res.success).toBe(false);
+ expect(landingQueries.reorderLandingPageImagesChecked).not.toHaveBeenCalled();
+ });
+});
+
+describe("duplicateLandingPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sharedLandingQueries.resolveStorefrontBaseUrl.mockResolvedValue("https://fallback.example.com");
+ });
+
+ it("returns the fresh draft with its new slug and link", async () => {
+ landingQueries.duplicateLandingPage.mockResolvedValue("new-id-1");
+ landingQueries.getLandingPageById.mockResolvedValue({ ...lpWithImages, id: "new-id-1", slug: "lp-copy0001" });
+
+ const res = await call("duplicateLandingPage", { landingPageId: UUID });
+
+ expect(res.success).toBe(true);
+ expect(res.publicUrl).toBe("https://fallback.example.com/lp/lp-copy0001");
+ expect(res.note).toContain("zero");
+ });
+
+ it("fails for a nonexistent landing page", async () => {
+ landingQueries.duplicateLandingPage.mockResolvedValue(null);
+
+ const res = await call("duplicateLandingPage", { landingPageId: UUID });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("not found");
+ });
+});
+
+describe("unpublishLandingPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sharedLandingQueries.resolveStorefrontBaseUrl.mockResolvedValue("https://fallback.example.com");
+ });
+
+ it("returns the page to draft and reports the (now dead) link", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue({ ...lpWithImages, status: "published" });
+ landingQueries.unpublishLandingPage.mockResolvedValue(undefined);
+
+ const res = await call("unpublishLandingPage", { landingPageId: UUID });
+
+ expect(res.success).toBe(true);
+ expect(landingQueries.unpublishLandingPage).toHaveBeenCalledWith(db, UUID);
+ expect(res.publicUrl).toBe("https://fallback.example.com/lp/lp-abc12345");
+ });
+
+ it("fails for a nonexistent landing page and never unpublishes", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue(null);
+
+ const res = await call("unpublishLandingPage", { landingPageId: UUID });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("not found");
+ expect(landingQueries.unpublishLandingPage).not.toHaveBeenCalled();
+ });
+});
+
+describe("archiveLandingPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("archives and returns the page state with history preserved", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue({ ...lpWithImages, status: "published" });
+ landingQueries.archiveLandingPage.mockResolvedValue(undefined);
+
+ const res = await call("archiveLandingPage", { landingPageId: UUID });
+
+ expect(res.success).toBe(true);
+ expect(res.message).toContain("preserved");
+ expect(landingQueries.archiveLandingPage).toHaveBeenCalledWith(db, UUID);
+ });
+
+ it("fails for a nonexistent landing page and never archives", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue(null);
+
+ const res = await call("archiveLandingPage", { landingPageId: UUID });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("not found");
+ expect(landingQueries.archiveLandingPage).not.toHaveBeenCalled();
+ });
+
+ it("rejects unknown fields", async () => {
+ const res = await call("archiveLandingPage", { landingPageId: UUID, keepStats: true });
+
+ expect(res.success).toBe(false);
+ expect(res.error).toContain("keepStats");
+ });
+});
+
+describe("output schemas match what the tools actually return (structuredContent contract)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sharedLandingQueries.resolveStorefrontBaseUrl.mockResolvedValue("https://fallback.example.com");
+ });
+
+ it("listLandingPages result validates against its output schema", async () => {
+ landingQueries.listLandingPages.mockResolvedValue([
+ {
+ id: UUID,
+ slug: "lp-abc12345",
+ name: "Zinc page",
+ status: "published",
+ productId: "p1",
+ productName: "Zinc",
+ productHandle: "zinc",
+ imageCount: 3,
+ views: 10,
+ orders: 2,
+ revenue: 9000,
+ publishedAt: null,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ ]);
+
+ const res = await call("listLandingPages", {});
+
+ expect(LANDING_PAGE_TOOL_OUTPUT_SCHEMAS["listLandingPages"]!.safeParse(res).success).toBe(true);
+ });
+
+ it("publishLandingPage empty-stack warning variant validates (warning is declared)", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue(lpEmptyStack);
+
+ const res = await call("publishLandingPage", { landingPageId: UUID });
+
+ expect(res.warning).toBeDefined();
+ expect(LANDING_PAGE_TOOL_OUTPUT_SCHEMAS["publishLandingPage"]!.safeParse(res).success).toBe(true);
+ });
+
+ it("publishLandingPage handled failure validates against the failure envelope", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue(null);
+
+ const res = await call("publishLandingPage", { landingPageId: UUID });
+
+ expect(res.success).toBe(false);
+ expect(LANDING_PAGE_TOOL_OUTPUT_SCHEMAS["publishLandingPage"]!.safeParse(res).success).toBe(true);
+ });
+
+ it("uploadLandingPageImage + status results validate (the ChatGPT pipeline contract)", async () => {
+ const { env } = makeUploadEnv();
+ const upload = await call(
+ "uploadLandingPageImage",
+ { landingPageId: UUID, imageUrl: "https://x/y.png", contentType: "image/png" },
+ env,
+ );
+ expect(LANDING_PAGE_TOOL_OUTPUT_SCHEMAS["uploadLandingPageImage"]!.safeParse(upload).success).toBe(true);
+
+ const { env: envDone } = makeUploadEnv({
+ status: "complete",
+ output: {
+ imageId: "img-9",
+ r2Key: R2_KEY,
+ src: `https://media.example.com/${R2_KEY}`,
+ position: 1,
+ width: 1080,
+ height: 1350,
+ altText: null,
+ },
+ });
+ const done = await call("getLandingPageImageUploadStatus", { uploadJobId: "lpimg-abc" }, envDone);
+ expect(LANDING_PAGE_TOOL_OUTPUT_SCHEMAS["getLandingPageImageUploadStatus"]!.safeParse(done).success).toBe(true);
+
+ const { env: envFailed } = makeUploadEnv({
+ status: "errored",
+ error: { name: "NonRetryableError", message: "Image URL returned HTTP 404" },
+ });
+ const failed = await call("getLandingPageImageUploadStatus", { uploadJobId: "lpimg-abc" }, envFailed);
+ expect(LANDING_PAGE_TOOL_OUTPUT_SCHEMAS["getLandingPageImageUploadStatus"]!.safeParse(failed).success).toBe(true);
+ });
+
+ it("archiveLandingPage and reorderLandingPageImages results validate", async () => {
+ landingQueries.getLandingPageById.mockResolvedValue(lpWithImages);
+ landingQueries.archiveLandingPage.mockResolvedValue(undefined);
+ const archived = await call("archiveLandingPage", { landingPageId: UUID });
+ expect(LANDING_PAGE_TOOL_OUTPUT_SCHEMAS["archiveLandingPage"]!.safeParse(archived).success).toBe(true);
+
+ landingQueries.reorderLandingPageImagesChecked.mockResolvedValue([
+ { id: "img-1", position: 1, r2Key: R2_KEY, src: "s", altText: null, width: 1, height: 1, source: "ai", createdAt: "2026-01-01T00:00:00.000Z" },
+ ]);
+ const reordered = await call("reorderLandingPageImages", {
+ landingPageId: UUID,
+ imageIds: ["8a2f1d3b-5c4e-4f60-8b7c-9d0e1f2a3b4c"],
+ });
+ expect(LANDING_PAGE_TOOL_OUTPUT_SCHEMAS["reorderLandingPageImages"]!.safeParse(reordered).success).toBe(true);
+ });
+});
diff --git a/cod-server/src/endpoints/landing-pages/ai-tools.ts b/cod-server/src/endpoints/landing-pages/ai-tools.ts
index f6c4924..a5740cb 100644
--- a/cod-server/src/endpoints/landing-pages/ai-tools.ts
+++ b/cod-server/src/endpoints/landing-pages/ai-tools.ts
@@ -3,6 +3,17 @@ import { z } from "zod";
import * as queries from "./queries";
import { createLandingPageSchema, updateLandingPageSchema } from "./validation";
import { getDb } from "@/db";
+import type { Env } from "@/types/env";
+import { sniffImageType } from "@/lib/image-dimensions";
+import {
+ BASE64_MAX_INPUT_LENGTH,
+ canonicalImageContentType,
+ decodeBase64Image,
+ IMAGE_CONTENT_TYPES,
+ MAX_IMAGE_BYTES,
+ mintLandingImageUploadIds,
+} from "@/lib/landing-image-upload";
+import { toolOutput } from "@/lib/tool-output-schema";
import {
buildLandingPagePublicUrl,
resolveStorefrontBaseUrl,
@@ -13,29 +24,387 @@ import {
* layer (src/mcp/schemas.ts) can derive tools/list inputSchema from the exact
* same definitions — the advertised schema and the executed validation cannot
* drift apart.
+ *
+ * Every schema is STRICT: an unknown field is an explicit, recoverable
+ * validation error — never a silently stripped key.
*/
-export const listLandingPagesSchema = z.object({
+export const listLandingPagesSchema = z.strictObject({
productId: z.string().optional().describe("Filter by product UUID"),
status: z.enum(["draft", "published", "archived"]).optional().describe("Filter by lifecycle status"),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .max(200)
+ .default(50)
+ .describe("Page size (1-200, default 50). Rows are newest-first; page through with offset when the result hits the limit."),
+ offset: z
+ .number()
+ .int()
+ .min(0)
+ .default(0)
+ .describe("Number of rows to skip — pair with limit for paging through large catalogs."),
});
-export const getLandingPageDetailsSchema = z.object({
+export const getLandingPageDetailsSchema = z.strictObject({
landingPageId: z.string().uuid().describe("The UUID of the landing page to retrieve"),
});
-export const getLandingPageStatsSchema = z.object({
+export const getLandingPageStatsSchema = z.strictObject({
landingPageId: z.string().uuid().describe("The UUID of the landing page to get stats for"),
});
-export const createLandingPageToolSchema = createLandingPageSchema;
-export const updateLandingPageToolSchema = z.object({
+export const createLandingPageToolSchema = createLandingPageSchema.strict();
+export const updateLandingPageToolSchema = z.strictObject({
landingPageId: z.string().uuid().describe("The UUID of the landing page to update"),
- updates: updateLandingPageSchema,
+ updates: updateLandingPageSchema.strict(),
});
-export const publishLandingPageSchema = z.object({
+export const publishLandingPageSchema = z.strictObject({
landingPageId: z.string().uuid().describe("The UUID of the landing page to publish"),
});
-export const deleteLandingPageSchema = z.object({
+export const deleteLandingPageSchema = z.strictObject({
landingPageId: z.string().uuid().describe("The UUID of the landing page to delete"),
});
+/** ChatGPT file-object input (openai/fileParams contract): the CLIENT fills
+ * this object — download_url + file_id required, mime_type/file_name optional.
+ * The model never touches image bytes. */
+const chatgptFileObjectSchema = z.strictObject({
+ download_url: z
+ .string()
+ .min(1)
+ .describe("Direct fetchable URL of the image — filled in by the client, not the model"),
+ file_id: z
+ .string()
+ .min(1)
+ .describe("Client-side file identifier — filled in by the client, not the model"),
+ mime_type: z.string().optional().describe("MIME type of the image, when the client knows it"),
+ file_name: z.string().optional().describe("File name, when the client knows it"),
+});
+
+export const uploadLandingPageImageSchema = z
+ .strictObject({
+ landingPageId: z
+ .string()
+ .uuid()
+ .describe("The UUID of the landing page that receives the image"),
+ image: chatgptFileObjectSchema
+ .optional()
+ .describe(
+ "PREFERRED from ChatGPT and other chat clients: the conversation image as a file object. " +
+ "The client fills download_url automatically — pass the generated image here; do NOT inline image data.",
+ ),
+ imageUrl: z
+ .url({ protocol: /^https?$/, message: "imageUrl must be an http(s) URL" })
+ .optional()
+ .describe(
+ "A directly-fetchable public http(s) URL of the image — e.g. the download URL of an image you generated. " +
+ "Login-protected or expired links fail with a 'not publicly fetchable' error; if so, re-generate or re-host the image and retry with a fresh URL.",
+ ),
+ imageBase64: z
+ .string()
+ .max(BASE64_MAX_INPUT_LENGTH)
+ .optional()
+ .describe(
+ "Programmatic clients ONLY (API/agent harnesses that construct this call in code). " +
+ "NEVER use from a chat client: inline image data is blocked by client safety checks before the call is sent, " +
+ "and raw bytes, base64-encoded (max 8 MB decoded), cannot be emitted as tool arguments anyway.",
+ ),
+ contentType: z
+ .enum(IMAGE_CONTENT_TYPES)
+ .describe(
+ "MIME type of the actual image bytes: image/png, image/jpeg, image/webp, or image/gif. " +
+ "ChatGPT image generation outputs PNG. Verified server-side by magic-byte sniffing — a mismatch is rejected.",
+ ),
+ altText: z.string().max(1000).optional().describe("Alt text for accessibility"),
+ position: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe("Stack position, 1 = top of the page. Omit to append at the end of the stack."),
+ width: z
+ .number()
+ .int()
+ .min(1)
+ .max(20000)
+ .optional()
+ .describe(
+ "Intrinsic pixel width — only if you know it; the server measures automatically and its measurement wins.",
+ ),
+ height: z
+ .number()
+ .int()
+ .min(1)
+ .max(20000)
+ .optional()
+ .describe(
+ "Intrinsic pixel height — only if you know it; the server measures automatically and its measurement wins.",
+ ),
+ })
+ .refine(
+ (data) =>
+ [data.image, data.imageUrl, data.imageBase64].filter((v) => v !== undefined).length === 1,
+ {
+ message:
+ "Provide exactly one of image (chat clients — the file object), imageUrl (a public URL), or imageBase64 (programmatic clients only).",
+ },
+ );
+
+export const getLandingPageImageUploadStatusSchema = z.strictObject({
+ uploadJobId: z
+ .string()
+ .min(6)
+ .max(100)
+ .describe("The uploadJobId returned by uploadLandingPageImage"),
+});
+
+/**
+ * ChatGPT tool-descriptor `_meta` extensions for this domain. Merged by
+ * src/mcp/schemas.ts into TOOL_META and attached at registration time in
+ * server-factory.ts.
+ *
+ * `openai/fileParams` tells the ChatGPT runtime that the named input is a
+ * file object it should construct itself (with a real, fetchable
+ * download_url) — the supported way to pass conversation files to a tool,
+ * because tool-call arguments are model-generated JSON that can never carry
+ * image bytes.
+ *
+ * `openai/toolInvocation` status text (≤64 chars each) is what ChatGPT shows
+ * while the tool runs and after it completes — the async upload is the one
+ * flow where that feedback matters most.
+ */
+export const LANDING_PAGE_TOOL_META: Record> = {
+ uploadLandingPageImage: {
+ "openai/fileParams": ["image"],
+ "openai/toolInvocation/invoking": "Starting background image upload…",
+ "openai/toolInvocation/invoked": "Upload job created — poll status until complete",
+ },
+};
+
+/** One landing_page_images row — the ordered image stack entry. */
+const lpImageRowSchema = z.looseObject({
+ id: z.string().describe("Image row UUID — used by removeLandingPageImage and reorderLandingPageImages"),
+ r2Key: z.string().describe("R2 storage object key (landing/.)"),
+ src: z.string().describe("Public image URL on the media domain"),
+ altText: z.string().nullable().describe("Alt text, or null"),
+ position: z.number().int().describe("Stack position — 1 is the top of the page"),
+ width: z.number().int().nullable().describe("Intrinsic pixel width, or null when unmeasured"),
+ height: z.number().int().nullable().describe("Intrinsic pixel height, or null when unmeasured"),
+ source: z.enum(["upload", "ai"]).describe("Provenance: uploaded by a human or generated by an AI agent"),
+});
+
+/** A landing page detail aggregate (the shape getLandingPageById returns). */
+const lpDetailSchema = z.looseObject({
+ id: z.string().describe("Landing page UUID"),
+ slug: z.string().describe("Public slug — the page lives at /lp/"),
+ name: z.string(),
+ status: z.enum(["draft", "published", "archived"]).describe("Lifecycle status"),
+ productId: z.string().describe("The single product this page markets"),
+ imageGap: z.number().int().describe("Gap in pixels between stacked images"),
+ views: z.number().int().describe("Render count (non-unique; refreshes and bots included)"),
+ images: z.array(lpImageRowSchema).describe("The ordered image stack — index 0 renders at the top of the page"),
+ product: z
+ .looseObject({
+ id: z.string(),
+ name: z.string(),
+ handle: z.string(),
+ price: z.number().describe("Catalog price charged on this page (server-authoritative)"),
+ })
+ .nullable()
+ .describe("The linked product, or null when it was deleted"),
+ stats: z.looseObject({
+ views: z.number().int(),
+ orders: z.number().int().describe("Orders placed and attributed to this page (any status)"),
+ revenue: z.number().describe("Gross booked value of attributed orders (any status)"),
+ }),
+});
+
+const lpListItemSchema = z.looseObject({
+ id: z.string().describe("Landing page UUID"),
+ slug: z.string().describe("Public slug — the page lives at /lp/"),
+ name: z.string(),
+ status: z.enum(["draft", "published", "archived"]),
+ productId: z.string(),
+ productName: z.string().nullable(),
+ imageCount: z.number().int().describe("Images in the stack"),
+ views: z.number().int(),
+ orders: z.number().int().describe("Orders placed and attributed (any status)"),
+ revenue: z.number().describe("Gross booked value (any status)"),
+ publicPath: z.string().describe("Relative public path /lp/"),
+ publicUrl: z.string().describe("Absolute public URL when a storefront base resolves, else the relative path"),
+});
+
+const lpStatsSchema = z.looseObject({
+ views: z.number().int().describe("Render count (non-unique)"),
+ orders: z.number().int().describe("Orders placed and attributed (any status, cancelled/returned included)"),
+ revenue: z.number().describe("Gross booked value of attributed orders (any status)"),
+});
+
+export const LANDING_PAGE_TOOL_OUTPUT_SCHEMAS: Record = {
+ listLandingPages: toolOutput({
+ count: z.number().int().describe("Rows returned on this page — page with offset when it equals the limit"),
+ landingPages: z.array(lpListItemSchema).describe("Landing pages, newest first"),
+ }),
+ getLandingPageDetails: toolOutput({
+ landingPage: lpDetailSchema.describe("Settings, the ordered image stack, the linked product, and stats"),
+ }),
+ getLandingPageStats: toolOutput({
+ stats: lpStatsSchema,
+ }),
+ createLandingPage: toolOutput({
+ landingPage: lpDetailSchema.describe("The created draft — its image stack is EMPTY until images are added"),
+ publicPath: z.string(),
+ publicUrl: z.string(),
+ note: z.string().describe("Next steps: add images, then publish"),
+ }),
+ updateLandingPage: toolOutput({
+ landingPage: lpDetailSchema,
+ }),
+ publishLandingPage: toolOutput({
+ landingPage: lpDetailSchema.describe("The published page"),
+ publicPath: z.string(),
+ publicUrl: z.string().describe("The live link the merchant pastes into ad sets"),
+ warning: z.string().optional().describe("Present when published with an EMPTY image stack — add images before running ads"),
+ }),
+ deleteLandingPage: toolOutput({
+ message: z.string(),
+ }),
+ uploadLandingPageImage: toolOutput({
+ uploadJobId: z.string().describe("Poll getLandingPageImageUploadStatus with this ID until 'complete' or 'failed'"),
+ status: z.literal("processing").describe("The upload runs in the background"),
+ r2Key: z.string(),
+ src: z.string().describe("The public URL the image will be served from once stored"),
+ note: z.string(),
+ }),
+ getLandingPageImageUploadStatus: toolOutput({
+ uploadJobId: z.string(),
+ status: z
+ .enum(["processing", "complete", "failed", "stopped", "unknown"])
+ .describe("processing → keep polling; complete → the image is in the stack; failed → see error; unknown → check the stack directly"),
+ image: z
+ .looseObject({
+ imageId: z.string().describe("The inserted image row's UUID — used by removeLandingPageImage and reorderLandingPageImages"),
+ r2Key: z.string(),
+ src: z.string().describe("Public image URL"),
+ position: z.number().int().nullable().describe("Stack position — 1 is the top of the page"),
+ width: z.number().int().nullable(),
+ height: z.number().int().nullable(),
+ altText: z.string().nullable(),
+ })
+ .nullable()
+ .optional()
+ .describe("On complete: the inserted image (imageId, src, position)"),
+ error: z.string().optional().describe("On failed: what went wrong and how to recover"),
+ note: z.string().optional(),
+ advice: z.string().optional().describe("Next-step guidance"),
+ }),
+ removeLandingPageImage: toolOutput({
+ images: z.array(lpImageRowSchema).describe("The stack after removal"),
+ count: z.number().int(),
+ }),
+ reorderLandingPageImages: toolOutput({
+ images: z.array(lpImageRowSchema).describe("The stack in its new order — index 0 is the top of the page"),
+ count: z.number().int(),
+ }),
+ duplicateLandingPage: toolOutput({
+ landingPage: lpDetailSchema.describe("The fresh draft copy — views/orders/revenue start at zero"),
+ publicPath: z.string(),
+ publicUrl: z.string(),
+ note: z.string(),
+ }),
+ unpublishLandingPage: toolOutput({
+ landingPage: lpDetailSchema.describe("The page, now back in draft — its link 404s but history stays"),
+ publicPath: z.string(),
+ publicUrl: z.string(),
+ }),
+ archiveLandingPage: toolOutput({
+ landingPage: lpDetailSchema.describe("The archived page — history preserved, link retired"),
+ message: z.string(),
+ }),
+};
+
+export const removeLandingPageImageSchema = z.strictObject({
+ landingPageId: z
+ .string()
+ .uuid()
+ .describe("The UUID of the landing page that owns the image"),
+ imageId: z
+ .string()
+ .uuid()
+ .describe(
+ "The UUID of the image row to remove — get the current stack and its IDs from getLandingPageDetails",
+ ),
+});
+
+export const reorderLandingPageImagesSchema = z.strictObject({
+ landingPageId: z.string().uuid().describe("The UUID of the landing page"),
+ imageIds: z
+ .array(z.string().uuid())
+ .min(1)
+ .describe(
+ "The COMPLETE ordered list of ALL image IDs for this landing page — index 0 becomes position 1 (top of the page). " +
+ "No duplicates, no omissions: the set must exactly match the current stack from getLandingPageDetails.",
+ ),
+});
+
+export const duplicateLandingPageSchema = z.strictObject({
+ landingPageId: z.string().uuid().describe("The UUID of the landing page to duplicate"),
+});
+
+export const unpublishLandingPageSchema = z.strictObject({
+ landingPageId: z.string().uuid().describe("The UUID of the landing page to unpublish"),
+});
+
+export const archiveLandingPageSchema = z.strictObject({
+ landingPageId: z.string().uuid().describe("The UUID of the landing page to archive"),
+});
+
+/** Workflow InstanceStatus → the shape the status tool reports to the model. */
+export interface UploadJobStatusView {
+ status: "processing" | "complete" | "failed" | "stopped" | "unknown";
+ image?: unknown;
+ error?: string;
+ note?: string;
+ advice?: string;
+}
+
+export function mapUploadJobStatus(raw: {
+ status: string;
+ error?: { message?: string } | null;
+ output?: unknown;
+}): UploadJobStatusView {
+ switch (raw.status) {
+ case "queued":
+ case "running":
+ case "waiting":
+ case "waitingForPause":
+ return {
+ status: "processing",
+ note: "The upload is still running — keep polling with the same uploadJobId.",
+ };
+ case "complete":
+ return {
+ status: "complete",
+ image: raw.output ?? null,
+ advice:
+ "The image is in the landing page stack. Review it with getLandingPageDetails, arrange the stack with reorderLandingPageImages if needed, " +
+ "then publishLandingPage to make the link live.",
+ };
+ case "errored":
+ return {
+ status: "failed",
+ error: raw.error?.message ?? "The upload failed for an unknown reason.",
+ };
+ case "paused":
+ case "terminated":
+ return { status: "stopped", note: `The upload job was ${raw.status}.` };
+ default:
+ return {
+ status: "unknown",
+ advice:
+ "Check getLandingPageDetails to see whether the image landed before re-uploading.",
+ };
+ }
+}
+
export const LANDING_PAGE_TOOL_SCHEMAS: Record = {
listLandingPages: listLandingPagesSchema.shape,
getLandingPageDetails: getLandingPageDetailsSchema.shape,
@@ -44,8 +413,41 @@ export const LANDING_PAGE_TOOL_SCHEMAS: Record = {
updateLandingPage: updateLandingPageToolSchema.shape,
publishLandingPage: publishLandingPageSchema.shape,
deleteLandingPage: deleteLandingPageSchema.shape,
+ uploadLandingPageImage: uploadLandingPageImageSchema.shape,
+ getLandingPageImageUploadStatus: getLandingPageImageUploadStatusSchema.shape,
+ removeLandingPageImage: removeLandingPageImageSchema.shape,
+ reorderLandingPageImages: reorderLandingPageImagesSchema.shape,
+ duplicateLandingPage: duplicateLandingPageSchema.shape,
+ unpublishLandingPage: unpublishLandingPageSchema.shape,
+ archiveLandingPage: archiveLandingPageSchema.shape,
};
+/** Env surface the landing-page tools consume: deployment vars, the R2
+ * bucket, and the background upload workflow binding. */
+export type LandingPageToolEnv = Pick<
+ Env,
+ "STOREFRONT_URL" | "IMAGES" | "MEDIA_DOMAIN" | "LP_IMAGE_UPLOAD_WORKFLOW"
+>;
+
+/** Verified session identity — structurally satisfied by McpProps. Only the
+ * upload tool needs it (the background workflow audits through this actor). */
+export interface LandingPageToolSession {
+ userId: string;
+ role: "admin" | "staff";
+ name?: string;
+ email?: string;
+}
+
+/** Zod issues → one readable line. Empty paths (e.g. unrecognized keys) do not
+ * get a dangling "path: " prefix. */
+function formatIssues(error: z.ZodError): string {
+ return error.issues
+ .map((issue) =>
+ issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message,
+ )
+ .join("; ");
+}
+
/**
* AI Tools for Landing Pages
*
@@ -56,14 +458,21 @@ export const LANDING_PAGE_TOOL_SCHEMAS: Record = {
*
* Two-Layer Validation Pattern:
* - Layer 1 (LLM-level): Permissive input schema accepts any object to prevent SDK crashes
- * - Layer 2 (App-level): Strict validation inside execute() with graceful error handling
+ * - Layer 2 (App-level): STRICT validation inside execute() with graceful error handling —
+ * unknown fields are rejected with an actionable message so the model can retry
*/
-export const getLandingPageTools = (db: ReturnType) => ({
+export const getLandingPageTools = (
+ db: ReturnType,
+ env?: LandingPageToolEnv,
+ session?: LandingPageToolSession,
+) => ({
listLandingPages: tool({
description:
- "List landing pages (newest first) with per-page stats: views, attributed orders, revenue. " +
+ "List landing pages (newest first) with per-page stats: views, attributed orders, and revenue. " +
+ "Orders are counted as PLACED regardless of status (cancelled/returned included) — treat revenue as gross booked value, not realized sales. " +
"Optionally filter by product or lifecycle status (draft | published | archived). " +
+ "Returns up to `limit` rows (default 50) — page with offset when the result fills the page. " +
"Each row includes the public slug — the link the merchant pastes into ad sets is /lp/.",
inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
execute: async (args) => {
@@ -71,15 +480,23 @@ export const getLandingPageTools = (db: ReturnType) => ({
const validationSchema = listLandingPagesSchema;
const parsed = validationSchema.safeParse(args ?? {});
if (!parsed.success) {
- const errorDetails = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
- return { success: false, error: `Invalid filter arguments: ${errorDetails}. Expected: productId (UUID, optional), status (draft|published|archived, optional)` };
+ return {
+ success: false,
+ error:
+ `Invalid filter arguments: ${formatIssues(parsed.error)}. ` +
+ "Expected: productId (UUID, optional), status (draft|published|archived, optional), limit (1-200, default 50), offset (≥ 0). Unknown fields are rejected.",
+ };
}
- const pages = await queries.listLandingPages(db, {
- ...(parsed.data.productId ? { productId: parsed.data.productId } : {}),
- ...(parsed.data.status ? { status: parsed.data.status } : {}),
- });
- const baseUrl = await resolveStorefrontBaseUrl(db);
+ const pages = await queries.listLandingPages(
+ db,
+ {
+ ...(parsed.data.productId ? { productId: parsed.data.productId } : {}),
+ ...(parsed.data.status ? { status: parsed.data.status } : {}),
+ },
+ { limit: parsed.data.limit, offset: parsed.data.offset },
+ );
+ const baseUrl = await resolveStorefrontBaseUrl(db, env?.STOREFRONT_URL);
return {
success: true,
count: pages.length,
@@ -113,7 +530,10 @@ export const getLandingPageTools = (db: ReturnType) => ({
try {
const parsed = getLandingPageDetailsSchema.safeParse(args ?? {});
if (!parsed.success) {
- return { success: false, error: "Invalid arguments. Expected: landingPageId (UUID)" };
+ return {
+ success: false,
+ error: `Invalid arguments: ${formatIssues(parsed.error)}. Expected: landingPageId (UUID). Unknown fields are rejected.`,
+ };
}
const lp = await queries.getLandingPageById(db, parsed.data.landingPageId);
@@ -130,13 +550,17 @@ export const getLandingPageTools = (db: ReturnType) => ({
getLandingPageStats: tool({
description:
"Get a landing page's performance stats: views, attributed orders, and revenue. " +
+ "Orders are counted as PLACED regardless of status (cancelled/returned included) — treat revenue as gross booked value, not realized sales. " +
"Conversion rate = orders / views (compute it yourself; zero views means the rate is undefined).",
inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
execute: async (args) => {
try {
const parsed = getLandingPageStatsSchema.safeParse(args ?? {});
if (!parsed.success) {
- return { success: false, error: "Invalid arguments. Expected: landingPageId (UUID)" };
+ return {
+ success: false,
+ error: `Invalid arguments: ${formatIssues(parsed.error)}. Expected: landingPageId (UUID). Unknown fields are rejected.`,
+ };
}
const stats = await queries.getLandingPageStats(db, parsed.data.landingPageId);
@@ -152,27 +576,31 @@ export const getLandingPageTools = (db: ReturnType) => ({
createLandingPage: tool({
description:
- "Create a draft landing page for a product. The slug defaults to lp-<8 chars> and is editable later. " +
- "After creation, add images via the dashboard Studio (POST /api/landing-pages/{id}/images) or updateLandingPage. " +
+ "Create a draft landing page for a product. The slug defaults to lp-<8 chars> and is editable later via updateLandingPage. " +
+ "A new page starts with an EMPTY image stack — add images with uploadLandingPageImage before publishing. " +
"The page charges the product's catalog price — there is no price override; the price story lives in the images.",
inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
execute: async (args) => {
try {
const parsed = createLandingPageToolSchema.safeParse(args ?? {});
if (!parsed.success) {
- const errorDetails = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
- return { success: false, error: `Invalid arguments: ${errorDetails}. Expected: name (2-200 chars), productId (UUID), optional slug ([a-z0-9-]{3,60})` };
+ return {
+ success: false,
+ error:
+ `Invalid arguments: ${formatIssues(parsed.error)}. ` +
+ "Expected: name (2-200 chars), productId (UUID), optional slug ([a-z0-9-]{3,60}), optional imageGap (0-200 px). Unknown fields are rejected — images cannot be set at creation.",
+ };
}
const { id, slug } = await queries.createLandingPage(db, parsed.data);
const lp = await queries.getLandingPageById(db, id);
- const baseUrl = await resolveStorefrontBaseUrl(db);
+ const baseUrl = await resolveStorefrontBaseUrl(db, env?.STOREFRONT_URL);
return {
success: true,
landingPage: lp,
publicPath: `/lp/${slug}`,
publicUrl: buildLandingPagePublicUrl(baseUrl, slug),
- note: "Draft created. Add images, then publishLandingPage to make the link live.",
+ note: "Draft created with an empty image stack. Add images via uploadLandingPageImage, then publishLandingPage to make the link live.",
};
} catch (error) {
return { success: false, error: `Failed to create landing page: ${(error as Error).message}` };
@@ -182,15 +610,19 @@ export const getLandingPageTools = (db: ReturnType) => ({
updateLandingPage: tool({
description:
- "Partially update a landing page: name, slug, image gap (imageGap, pixels 0-200), or SEO meta. " +
- "A taken slug is rejected. Image stack changes go through the image endpoints, not this tool.",
+ "Partially update a landing page's settings: name, slug, image gap (imageGap, pixels 0-200), or SEO meta (metaTitle, metaDescription). " +
+ "A taken slug is rejected. The image stack is NOT managed by this tool — use uploadLandingPageImage, removeLandingPageImage, and reorderLandingPageImages instead.",
inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
execute: async (args) => {
try {
const parsed = updateLandingPageToolSchema.safeParse(args ?? {});
if (!parsed.success) {
- const errorDetails = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
- return { success: false, error: `Invalid arguments: ${errorDetails}. Expected: landingPageId (UUID) and updates { name?, slug?, imageGap?, metaTitle?, metaDescription? }` };
+ return {
+ success: false,
+ error:
+ `Invalid arguments: ${formatIssues(parsed.error)}. ` +
+ "Expected: landingPageId (UUID) and updates { name?, slug?, imageGap?, metaTitle?, metaDescription? }. Unknown fields are rejected — the image stack is not updatable here.",
+ };
}
await queries.updateLandingPage(db, parsed.data.landingPageId, parsed.data.updates);
@@ -205,23 +637,41 @@ export const getLandingPageTools = (db: ReturnType) => ({
publishLandingPage: tool({
description:
"Publish a landing page — its link /lp/ goes live and starts counting views. " +
- "Unpublishing returns it to draft via the dashboard (the link stops resolving but history stays).",
+ "The result carries a warning when the page's image stack is empty (the public page would render only the order form). " +
+ "unpublishLandingPage returns it to draft later (the link stops resolving but history stays).",
inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
execute: async (args) => {
try {
const parsed = publishLandingPageSchema.safeParse(args ?? {});
if (!parsed.success) {
- return { success: false, error: "Invalid arguments. Expected: landingPageId (UUID)" };
+ return {
+ success: false,
+ error: `Invalid arguments: ${formatIssues(parsed.error)}. Expected: landingPageId (UUID). Unknown fields are rejected.`,
+ };
+ }
+
+ const existing = await queries.getLandingPageById(db, parsed.data.landingPageId);
+ if (!existing) {
+ return { success: false, error: `Landing page ${parsed.data.landingPageId} not found` };
}
await queries.publishLandingPage(db, parsed.data.landingPageId);
const lp = await queries.getLandingPageById(db, parsed.data.landingPageId);
- const baseUrl = await resolveStorefrontBaseUrl(db);
+ const slug = lp?.slug ?? existing.slug;
+ const imageCount = lp?.images?.length ?? existing.images?.length ?? 0;
+ const baseUrl = await resolveStorefrontBaseUrl(db, env?.STOREFRONT_URL);
return {
success: true,
landingPage: lp,
- publicPath: `/lp/${lp?.slug ?? ""}`,
- publicUrl: lp ? buildLandingPagePublicUrl(baseUrl, lp.slug) : null,
+ publicPath: `/lp/${slug}`,
+ publicUrl: buildLandingPagePublicUrl(baseUrl, slug),
+ ...(imageCount === 0
+ ? {
+ warning:
+ "Published with an EMPTY image stack — the public page will render only the order form with no images. " +
+ "Add images via uploadLandingPageImage before pointing ads at this link.",
+ }
+ : {}),
};
} catch (error) {
return { success: false, error: `Failed to publish: ${(error as Error).message}` };
@@ -232,14 +682,17 @@ export const getLandingPageTools = (db: ReturnType) => ({
deleteLandingPage: tool({
description:
"Permanently delete a landing page with NO attributed orders. " +
- "Refused (LANDING_PAGE_HAS_ORDERS) when any order references the page — archive via the dashboard instead so attribution history stays intact. " +
+ "Refused (LANDING_PAGE_HAS_ORDERS) when any order references the page — archive it instead via archiveLandingPage so attribution history stays intact. " +
"This action is immediate and irreversible.",
inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
execute: async (args) => {
try {
const parsed = deleteLandingPageSchema.safeParse(args ?? {});
if (!parsed.success) {
- return { success: false, error: "Invalid arguments. Expected: landingPageId (UUID)" };
+ return {
+ success: false,
+ error: `Invalid arguments: ${formatIssues(parsed.error)}. Expected: landingPageId (UUID). Unknown fields are rejected.`,
+ };
}
const stats = await queries.getLandingPageStats(db, parsed.data.landingPageId);
@@ -249,7 +702,7 @@ export const getLandingPageTools = (db: ReturnType) => ({
if (stats.orders > 0) {
return {
success: false,
- error: `Landing page has ${stats.orders} attributed order(s) — delete is refused. Archive it from the dashboard instead.`,
+ error: `Landing page has ${stats.orders} attributed order(s) — delete is refused. Archive it instead via archiveLandingPage.`,
};
}
@@ -260,4 +713,418 @@ export const getLandingPageTools = (db: ReturnType) => ({
}
},
}),
+
+ uploadLandingPageImage: tool({
+ description:
+ "Upload an image into a landing page's image stack (the marketing story — price framing, urgency, benefits — lives in the images). " +
+ "ASYNCHRONOUS: this returns an uploadJobId immediately; the actual download/store runs in the background. " +
+ "Afterwards, poll getLandingPageImageUploadStatus with that uploadJobId until it reports 'complete' or 'failed'.\n" +
+ "Pass the image in EXACTLY ONE of three ways:\n" +
+ "• image (file object) — the way ChatGPT and chat clients pass a conversation image: the client fills download_url itself. " +
+ "Generate the image first, then pass it here.\n" +
+ "• imageUrl — any directly-fetchable public http(s) URL (e.g. a re-hosted image). Login-protected or expired links fail with 'not publicly fetchable' — re-host and retry.\n" +
+ "• imageBase64 — programmatic clients only. NEVER from a chat client: inline image data is blocked by client safety checks before the call is sent.\n" +
+ "contentType must match the actual bytes (ChatGPT image generation outputs image/png) — verified server-side by magic-byte sniffing. " +
+ "New images append to the end of the stack; set position to place one (1 = top of the page), or reorder the whole stack later with reorderLandingPageImages. " +
+ "A failed job is safe to retry: every attempt gets a fresh uploadJobId.",
+ inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
+ execute: async (args) => {
+ try {
+ const parsed = uploadLandingPageImageSchema.safeParse(args ?? {});
+ if (!parsed.success) {
+ return {
+ success: false,
+ error:
+ `Invalid arguments: ${formatIssues(parsed.error)}. ` +
+ "Expected: landingPageId (UUID), contentType (image/png|jpeg|webp|gif), and exactly one of image (chat clients — file object), imageUrl (http(s) URL), or imageBase64 (programmatic clients only); " +
+ "optional: altText, position, width, height. Unknown fields are rejected.",
+ };
+ }
+ const data = parsed.data;
+
+ if (!env?.LP_IMAGE_UPLOAD_WORKFLOW || !env.MEDIA_DOMAIN) {
+ return {
+ success: false,
+ error:
+ "Image upload is not available on this deployment — the background upload workflow is not provisioned.",
+ };
+ }
+ if (!session) {
+ return {
+ success: false,
+ error: "Upload requires a verified session identity — none is attached to this connection.",
+ };
+ }
+
+ const existing = await queries.getLandingPageById(db, data.landingPageId);
+ if (!existing) {
+ return { success: false, error: `Landing page ${data.landingPageId} not found` };
+ }
+
+ const { r2Key, instanceId } = mintLandingImageUploadIds(data.contentType);
+
+ // Resolve the three input shapes to the workflow's two entry kinds.
+ let kind: "url" | "bytes";
+ let imageUrl: string | undefined;
+ if (data.image !== undefined) {
+ const { download_url: downloadUrl } = data.image;
+ if (!/^https?:\/\//.test(downloadUrl)) {
+ return {
+ success: false,
+ error: "The image file's download_url must be an http(s) URL — cannot fetch it server-side.",
+ };
+ }
+ kind = "url";
+ imageUrl = downloadUrl;
+ } else if (data.imageUrl !== undefined) {
+ kind = "url";
+ imageUrl = data.imageUrl;
+ } else {
+ const imageBase64 = data.imageBase64;
+ if (imageBase64 === undefined) {
+ return {
+ success: false,
+ error:
+ "Provide exactly one of image (chat clients — file object), imageUrl (http(s) URL), or imageBase64 (programmatic clients only).",
+ };
+ }
+ kind = "bytes";
+
+ if (!env.IMAGES) {
+ return { success: false, error: "Image upload is not available — R2 storage is not bound." };
+ }
+ let bytes: Uint8Array;
+ try {
+ bytes = decodeBase64Image(imageBase64);
+ } catch {
+ return { success: false, error: "imageBase64 is not valid base64." };
+ }
+ if (bytes.byteLength === 0) {
+ return { success: false, error: "imageBase64 decodes to zero bytes." };
+ }
+ if (bytes.byteLength > MAX_IMAGE_BYTES) {
+ return {
+ success: false,
+ error: `Image exceeds the 8 MB cap (${bytes.byteLength} bytes decoded).`,
+ };
+ }
+ const sniffed = sniffImageType(bytes);
+ const claimed = canonicalImageContentType(data.contentType);
+ if (!sniffed) {
+ return {
+ success: false,
+ error: "imageBase64 is not a recognized image (png, jpeg, webp, or gif).",
+ };
+ }
+ if (sniffed !== claimed) {
+ return {
+ success: false,
+ error: `Content mismatch: imageBase64 contains ${sniffed} but contentType claims ${data.contentType}.`,
+ };
+ }
+ try {
+ await env.IMAGES.put(r2Key, bytes, {
+ httpMetadata: {
+ contentType: claimed,
+ cacheControl: "public, max-age=31536000, immutable",
+ },
+ customMetadata: { source: "ai", uploadedAt: new Date().toISOString() },
+ });
+ } catch (err) {
+ return {
+ success: false,
+ error: `Failed to store image bytes: ${(err as Error).message}`,
+ };
+ }
+ }
+
+ let instance: { id: string };
+ try {
+ instance = await env.LP_IMAGE_UPLOAD_WORKFLOW.create({
+ id: instanceId,
+ params: {
+ kind,
+ landingPageId: data.landingPageId,
+ r2Key,
+ contentType: data.contentType,
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
+ ...(data.altText !== undefined ? { altText: data.altText } : {}),
+ ...(data.position !== undefined ? { position: data.position } : {}),
+ ...(data.width !== undefined ? { width: data.width } : {}),
+ ...(data.height !== undefined ? { height: data.height } : {}),
+ actor: {
+ id: session.userId,
+ name: session.name || session.email || session.userId,
+ role: session.role,
+ },
+ },
+ });
+ } catch (err) {
+ return {
+ success: false,
+ error: `Failed to start the background upload: ${(err as Error).message}`,
+ };
+ }
+
+ return {
+ success: true,
+ uploadJobId: instance.id,
+ status: "processing",
+ r2Key,
+ src: `https://${env.MEDIA_DOMAIN}/${r2Key}`,
+ note: "The upload runs in the background. Call getLandingPageImageUploadStatus with uploadJobId until status is 'complete' or 'failed'.",
+ };
+ } catch (error) {
+ return { success: false, error: `Failed to upload image: ${(error as Error).message}` };
+ }
+ },
+ }),
+
+ getLandingPageImageUploadStatus: tool({
+ description:
+ "Check the progress of a background uploadLandingPageImage job — pass the uploadJobId that tool returned. " +
+ "Returns status: 'processing' (still running — keep polling), 'complete' (the image is in the stack; includes imageId, src, and position), " +
+ "'failed' (includes a recoverable error — e.g. re-generate the image and retry with a fresh URL), 'stopped', or " +
+ "'unknown' (job state no longer retained — check getLandingPageDetails to see whether the image landed before re-uploading).",
+ inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
+ execute: async (args) => {
+ try {
+ const parsed = getLandingPageImageUploadStatusSchema.safeParse(args ?? {});
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: `Invalid arguments: ${formatIssues(parsed.error)}. Expected: uploadJobId (the ID returned by uploadLandingPageImage). Unknown fields are rejected.`,
+ };
+ }
+
+ if (!env?.LP_IMAGE_UPLOAD_WORKFLOW) {
+ return {
+ success: false,
+ error:
+ "Upload status is not available on this deployment — the background upload workflow is not provisioned.",
+ };
+ }
+
+ let instance: { status(): Promise<{ status: string; error?: { message?: string } | null; output?: unknown }> };
+ try {
+ instance = await env.LP_IMAGE_UPLOAD_WORKFLOW.get(parsed.data.uploadJobId);
+ } catch {
+ return {
+ success: true,
+ uploadJobId: parsed.data.uploadJobId,
+ status: "unknown",
+ advice:
+ "This job's state is no longer retained (or the ID is not a known job). " +
+ "Check getLandingPageDetails to see whether the image landed before re-uploading.",
+ };
+ }
+
+ const raw = await instance.status();
+ return {
+ success: true,
+ uploadJobId: parsed.data.uploadJobId,
+ ...mapUploadJobStatus(raw),
+ };
+ } catch (error) {
+ return { success: false, error: `Failed to check upload status: ${(error as Error).message}` };
+ }
+ },
+ }),
+
+ removeLandingPageImage: tool({
+ description:
+ "Remove an image from a landing page's stack — get the current image IDs from getLandingPageDetails. " +
+ "Storage note: duplicated pages can share the same underlying image object; the storage object itself is deleted only when this was its LAST referencing image. " +
+ "Returns the updated stack. Removing is final for this page — restoring means re-uploading via uploadLandingPageImage.",
+ inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
+ execute: async (args) => {
+ try {
+ const parsed = removeLandingPageImageSchema.safeParse(args ?? {});
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: `Invalid arguments: ${formatIssues(parsed.error)}. Expected: landingPageId (UUID) and imageId (UUID). Unknown fields are rejected.`,
+ };
+ }
+ const { landingPageId, imageId } = parsed.data;
+
+ const image = await queries.getLandingPageImage(db, landingPageId, imageId);
+ if (!image) {
+ return {
+ success: false,
+ error: `Image ${imageId} not found on landing page ${landingPageId}`,
+ };
+ }
+
+ // Same contract as the dashboard path: R2 delete FIRST — a storage
+ // failure aborts so the DB record never points at a missing object;
+ // shared objects (duplicates) survive until their last reference goes.
+ if (image.r2Key) {
+ const otherRefs = await queries.countOtherLandingPageImageReferences(
+ db,
+ image.r2Key,
+ imageId,
+ );
+ if (otherRefs === 0) {
+ if (!env?.IMAGES) {
+ return {
+ success: false,
+ error: "Image removal is not available — R2 storage is not bound on this deployment.",
+ };
+ }
+ try {
+ await env.IMAGES.delete(image.r2Key);
+ } catch (err) {
+ return {
+ success: false,
+ error: `Failed to delete image from storage: ${(err as Error).message}`,
+ };
+ }
+ }
+ }
+
+ await queries.deleteLandingPageImage(db, landingPageId, imageId);
+ const images = await queries.getLandingPageImages(db, landingPageId);
+ return { success: true, images, count: images.length };
+ } catch (error) {
+ return { success: false, error: `Failed to remove image: ${(error as Error).message}` };
+ }
+ },
+ }),
+
+ reorderLandingPageImages: tool({
+ description:
+ "Set the order of a landing page's image stack. Index 1 is the TOP of the page — the first thing shoppers see, so put the strongest creative there. " +
+ "Send the COMPLETE ordered array of ALL image IDs (from getLandingPageDetails): every current image exactly once — no duplicates, no omissions. " +
+ "Partial or mismatched lists are rejected. Returns the stack in its new order.",
+ inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
+ execute: async (args) => {
+ try {
+ const parsed = reorderLandingPageImagesSchema.safeParse(args ?? {});
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: `Invalid arguments: ${formatIssues(parsed.error)}. Expected: landingPageId (UUID) and imageIds (the complete ordered array of all image UUIDs, index 1 = top of the page). Unknown fields are rejected.`,
+ };
+ }
+
+ const images = await queries.reorderLandingPageImagesChecked(
+ db,
+ parsed.data.landingPageId,
+ parsed.data.imageIds,
+ );
+ return { success: true, images, count: images.length };
+ } catch (error) {
+ return { success: false, error: `Failed to reorder images: ${(error as Error).message}` };
+ }
+ },
+ }),
+
+ duplicateLandingPage: tool({
+ description:
+ "Duplicate a landing page: a fresh DRAFT with a new slug, the same product, spacing, SEO meta, and image stack (copies share the original storage objects). " +
+ "Views, orders, revenue, and published state are NEVER copied — a duplicate is a fresh creative test, not a stats clone. " +
+ "Returns the new draft with its new slug and public link.",
+ inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
+ execute: async (args) => {
+ try {
+ const parsed = duplicateLandingPageSchema.safeParse(args ?? {});
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: `Invalid arguments: ${formatIssues(parsed.error)}. Expected: landingPageId (UUID). Unknown fields are rejected.`,
+ };
+ }
+
+ const newId = await queries.duplicateLandingPage(db, parsed.data.landingPageId);
+ if (!newId) {
+ return { success: false, error: `Landing page ${parsed.data.landingPageId} not found` };
+ }
+
+ const lp = await queries.getLandingPageById(db, newId);
+ if (!lp) {
+ return { success: false, error: "Failed to load the duplicated landing page" };
+ }
+ const baseUrl = await resolveStorefrontBaseUrl(db, env?.STOREFRONT_URL);
+ return {
+ success: true,
+ landingPage: lp,
+ publicPath: `/lp/${lp.slug}`,
+ publicUrl: buildLandingPagePublicUrl(baseUrl, lp.slug),
+ note: "Fresh draft created — views, orders, and revenue start at zero. Edit it, then publishLandingPage when ready.",
+ };
+ } catch (error) {
+ return { success: false, error: `Failed to duplicate landing page: ${(error as Error).message}` };
+ }
+ },
+ }),
+
+ unpublishLandingPage: tool({
+ description:
+ "Return a published landing page to draft — its /lp/ link stops resolving (404) but views and order attribution history stay intact. " +
+ "Publish again anytime with publishLandingPage.",
+ inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
+ execute: async (args) => {
+ try {
+ const parsed = unpublishLandingPageSchema.safeParse(args ?? {});
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: `Invalid arguments: ${formatIssues(parsed.error)}. Expected: landingPageId (UUID). Unknown fields are rejected.`,
+ };
+ }
+
+ const existing = await queries.getLandingPageById(db, parsed.data.landingPageId);
+ if (!existing) {
+ return { success: false, error: `Landing page ${parsed.data.landingPageId} not found` };
+ }
+
+ await queries.unpublishLandingPage(db, parsed.data.landingPageId);
+ const lp = await queries.getLandingPageById(db, parsed.data.landingPageId);
+ const baseUrl = await resolveStorefrontBaseUrl(db, env?.STOREFRONT_URL);
+ return {
+ success: true,
+ landingPage: lp,
+ publicPath: `/lp/${lp?.slug ?? existing.slug}`,
+ publicUrl: buildLandingPagePublicUrl(baseUrl, lp?.slug ?? existing.slug),
+ };
+ } catch (error) {
+ return { success: false, error: `Failed to unpublish: ${(error as Error).message}` };
+ }
+ },
+ }),
+
+ archiveLandingPage: tool({
+ description:
+ "Retire a landing page: the link stops resolving and the page leaves the active lists, but its order attribution history stays intact. " +
+ "This is the REQUIRED exit for a page with attributed orders — deleteLandingPage refuses those and tells you to archive instead. " +
+ "The page stays inspectable: its stats and attribution history remain queryable via getLandingPageDetails and getLandingPageStats.",
+ inputSchema: z.object({}).passthrough(), // Layer 1: Permissive input
+ execute: async (args) => {
+ try {
+ const parsed = archiveLandingPageSchema.safeParse(args ?? {});
+ if (!parsed.success) {
+ return {
+ success: false,
+ error: `Invalid arguments: ${formatIssues(parsed.error)}. Expected: landingPageId (UUID). Unknown fields are rejected.`,
+ };
+ }
+
+ const existing = await queries.getLandingPageById(db, parsed.data.landingPageId);
+ if (!existing) {
+ return { success: false, error: `Landing page ${parsed.data.landingPageId} not found` };
+ }
+
+ await queries.archiveLandingPage(db, parsed.data.landingPageId);
+ const lp = await queries.getLandingPageById(db, parsed.data.landingPageId);
+ return {
+ success: true,
+ landingPage: lp,
+ message: "Landing page archived — its order attribution history is preserved.",
+ };
+ } catch (error) {
+ return { success: false, error: `Failed to archive landing page: ${(error as Error).message}` };
+ }
+ },
+ }),
});
diff --git a/cod-server/src/endpoints/landing-pages/handlers.ts b/cod-server/src/endpoints/landing-pages/handlers.ts
index 16f97d9..d84e9d2 100644
--- a/cod-server/src/endpoints/landing-pages/handlers.ts
+++ b/cod-server/src/endpoints/landing-pages/handlers.ts
@@ -36,12 +36,29 @@ export async function listLandingPages(c: Context) {
const withPublicUrl = await publicUrlDecorator(c);
const productId = c.req.query("productId");
const status = c.req.query("status");
- const data = await queries.listLandingPages(db, {
- ...(productId ? { productId } : {}),
- ...(status === "draft" || status === "published" || status === "archived"
- ? { status }
- : {}),
- });
+ // Route-validated query (coerced numbers); manual fallback parses the raw
+ // strings the same way the JSON-body pattern in this file does.
+ const valid = (c.req as any).valid?.("query") as
+ | { limit?: number; offset?: number }
+ | undefined;
+ const limit =
+ valid?.limit ?? (c.req.query("limit") !== undefined
+ ? Number(c.req.query("limit"))
+ : undefined);
+ const offset =
+ valid?.offset ?? (c.req.query("offset") !== undefined
+ ? Number(c.req.query("offset"))
+ : undefined);
+ const data = await queries.listLandingPages(
+ db,
+ {
+ ...(productId ? { productId } : {}),
+ ...(status === "draft" || status === "published" || status === "archived"
+ ? { status }
+ : {}),
+ },
+ { ...(limit !== undefined ? { limit } : {}), ...(offset !== undefined ? { offset } : {}) },
+ );
return c.json({ success: true, data: data.map(withPublicUrl), count: data.length }, 200);
}
diff --git a/cod-server/src/endpoints/landing-pages/landing-pages.api-e2e.test.ts b/cod-server/src/endpoints/landing-pages/landing-pages.api-e2e.test.ts
index 3ddae35..d7071a2 100644
--- a/cod-server/src/endpoints/landing-pages/landing-pages.api-e2e.test.ts
+++ b/cod-server/src/endpoints/landing-pages/landing-pages.api-e2e.test.ts
@@ -344,6 +344,67 @@ describe("Slice 2 — landing pages management API (real D1 + real routes)", ()
expect(compare.data.map((d: any) => d.slug).sort()).toEqual([slugA, slugB].sort());
});
+ it("list pagination: opt-in limit/offset; omitted → full list", async () => {
+ const productId = await seedProduct();
+ const rnd = crypto.randomUUID().slice(0, 6);
+ const ids: string[] = [];
+ for (let i = 0; i < 3; i++) {
+ const res = (await (
+ await createLp({ name: `Page ${i}`, productId, slug: `pg-${i}-${rnd}` })
+ ).json()) as any;
+ ids.push(res.data.id);
+ }
+
+ // No params → unbounded (dashboard contract).
+ const full = (await (
+ await app.request(`/api/landing-pages?productId=${productId}`)
+ ).json()) as any;
+ expect(full.data).toHaveLength(3);
+
+ // limit=2 → exactly 2 rows of this product.
+ const page1 = (await (
+ await app.request(`/api/landing-pages?productId=${productId}&limit=2`)
+ ).json()) as any;
+ expect(page1.data).toHaveLength(2);
+ expect(page1.count).toBe(2);
+ page1.data.forEach((row: any) => expect(row.productId).toBe(productId));
+
+ // limit=2&offset=2 → exactly 1 row.
+ const page2 = (await (
+ await app.request(`/api/landing-pages?productId=${productId}&limit=2&offset=2`)
+ ).json()) as any;
+ expect(page2.data).toHaveLength(1);
+ expect(page2.data[0].productId).toBe(productId);
+
+ // Pages cover the full set without overlap.
+ const paged = new Set([...page1.data, ...page2.data].map((d: any) => d.id));
+ expect([...paged].sort()).toEqual([...ids].sort());
+
+ // Out-of-range limit is a 400 validation error.
+ const bad = await app.request(`/api/landing-pages?limit=500`);
+ expect(bad.status).toBe(400);
+ });
+
+ it("getLandingPageById detail carries images + product + stats together (batched read)", async () => {
+ const productId = await seedProduct();
+ const rnd = crypto.randomUUID().slice(0, 6);
+ const created = (await (
+ await createLp({ name: "Detail page", productId, slug: `detail-${rnd}` })
+ ).json()) as any;
+
+ const detail = (await (
+ await app.request(`/api/landing-pages/${created.data.id}`)
+ ).json()) as any;
+ expect(detail.data.id).toBe(created.data.id);
+ expect(Array.isArray(detail.data.images)).toBe(true);
+ expect(detail.data.product).toMatchObject({ id: productId, price: expect.any(Number) });
+ expect(detail.data.stats).toMatchObject({
+ views: expect.any(Number),
+ orders: expect.any(Number),
+ revenue: expect.any(Number),
+ });
+ });
+
it("invalid slug format is a 400 validation error (not a 500)", async () => {
const productId = await seedProduct();
const res = await createLp({ name: "Bad slug", productId, slug: "Invalid Slug!" });
diff --git a/cod-server/src/endpoints/landing-pages/routes.ts b/cod-server/src/endpoints/landing-pages/routes.ts
index 9a148aa..d5450b8 100644
--- a/cod-server/src/endpoints/landing-pages/routes.ts
+++ b/cod-server/src/endpoints/landing-pages/routes.ts
@@ -40,6 +40,15 @@ const listQuery = z.object({
status: z.enum(["draft", "published", "archived"]).optional().openapi({
description: "Filter by lifecycle status",
}),
+ limit: z.coerce.number().int().min(1).max(200).optional().openapi({
+ description:
+ "Opt-in page size (1-200). Omitted → the full list (the dashboard fetches everything; LLM-facing clients should set a limit).",
+ example: 50,
+ }),
+ offset: z.coerce.number().int().min(0).optional().openapi({
+ description: "Opt-in page offset — pair with limit. Rows are newest-first.",
+ example: 0,
+ }),
});
// ─── Routes ───────────────────────────────────────────────────────────────────
@@ -51,7 +60,7 @@ const listLandingPagesRoute = defineRoute({
tags: ["Landing Pages"],
summary: "List landing pages",
description:
- "List landing pages (newest first) with per-page stats: views, attributed orders, revenue. Filter by product or status.",
+ "List landing pages (newest first) with per-page stats: views, attributed orders, revenue — orders count as placed regardless of status (cancelled/returned included). Filter by product or status. limit/offset are opt-in pagination; omitting them returns the full list.",
operationId: "listLandingPages",
query: listQuery,
responses: {
@@ -70,7 +79,7 @@ const compareLandingPagesRoute = defineRoute({
tags: ["Landing Pages"],
summary: "Compare landing pages of one product",
description:
- "The A/B view: every landing page of one product side by side with views, orders, revenue. Conversion rate = orders / views (undefined on zero views — compute client-side).",
+ "The A/B view: every landing page of one product side by side with views, orders, revenue — orders count as placed regardless of status (cancelled/returned included). Conversion rate = orders / views (undefined on zero views — compute client-side).",
operationId: "compareLandingPages",
query: z.object({
productId: z.string().openapi({ description: "Product UUID to compare pages for" }),
From 0761eb06a585d285a9dc2b7e24bc631af7a90078 Mon Sep 17 00:00:00 2001
From: Bilal Mansouri <124762008+bighadj22@users.noreply.github.com>
Date: Sat, 12 Sep 2026 20:55:00 +0100
Subject: [PATCH 3/6] feat(mcp): output schemas + structuredContent on all 96
tools
Every ai-tools domain exports an output-schema map built from a shared
success/failure envelope; the MCP surface advertises outputSchema on every
registered tool and validates each result's structuredContent against it
(drift logs loudly and falls back to the text result, never failing a
working tool). Payload fields documented for model consumption; DB-row
passthroughs declare guaranteed identity fields only.
---
.../src/endpoints/customer-groups/ai-tools.ts | 36 ++++++++
.../src/endpoints/customer-tags/ai-tools.ts | 35 ++++++++
.../src/endpoints/customers/ai-tools.ts | 58 +++++++++++++
.../src/endpoints/driver-payments/ai-tools.ts | 39 +++++++++
cod-server/src/endpoints/drivers/ai-tools.ts | 47 +++++++++++
cod-server/src/endpoints/offers/ai-tools.ts | 53 ++++++++++++
cod-server/src/endpoints/orders/ai-tools.ts | 84 +++++++++++++++++++
.../src/endpoints/product-groups/ai-tools.ts | 33 ++++++++
cod-server/src/endpoints/products/ai-tools.ts | 57 +++++++++++++
cod-server/src/endpoints/reviews/ai-tools.ts | 41 +++++++++
.../endpoints/shipping-profiles/ai-tools.ts | 72 ++++++++++++++++
cod-server/src/endpoints/stock/ai-tools.ts | 69 +++++++++++++++
cod-server/src/endpoints/variants/ai-tools.ts | 51 +++++++++++
cod-server/src/endpoints/wilayas/ai-tools.ts | 28 +++++++
14 files changed, 703 insertions(+)
diff --git a/cod-server/src/endpoints/customer-groups/ai-tools.ts b/cod-server/src/endpoints/customer-groups/ai-tools.ts
index e27995b..ffcfa75 100644
--- a/cod-server/src/endpoints/customer-groups/ai-tools.ts
+++ b/cod-server/src/endpoints/customer-groups/ai-tools.ts
@@ -7,6 +7,7 @@ import {
customerGroupFiltersSchema,
} from "./validation";
import { getDb } from "@/db";
+import { toolOutput, timestampSchema } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -46,6 +47,41 @@ export const CUSTOMER_GROUP_TOOL_SCHEMAS: Record = {
removeCustomerFromGroup: removeCustomerFromGroupSchema.shape,
};
+const customerGroupRowSchema = z.looseObject({
+ id: z.string().describe("Group UUID"),
+ name: z.string().describe("Names are not unique — distinguish groups by color or description"),
+ description: z.string().nullable().optional().describe("Internal policy note, never shown to shoppers"),
+ color: z.string().optional().describe("Hex color for dashboard identification"),
+ memberCount: z.number().int().optional().describe("Denormalized member counter — doubles as the deletion guard"),
+});
+
+export const CUSTOMER_GROUP_TOOL_OUTPUT_SCHEMAS: Record = {
+ listCustomerGroups: toolOutput({
+ count: z.number().int(),
+ groups: z.array(customerGroupRowSchema).describe("Customer groups"),
+ }),
+ getCustomerGroupDetails: toolOutput({
+ group: customerGroupRowSchema.describe("The group; includes members when withMembers was true"),
+ }),
+ createCustomerGroup: toolOutput({
+ group: customerGroupRowSchema.describe("The created group"),
+ message: z.string(),
+ }),
+ updateCustomerGroup: toolOutput({
+ group: customerGroupRowSchema.describe("The updated group"),
+ message: z.string(),
+ }),
+ deleteCustomerGroup: toolOutput({
+ message: z.string(),
+ }),
+ addCustomerToGroup: toolOutput({
+ message: z.string().describe("Idempotent — adding an existing member succeeds without change"),
+ }),
+ removeCustomerFromGroup: toolOutput({
+ message: z.string().describe("Silent on unknown pairings — returns success"),
+ }),
+};
+
/**
* AI Tools for Customer Group Management
*
diff --git a/cod-server/src/endpoints/customer-tags/ai-tools.ts b/cod-server/src/endpoints/customer-tags/ai-tools.ts
index a9606cc..a0074b8 100644
--- a/cod-server/src/endpoints/customer-tags/ai-tools.ts
+++ b/cod-server/src/endpoints/customer-tags/ai-tools.ts
@@ -7,6 +7,7 @@ import {
customerTagFiltersSchema,
} from "./validation";
import { getDb } from "@/db";
+import { toolOutput } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -46,6 +47,40 @@ export const CUSTOMER_TAG_TOOL_SCHEMAS: Record = {
unassignTagFromCustomer: unassignTagFromCustomerSchema.shape,
};
+const customerTagRowSchema = z.looseObject({
+ id: z.string().describe("Tag UUID"),
+ name: z.string().describe("Unique across the whole store"),
+ color: z.string().optional().describe("Hex color for dashboards"),
+ assignmentCount: z.number().int().optional().describe("Denormalized counter — doubles as the deletion guard"),
+});
+
+export const CUSTOMER_TAG_TOOL_OUTPUT_SCHEMAS: Record = {
+ listCustomerTags: toolOutput({
+ count: z.number().int(),
+ tags: z.array(customerTagRowSchema).describe("Customer tags"),
+ }),
+ getCustomerTagDetails: toolOutput({
+ tag: customerTagRowSchema.describe("The tag; includes assigned customers when withCustomers was true"),
+ }),
+ createCustomerTag: toolOutput({
+ tag: customerTagRowSchema.describe("The created tag"),
+ message: z.string(),
+ }),
+ updateCustomerTag: toolOutput({
+ tag: customerTagRowSchema.describe("The updated tag"),
+ message: z.string(),
+ }),
+ deleteCustomerTag: toolOutput({
+ message: z.string(),
+ }),
+ assignTagToCustomer: toolOutput({
+ message: z.string().describe("Idempotent — assigning an already-tagged customer succeeds without change"),
+ }),
+ unassignTagFromCustomer: toolOutput({
+ message: z.string().describe("Silent on unknown pairings — returns success"),
+ }),
+};
+
/**
* AI Tools for Customer Tag Management
*
diff --git a/cod-server/src/endpoints/customers/ai-tools.ts b/cod-server/src/endpoints/customers/ai-tools.ts
index 84a346e..c2b48a0 100644
--- a/cod-server/src/endpoints/customers/ai-tools.ts
+++ b/cod-server/src/endpoints/customers/ai-tools.ts
@@ -7,6 +7,7 @@ import {
updateCustomerSchema
} from "./validation";
import { getDb } from "@/db";
+import { toolOutput, timestampSchema } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -47,6 +48,63 @@ export const CUSTOMER_TOOL_SCHEMAS: Record = {
deleteCustomer: deleteCustomerSchema.shape,
};
+const customerRowSchema = z.looseObject({
+ id: z.string().describe("Customer UUID"),
+ name: z.string(),
+ phone: z.string().describe("Primary Algerian mobile (05/06/07) — the customer's identity anchor"),
+ wilaya: z.string().optional().describe("Wilaya display-name snapshot"),
+ commune: z.string().optional().describe("Commune display-name snapshot"),
+ totalSpent: z.number().optional().describe("Running value of kept orders in DZD"),
+ totalOrders: z.number().optional().describe("Orders ever placed, minus deleted ones"),
+ lastOrderAt: timestampSchema.nullable().optional().describe("Creation time of the most recent order, or null"),
+});
+
+export const CUSTOMER_TOOL_OUTPUT_SCHEMAS: Record = {
+ listCustomers: toolOutput({
+ count: z.number().int(),
+ customers: z.array(customerRowSchema).describe("Customers matching the filters"),
+ }),
+ getCustomerDetails: toolOutput({
+ customer: customerRowSchema.describe("The customer profile"),
+ }),
+ findCustomerByPhone: toolOutput({
+ customer: customerRowSchema.describe("The customer owning that phone number"),
+ }),
+ createNewCustomer: toolOutput({
+ customer: customerRowSchema.describe("The created customer"),
+ message: z.string(),
+ }),
+ updateCustomerProfile: toolOutput({
+ customer: customerRowSchema.describe("The updated customer"),
+ message: z.string(),
+ }),
+ getCustomerOrderHistory: toolOutput({
+ count: z.number().int(),
+ orders: z
+ .array(
+ z.looseObject({
+ id: z.string().describe("Order UUID"),
+ orderNumber: z.string().describe("Human-readable order number (ORD-YYYYMMDD-NNNN)"),
+ status: z.string().describe("Order lifecycle status"),
+ totalPrice: z.number().describe("Product subtotal excluding delivery fee (DZD)"),
+ createdAt: timestampSchema,
+ wilaya: z.string(),
+ commune: z.string(),
+ }),
+ )
+ .describe("The customer's orders, newest first"),
+ }),
+ getCustomerMemberships: toolOutput({
+ groups: z.array(z.looseObject({ id: z.string(), name: z.string() })).describe("Customer groups this customer belongs to"),
+ tags: z.array(z.looseObject({ id: z.string(), name: z.string() })).describe("Tags assigned to this customer"),
+ groupCount: z.number().int(),
+ tagCount: z.number().int(),
+ }),
+ deleteCustomer: toolOutput({
+ message: z.string(),
+ }),
+};
+
/**
* AI Tools for Customer Management
*
diff --git a/cod-server/src/endpoints/driver-payments/ai-tools.ts b/cod-server/src/endpoints/driver-payments/ai-tools.ts
index 8e8d069..7ab7cd6 100644
--- a/cod-server/src/endpoints/driver-payments/ai-tools.ts
+++ b/cod-server/src/endpoints/driver-payments/ai-tools.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import * as queries from "./queries";
import { createPaymentSchema } from "./validation";
import { getDb } from "@/db";
+import { toolOutput, timestampSchema } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -26,6 +27,44 @@ export const DRIVER_PAYMENT_TOOL_SCHEMAS: Record = {
createDriverSettlement: createDriverSettlementSchema.shape,
};
+export const DRIVER_PAYMENT_TOOL_OUTPUT_SCHEMAS: Record = {
+ listDriverPayments: toolOutput({
+ count: z.number().int(),
+ payments: z.array(
+ z.looseObject({
+ id: z.string().describe("Payment record UUID"),
+ type: z.string().describe("Settlement type (COD remittance, fee payment, or net settlement)"),
+ amount: z.number().describe("Server-computed settlement amount in DZD"),
+ orderCount: z.number().int().describe("Orders settled in this payment"),
+ createdAt: timestampSchema,
+ createdByName: z.string().describe("Who created the settlement (audit attribution)"),
+ notes: z.string().nullable(),
+ }),
+ ).describe("Settlement history, newest first"),
+ }),
+ getPendingSettlements: toolOutput({
+ count: z.number().int(),
+ orders: z.array(
+ z.looseObject({
+ id: z.string().describe("Order UUID"),
+ orderNumber: z.string(),
+ codAmount: z.number().describe("Customer cash the driver still owes the shop (DZD)"),
+ driverFee: z.number().describe("Frozen per-delivery fee owed to the driver (DZD)"),
+ updatedAt: timestampSchema,
+ status: z.string(),
+ }),
+ ).describe("Delivered orders awaiting settlement"),
+ }),
+ createDriverSettlement: toolOutput({
+ settlement: z.looseObject({
+ type: z.string().optional().describe("Settlement type"),
+ orderCount: z.number().int().optional().describe("Orders settled"),
+ amount: z.number().optional().describe("Settled amount in DZD"),
+ }).describe("The created payment record"),
+ message: z.string(),
+ }),
+};
+
/**
* AI Tools for Driver Payment Management
*
diff --git a/cod-server/src/endpoints/drivers/ai-tools.ts b/cod-server/src/endpoints/drivers/ai-tools.ts
index e48852d..4c21700 100644
--- a/cod-server/src/endpoints/drivers/ai-tools.ts
+++ b/cod-server/src/endpoints/drivers/ai-tools.ts
@@ -8,6 +8,7 @@ import {
updateDriverStatusSchema as updateDriverStatusInputSchema,
} from "./validation";
import { getDb } from "@/db";
+import { toolOutput } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -41,6 +42,52 @@ export const DRIVER_TOOL_SCHEMAS: Record = {
deleteDriver: deleteDriverSchema.shape,
};
+const driverRowSchema = z.looseObject({
+ id: z.string().describe("Driver UUID"),
+ firstName: z.string(),
+ lastName: z.string(),
+ phone: z.string().describe("Primary phone — uniquely identifies the driver"),
+ status: z.string().optional().describe("Availability: available | busy | inactive (changed only by its dedicated endpoint)"),
+ vehicleType: z.string().nullable().optional().describe("motorcycle | car | van, or null when unknown"),
+});
+
+export const DRIVER_TOOL_OUTPUT_SCHEMAS: Record = {
+ listDrivers: toolOutput({
+ count: z.number().int(),
+ drivers: z.array(
+ z.looseObject({
+ id: z.string().describe("Driver UUID"),
+ firstName: z.string(),
+ lastName: z.string(),
+ phone: z.string(),
+ status: z.string().describe("Availability: available | busy | inactive"),
+ vehicleType: z.string().nullable().describe("motorcycle | car | van, or null"),
+ compensationWilayaCount: z.number().int().describe("Wilayas with a configured pay rate — zero is legal but unpaid work"),
+ totalDelivered: z.number().int().describe("Delivered orders all time"),
+ pendingCash: z.number().describe("Collected customer cash not yet remitted, in DZD"),
+ }),
+ ).describe("Drivers matching the filters"),
+ }),
+ getDriverDetails: toolOutput({
+ driver: driverRowSchema.describe("Driver profile, compensation grid, and recent orders"),
+ }),
+ createNewDriver: toolOutput({
+ driver: driverRowSchema.describe("The created driver"),
+ message: z.string(),
+ }),
+ updateDriverProfile: toolOutput({
+ driver: driverRowSchema.describe("The updated driver"),
+ message: z.string(),
+ }),
+ updateDriverStatus: toolOutput({
+ driver: driverRowSchema.describe("The driver with its new availability status"),
+ message: z.string(),
+ }),
+ deleteDriver: toolOutput({
+ message: z.string(),
+ }),
+};
+
/**
* AI Tools for Driver Management
*
diff --git a/cod-server/src/endpoints/offers/ai-tools.ts b/cod-server/src/endpoints/offers/ai-tools.ts
index d76011b..2a9aa78 100644
--- a/cod-server/src/endpoints/offers/ai-tools.ts
+++ b/cod-server/src/endpoints/offers/ai-tools.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import * as queries from "./queries";
import { createOfferSchema, updateOfferSchema } from "./validation";
import { getDb } from "@/db";
+import { toolOutput, timestampSchema } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -31,6 +32,58 @@ export const OFFER_TOOL_SCHEMAS: Record = {
deleteOffer: deleteOfferSchema.shape,
};
+const offerProductRefSchema = z
+ .looseObject({
+ id: z.string().optional(),
+ name: z.string().optional(),
+ })
+ .nullable()
+ .describe("The referenced product, or null");
+
+const offerRowSchema = z.looseObject({
+ id: z.string().describe("Offer UUID"),
+ name: z.string(),
+ status: z.string().optional().describe("active | inactive — only active offers within schedule can qualify"),
+});
+
+export const OFFER_TOOL_OUTPUT_SCHEMAS: Record = {
+ listOffers: toolOutput({
+ count: z.number().int(),
+ offers: z.array(
+ z.looseObject({
+ id: z.string().describe("Offer UUID"),
+ name: z.string(),
+ status: z.string().describe("active | inactive"),
+ discountType: z.string().describe("free (Buy X Get Y reward) | free_shipping (delivery fee waived)"),
+ triggerProduct: offerProductRefSchema,
+ triggerVariant: z.looseObject({ id: z.string().optional() }).nullable().describe("The triggering variant, or null"),
+ triggerQuantity: z.number().int().describe("Minimum ordered units of the trigger product"),
+ rewardProduct: offerProductRefSchema,
+ rewardVariant: z.looseObject({ id: z.string().optional() }).nullable().describe("The rewarded variant, or null"),
+ rewardQuantity: z.number().int().describe("Free units granted on trigger"),
+ startsAt: timestampSchema.nullable().describe("Null means active immediately"),
+ endsAt: timestampSchema.nullable().describe("Null means it never expires"),
+ createdAt: timestampSchema,
+ updatedAt: timestampSchema,
+ }),
+ ).describe("Offers, newest first"),
+ }),
+ getOfferDetails: toolOutput({
+ offer: offerRowSchema.describe("The offer with resolved trigger and reward references"),
+ }),
+ createOffer: toolOutput({
+ offer: offerRowSchema.describe("The created offer"),
+ message: z.string(),
+ }),
+ updateOffer: toolOutput({
+ offer: offerRowSchema.describe("The updated offer"),
+ message: z.string(),
+ }),
+ deleteOffer: toolOutput({
+ message: z.string(),
+ }),
+};
+
/**
* AI Tools for Offer Management
*
diff --git a/cod-server/src/endpoints/orders/ai-tools.ts b/cod-server/src/endpoints/orders/ai-tools.ts
index 051765e..db0850f 100644
--- a/cod-server/src/endpoints/orders/ai-tools.ts
+++ b/cod-server/src/endpoints/orders/ai-tools.ts
@@ -10,6 +10,7 @@ import {
ORDER_STATUSES,
} from "./validation";
import { getDb } from "@/db";
+import { toolOutput, timestampSchema } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -53,6 +54,89 @@ export const ORDER_TOOL_SCHEMAS: Record = {
deleteOrder: deleteOrderSchema.shape,
};
+const orderRowSchema = z.looseObject({
+ id: z.string().describe("Order UUID"),
+ orderNumber: z.string().describe("Human-readable number (ORD-YYYYMMDD-NNNN) — what the customer sees"),
+ status: z.string().optional().describe("Lifecycle status; delivered, returned, and cancelled are terminal"),
+ customerName: z.string().optional(),
+ phone: z.string().optional(),
+ price: z.number().optional().describe("Product subtotal excluding delivery fee (DZD)"),
+ deliveryFee: z.number().optional().describe("Delivery charge to the customer (DZD)"),
+ codAmount: z.number().optional().describe("Cash the courier collects = price + deliveryFee (DZD)"),
+ deliveryType: z.string().optional().describe("home | stop_desk"),
+ trackingNumber: z.string().nullable().optional().describe("Carrier tracking number once dispatched, or null"),
+ driverId: z.string().nullable().optional().describe("Assigned driver UUID, or null"),
+});
+
+export const ORDER_TOOL_OUTPUT_SCHEMAS: Record = {
+ listOrders: toolOutput({
+ count: z.number().int(),
+ orders: z.array(
+ z.looseObject({
+ id: z.string().describe("Order UUID"),
+ orderNumber: z.string(),
+ status: z.string().describe("Lifecycle status"),
+ customerName: z.string(),
+ phone: z.string(),
+ wilaya: z.string(),
+ commune: z.string(),
+ price: z.number().describe("Product subtotal excluding delivery fee (DZD)"),
+ deliveryFee: z.number().describe("Delivery charge (DZD)"),
+ codAmount: z.number().describe("Cash the courier collects at the door (DZD)"),
+ deliveryType: z.string().describe("home | stop_desk"),
+ orderType: z.string().describe("online | offline"),
+ driverName: z.string().nullable().describe("Assigned driver's name, or null"),
+ trackingNumber: z.string().nullable().describe("Carrier tracking number, or null"),
+ companyId: z.string().nullable().describe("Carrier company ID once dispatched, or null"),
+ hasReview: z.boolean().describe("Whether the anchor review exists"),
+ createdAt: timestampSchema,
+ updatedAt: timestampSchema,
+ }),
+ ).describe("Orders, newest first"),
+ }),
+ getOrderDetails: toolOutput({
+ order: orderRowSchema.describe("Full order: product lines, status history, place names, and delivery references"),
+ }),
+ createOrder: toolOutput({
+ order: z.looseObject({
+ id: z.string().describe("Order UUID"),
+ orderNumber: z.string(),
+ status: z.literal("new").describe("Newly created — not yet confirmed"),
+ deliveryFee: z.number().describe("Delivery charge (DZD)"),
+ price: z.number().describe("Product subtotal (DZD)"),
+ codAmount: z.number().describe("Cash the courier collects = price + deliveryFee (DZD)"),
+ customerId: z.string(),
+ customerName: z.string(),
+ phone: z.string(),
+ wilayaId: z.number().int().describe("Wilaya number 1–58"),
+ communeId: z.string().nullable().describe("Commune ID (c-XX-YYY), or null"),
+ deliveryType: z.enum(["home", "stop_desk"]),
+ orderType: z.enum(["online", "offline"]),
+ }).describe("The created order — inventory was deducted"),
+ message: z.string(),
+ }),
+ updateOrderStatus: toolOutput({
+ message: z.string(),
+ previousStatus: z.string(),
+ newStatus: z.string().describe("cancelled/returned restore inventory; delivered/returned/cancelled are terminal"),
+ }),
+ assignDriverToOrder: toolOutput({
+ message: z.string(),
+ }),
+ unassignDriverFromOrder: toolOutput({
+ message: z.string().describe("Allowed until out_for_delivery"),
+ }),
+ recordOrderProductReturn: toolOutput({
+ result: z.looseObject({
+ status: z.enum(["fulfilled", "partially_returned", "returned"]).optional().describe("The order's return state after this line return"),
+ }).describe("Return outcome"),
+ message: z.string(),
+ }),
+ deleteOrder: toolOutput({
+ message: z.string().describe("Deletion restores all remaining inventory"),
+ }),
+};
+
/**
* AI Tools for Order Management
*
diff --git a/cod-server/src/endpoints/product-groups/ai-tools.ts b/cod-server/src/endpoints/product-groups/ai-tools.ts
index 8916ac2..79dfdc4 100644
--- a/cod-server/src/endpoints/product-groups/ai-tools.ts
+++ b/cod-server/src/endpoints/product-groups/ai-tools.ts
@@ -7,6 +7,7 @@ import {
updateGroupSchema,
} from "./validation";
import { getDb } from "@/db";
+import { toolOutput } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -35,6 +36,38 @@ export const PRODUCT_GROUP_TOOL_SCHEMAS: Record = {
deleteProductGroup: deleteProductGroupSchema.shape,
};
+const productGroupRowSchema = z.looseObject({
+ id: z.string().describe("Group UUID"),
+ name: z.string(),
+ slug: z.string().describe("Unique URL-safe identifier"),
+ description: z.string().nullable().optional(),
+ parentId: z.string().nullable().optional().describe("Parent group UUID — null for top-level groups"),
+ imageUrl: z.string().nullable().optional(),
+ position: z.number().int().optional().describe("Display order among siblings; lower sorts first"),
+ productsCount: z.number().int().optional().describe("Non-deleted products filed under this group (any status) — doubles as the deletion guard"),
+});
+
+export const PRODUCT_GROUP_TOOL_OUTPUT_SCHEMAS: Record = {
+ listProductGroups: toolOutput({
+ count: z.number().int(),
+ groups: z.array(productGroupRowSchema).describe("Groups ordered by position"),
+ }),
+ getProductGroupDetails: toolOutput({
+ group: productGroupRowSchema.describe("The group, including immediate child groups"),
+ }),
+ createProductGroup: toolOutput({
+ group: productGroupRowSchema.describe("The created group"),
+ message: z.string(),
+ }),
+ updateProductGroup: toolOutput({
+ group: productGroupRowSchema.describe("The updated group"),
+ message: z.string(),
+ }),
+ deleteProductGroup: toolOutput({
+ message: z.string(),
+ }),
+};
+
/**
* AI Tools for Product Group Management
*
diff --git a/cod-server/src/endpoints/products/ai-tools.ts b/cod-server/src/endpoints/products/ai-tools.ts
index 1f006d1..affb6d9 100644
--- a/cod-server/src/endpoints/products/ai-tools.ts
+++ b/cod-server/src/endpoints/products/ai-tools.ts
@@ -8,6 +8,7 @@ import {
updateStatusSchema,
} from "./validation";
import { getDb } from "@/db";
+import { toolOutput, timestampSchema } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -41,6 +42,62 @@ export const PRODUCT_TOOL_SCHEMAS: Record = {
deleteProduct: deleteProductSchema.shape,
};
+const productRowSchema = z.looseObject({
+ id: z.string().describe("Product UUID"),
+ name: z.string(),
+ handle: z.string().describe("Unique URL slug"),
+ price: z.number().describe("Price in DZD (integer, smallest unit)"),
+ sku: z.string().nullable().optional().describe("Merchant-facing code — set on simple products only"),
+ status: z.string().optional().describe("DRAFT | ACTIVE | ARCHIVED"),
+ visibility: z.boolean().optional().describe("Master internal switch — off means hidden everywhere"),
+ hasVariants: z.boolean().optional().describe("True → stock and pricing live on variants"),
+});
+
+export const PRODUCT_TOOL_OUTPUT_SCHEMAS: Record = {
+ listProducts: toolOutput({
+ count: z.number().int(),
+ products: z.array(
+ z.looseObject({
+ id: z.string().describe("Product UUID"),
+ name: z.string(),
+ handle: z.string().describe("Unique URL slug"),
+ sku: z.string().nullable().describe("Null on variant products (SKU lives on each variant)"),
+ price: z.number().describe("Price in DZD"),
+ status: z.string().describe("DRAFT | ACTIVE | ARCHIVED"),
+ visibility: z.boolean(),
+ hasVariants: z.boolean(),
+ variantsCount: z.number().int(),
+ totalInventory: z.number().int().describe("Variant stock sum, or own stock for simple products"),
+ primaryImageSrc: z.string().nullable(),
+ categoryId: z.string().nullable(),
+ tags: z.array(z.string()),
+ reviewCount: z.number().int().describe("Approved reviews only"),
+ avgRating: z.number().nullable().describe("Average of approved reviews, or null"),
+ showInStore: z.boolean().describe("Storefront-only visibility switch"),
+ storeFeatured: z.boolean(),
+ }),
+ ).describe("Products matching the filters"),
+ }),
+ getProductDetails: toolOutput({
+ product: productRowSchema.describe("Full product: variants, images, category, and review aggregates"),
+ }),
+ createNewProduct: toolOutput({
+ product: productRowSchema.describe("The created product"),
+ message: z.string(),
+ }),
+ updateProductDetails: toolOutput({
+ product: productRowSchema.describe("The updated product"),
+ message: z.string(),
+ }),
+ updateProductStatus: toolOutput({
+ product: productRowSchema.describe("The product with its new status"),
+ message: z.string(),
+ }),
+ deleteProduct: toolOutput({
+ message: z.string(),
+ }),
+};
+
/**
* AI Tools for Product Management
*
diff --git a/cod-server/src/endpoints/reviews/ai-tools.ts b/cod-server/src/endpoints/reviews/ai-tools.ts
index 1264cc5..e3b84b5 100644
--- a/cod-server/src/endpoints/reviews/ai-tools.ts
+++ b/cod-server/src/endpoints/reviews/ai-tools.ts
@@ -2,6 +2,7 @@ import { tool } from "ai";
import { z } from "zod";
import * as queries from "./queries";
import { getDb } from "@/db";
+import { toolOutput, timestampSchema } from "@/lib/tool-output-schema";
/**
* AI Tools for Review Moderation
@@ -58,6 +59,46 @@ export const REVIEW_TOOL_SCHEMAS: Record = {
deleteReview: deleteReviewSchema.shape,
};
+const reviewRowSchema = z.looseObject({
+ id: z.string().describe("Review UUID"),
+ productId: z.string().optional(),
+ rating: z.number().int().optional().describe("Whole stars 1–5"),
+ status: z.string().optional().describe("pending | approved | rejected — only approved appear on the storefront"),
+ customerName: z.string().optional().describe("Snapshot copied at submission time"),
+});
+
+export const REVIEW_TOOL_OUTPUT_SCHEMAS: Record = {
+ listReviews: toolOutput({
+ count: z.number().int().describe("Reviews on this page"),
+ total: z.number().int().describe("Total matching the filters across all pages"),
+ pendingCount: z.number().int().describe("Global pending count — returned regardless of filters (drives the moderation badge)"),
+ reviews: z.array(
+ z.looseObject({
+ id: z.string().describe("Review UUID"),
+ productId: z.string(),
+ productName: z.string(),
+ orderId: z.string().describe("The anchor order — one review per order, ever"),
+ orderNumber: z.string(),
+ customerName: z.string(),
+ rating: z.number().int().describe("Whole stars 1–5"),
+ title: z.string().nullable(),
+ body: z.string().nullable(),
+ status: z.string().describe("pending | approved | rejected"),
+ helpfulCount: z.number().int(),
+ createdAt: timestampSchema,
+ updatedAt: timestampSchema,
+ }),
+ ).describe("Reviews, newest first"),
+ }),
+ moderateReview: toolOutput({
+ review: reviewRowSchema.describe("The review with its new moderation status"),
+ message: z.string(),
+ }),
+ deleteReview: toolOutput({
+ message: z.string().describe("Deletion is permanent — no soft delete, no archive"),
+ }),
+};
+
export const getReviewTools = (db: ReturnType) => ({
listReviews: tool({
diff --git a/cod-server/src/endpoints/shipping-profiles/ai-tools.ts b/cod-server/src/endpoints/shipping-profiles/ai-tools.ts
index e923485..2c4305b 100644
--- a/cod-server/src/endpoints/shipping-profiles/ai-tools.ts
+++ b/cod-server/src/endpoints/shipping-profiles/ai-tools.ts
@@ -8,6 +8,7 @@ import {
communeOverrideSchema,
} from "./validation";
import { getDb } from "@/db";
+import { toolOutput } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -64,6 +65,77 @@ export const SHIPPING_PROFILE_TOOL_SCHEMAS: Record = {
resetShippingCommuneOverride: resetShippingCommuneOverrideSchema.shape,
};
+const shippingRuleSchema = z.looseObject({
+ wilayaId: z.number().int().optional().describe("Wilaya number 1–58"),
+ wilayaName: z.string().optional(),
+ homePrice: z.number().optional().describe("Home delivery price in DZD"),
+ stopDeskPrice: z.number().optional().describe("Stop-desk pickup price in DZD"),
+ homeEnabled: z.boolean().optional().describe("False → this profile cannot deliver home there"),
+ stopDeskEnabled: z.boolean().optional(),
+});
+
+const shippingProfileRowSchema = z.looseObject({
+ id: z.string().describe("Profile ID (plain string, not a UUID)"),
+ name: z.string(),
+ isDefault: z.boolean().optional().describe("Exactly one profile is default at any time"),
+ ruleCount: z.number().int().optional().describe("Wilaya rules configured"),
+ productCount: z.number().int().optional().describe("Products using this profile instead of the default"),
+ rules: z.array(shippingRuleSchema).optional().describe("Per-wilaya rules"),
+});
+
+export const SHIPPING_PROFILE_TOOL_OUTPUT_SCHEMAS: Record = {
+ listShippingProfiles: toolOutput({
+ count: z.number().int(),
+ profiles: z.array(shippingProfileRowSchema).describe("All shipping rate profiles"),
+ }),
+ getShippingProfile: toolOutput({
+ profile: shippingProfileRowSchema.describe("The profile with its per-wilaya rules"),
+ }),
+ getDefaultShippingRules: toolOutput({
+ count: z.number().int(),
+ rules: z.array(shippingRuleSchema).describe("The default profile's rules — the storefront fee source"),
+ }),
+ createShippingProfile: toolOutput({
+ profile: shippingProfileRowSchema.describe("The created profile"),
+ message: z.string(),
+ }),
+ updateShippingProfile: toolOutput({
+ profile: shippingProfileRowSchema.describe("The updated profile"),
+ message: z.string(),
+ }),
+ deleteShippingProfile: toolOutput({
+ message: z.string(),
+ }),
+ setShippingProfileRules: toolOutput({
+ profile: shippingProfileRowSchema.describe("The profile after the full atomic rule replacement"),
+ message: z.string(),
+ }),
+ listCommuneOverrides: toolOutput({
+ count: z.number().int(),
+ communes: z.array(
+ z.looseObject({
+ id: z.string().optional().describe("Commune ID (c-XX-YYY)"),
+ name: z.string().optional(),
+ rule: z
+ .looseObject({
+ id: z.string().optional().describe("The override row ID, or the inherited rule when no override exists"),
+ homeEnabled: z.boolean().optional(),
+ stopDeskEnabled: z.boolean().optional(),
+ homePrice: z.number().optional().nullable().describe("Effective home price — override where set, wilaya rule where null"),
+ stopDeskPrice: z.number().optional().nullable().describe("Effective stop-desk price — override where set, wilaya rule where null"),
+ })
+ .optional(),
+ }),
+ ).describe("The wilaya's communes with their override state"),
+ }),
+ setShippingCommuneOverride: toolOutput({
+ message: z.string(),
+ }),
+ resetShippingCommuneOverride: toolOutput({
+ message: z.string(),
+ }),
+};
+
/**
* AI Tools for Shipping Profile Management
*
diff --git a/cod-server/src/endpoints/stock/ai-tools.ts b/cod-server/src/endpoints/stock/ai-tools.ts
index 2140fe5..c6bfa54 100644
--- a/cod-server/src/endpoints/stock/ai-tools.ts
+++ b/cod-server/src/endpoints/stock/ai-tools.ts
@@ -9,6 +9,7 @@ import {
MOVEMENT_TYPES,
} from "./validation";
import { getDb } from "@/db";
+import { toolOutput, timestampSchema } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -59,6 +60,74 @@ export const STOCK_TOOL_SCHEMAS: Record = {
updateVariantStockThreshold: updateVariantStockThresholdToolSchema.shape,
};
+const stockAlertItemSchema = z.looseObject({
+ productId: z.string(),
+ variantId: z.string().nullable().describe("Null for simple products"),
+ productName: z.string(),
+ variantLabel: z.string().nullable().describe('Option values joined with " / ", or null for simple products'),
+ sku: z.string().nullable(),
+ inventory: z.number().int(),
+ lowStockThreshold: z.number().int(),
+ isOutOfStock: z.boolean(),
+ updatedAt: timestampSchema,
+});
+
+const stockMovementRowSchema = z.looseObject({
+ id: z.string(),
+ productId: z.string(),
+ variantId: z.string().nullable(),
+ type: z.enum(MOVEMENT_TYPES).describe("Kind of change — PURCHASE and ADJUSTMENT_* are manual; ORDER_* are order-driven automation"),
+ delta: z.number().int().describe("Signed change — positive = stock arriving, negative = stock leaving"),
+ qtyBefore: z.number().int(),
+ qtyAfter: z.number().int(),
+ reason: z.string().nullable().describe("Required on manual movement types"),
+ reference: z.string().nullable(),
+ createdBy: z.string(),
+ createdByName: z.string(),
+ createdAt: timestampSchema,
+});
+
+export const STOCK_TOOL_OUTPUT_SCHEMAS: Record = {
+ getStockOverview: toolOutput({
+ overview: z
+ .looseObject({
+ totalSkus: z.number().int().describe("Tracked SKUs — simple products plus active variants"),
+ outOfStockCount: z.number().int(),
+ lowStockCount: z.number().int(),
+ totalInventoryValue: z.number().describe("Valued at each SKU's selling price, in DZD"),
+ currency: z.string(),
+ outOfStockItems: z.array(stockAlertItemSchema).describe("Most urgent first — out-of-stock leads"),
+ lowStockItems: z.array(stockAlertItemSchema),
+ allItems: z.array(stockAlertItemSchema).describe("Every tracked SKU regardless of stock level"),
+ })
+ .describe("Health snapshot recomputed from live tables on every call"),
+ }),
+ getStockAlerts: toolOutput({
+ items: z.array(stockAlertItemSchema).describe("Attention list — out-of-stock first, then by rising inventory"),
+ total: z.number().int().describe("Total matching rows across all pages"),
+ }),
+ getProductStockHistory: toolOutput({
+ movements: z.array(stockMovementRowSchema).describe("The SKU's ledger, newest first"),
+ total: z.number().int().describe("Total matching rows across all pages"),
+ }),
+ adjustProductStock: toolOutput({
+ movement: stockMovementRowSchema.describe("The created ledger row — immutable once written"),
+ currentInventory: z.number().int().describe("The product's inventory after the adjustment"),
+ message: z.string(),
+ }),
+ adjustVariantStock: toolOutput({
+ movement: stockMovementRowSchema.describe("The created ledger row — immutable once written"),
+ currentInventory: z.number().int().describe("The variant's inventory after the adjustment"),
+ message: z.string(),
+ }),
+ updateProductStockThreshold: toolOutput({
+ message: z.string(),
+ }),
+ updateVariantStockThreshold: toolOutput({
+ message: z.string(),
+ }),
+};
+
/**
* AI Tools for Stock / Inventory Management
*
diff --git a/cod-server/src/endpoints/variants/ai-tools.ts b/cod-server/src/endpoints/variants/ai-tools.ts
index dae3bdb..f3109a7 100644
--- a/cod-server/src/endpoints/variants/ai-tools.ts
+++ b/cod-server/src/endpoints/variants/ai-tools.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import * as queries from "./queries";
import { createVariantSchema, updateVariantSchema } from "./validation";
import { getDb } from "@/db";
+import { toolOutput } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -36,6 +37,56 @@ export const VARIANT_TOOL_SCHEMAS: Record = {
deleteProductVariant: deleteProductVariantSchema.shape,
};
+const variantRowSchema = z.looseObject({
+ id: z.string().describe("Variant UUID"),
+ productId: z.string().describe("Parent product UUID"),
+ price: z.number().describe("Price in DZD — independent of the parent's price"),
+ sku: z.string().optional().describe("Globally unique merchant-facing code"),
+ variations: z.record(z.string(), z.string()).optional().describe('The combination as option-axis → value, e.g. {"Color": "Red", "Size": "M"}'),
+ inventory: z.number().int().optional(),
+ isDefault: z.boolean().optional().describe("Storefront pre-selects this variant when true"),
+ active: z.boolean().optional().describe("False → hidden from the storefront, data kept"),
+ position: z.number().int().optional().describe("Display order; listings sort by it"),
+});
+
+export const VARIANT_TOOL_OUTPUT_SCHEMAS: Record = {
+ listProductVariants: toolOutput({
+ count: z.number().int(),
+ variants: z.array(
+ z.looseObject({
+ id: z.string().describe("Variant UUID"),
+ productId: z.string(),
+ variations: z.record(z.string(), z.string()).describe('The combination, e.g. {"Color": "Red", "Size": "M"}'),
+ price: z.number().describe("Price in DZD"),
+ compareAtPrice: z.number().nullable().describe("Strike-through anchor price, or null"),
+ sku: z.string(),
+ barcode: z.string().nullable(),
+ inventory: z.number().int(),
+ lowStockThreshold: z.number().int(),
+ weightKg: z.number().nullable(),
+ imageId: z.string().nullable().describe("Linked product-image UUID, or null"),
+ isDefault: z.boolean(),
+ active: z.boolean(),
+ position: z.number().int(),
+ }),
+ ).describe("The product's variants, ordered by position"),
+ }),
+ getVariantDetails: toolOutput({
+ variant: variantRowSchema.describe("The variant"),
+ }),
+ createProductVariant: toolOutput({
+ variant: variantRowSchema.describe("The created variant"),
+ message: z.string(),
+ }),
+ updateVariant: toolOutput({
+ variant: variantRowSchema.describe("The updated variant"),
+ message: z.string(),
+ }),
+ deleteProductVariant: toolOutput({
+ message: z.string().describe("Confirms deletion; order history keeps its label and SKU text"),
+ }),
+};
+
/**
* AI Tools for Product Variant Management
*
diff --git a/cod-server/src/endpoints/wilayas/ai-tools.ts b/cod-server/src/endpoints/wilayas/ai-tools.ts
index 6ce8d6d..4544f92 100644
--- a/cod-server/src/endpoints/wilayas/ai-tools.ts
+++ b/cod-server/src/endpoints/wilayas/ai-tools.ts
@@ -3,6 +3,7 @@ import { z } from "zod";
import * as queries from "./queries";
import { wilayaFiltersSchema } from "./validation";
import { getDb } from "@/db";
+import { toolOutput } from "@/lib/tool-output-schema";
/**
* Layer-2 validation schemas, hoisted to module level and exported so the MCP
@@ -25,6 +26,33 @@ export const WILAYA_TOOL_SCHEMAS: Record = {
listWilayaCommunes: listWilayaCommunesSchema.shape,
};
+export const WILAYA_TOOL_OUTPUT_SCHEMAS: Record = {
+ listWilayas: toolOutput({
+ count: z.number().int(),
+ wilayas: z.array(
+ z.looseObject({
+ id: z.number().int().describe("Official wilaya number (1–58) — used in orders, shipping rules, and driver pay grids; NOT a UUID"),
+ name: z.string().describe("French name"),
+ nameAr: z.string().describe("Arabic name"),
+ }),
+ ).describe("All 58 wilayas in official-number order"),
+ }),
+ listWilayaCommunes: toolOutput({
+ wilaya: z.looseObject({
+ id: z.number().int(),
+ name: z.string(),
+ nameAr: z.string(),
+ }).describe("The wilaya"),
+ count: z.number().int(),
+ communes: z.array(
+ z.looseObject({
+ id: z.string().describe('Commune ID in c-XX-YYY format (e.g. "c-16-001") — use this for precise addresses, NOT a UUID'),
+ name: z.string(),
+ }),
+ ).describe("Communes of the wilaya, alphabetical by Latin name"),
+ }),
+};
+
/**
* AI Tools for Wilaya & Commune Reference Data
*
From ebcfcbcfdc93606f77bef1007609f740e6238e40 Mon Sep 17 00:00:00 2001
From: Bilal Mansouri <124762008+bighadj22@users.noreply.github.com>
Date: Sat, 12 Sep 2026 20:55:45 +0100
Subject: [PATCH 4/6] feat(mcp): annotations, titles, rate limiting,
client-side confirmation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ToolAnnotations derived from the platform's own classifications (read
scopes => readOnlyHint, DANGEROUS_TOOLS => destructiveHint) with
consistency tests; human-readable titles on all 96 tools
- per-subject tool-call rate limit (fixed window in RATE_LIMIT KV,
fail-open) keyed by the client's openai/subject hint; client hints
(subject/session) join the audit trail — correlation only, never
authorization
- removed the server-side two-round confirmation gate: per the platform
docs, write-action approval is the client's job, framed by our
destructiveHint annotations; the server gates by scope, validates,
rate-limits, and audits (confirm-tool + MCP_REQUEST_STATE_KEY removed)
- consent page redesign: real brand wordmark (light/dark), grouped
permission chips with localized domain/action labels (ar/fr/en),
account identity line, dark mode, RTL — all client-controlled values
escaped
- audit redaction for oversized string arguments
---
cod-server/src/mcp/annotations.test.ts | 124 ++++++++
cod-server/src/mcp/annotations.ts | 90 ++++++
cod-server/src/mcp/authorize.test.ts | 114 +++++++
cod-server/src/mcp/authorize.ts | 342 +++++++++++++++++----
cod-server/src/mcp/brand.ts | 14 +
cod-server/src/mcp/confirm-tool.test.ts | 184 -----------
cod-server/src/mcp/confirm-tool.ts | 111 -------
cod-server/src/mcp/elicit.test.ts | 15 +-
cod-server/src/mcp/elicit.ts | 27 +-
cod-server/src/mcp/execute-tool.test.ts | 175 ++++++++++-
cod-server/src/mcp/execute-tool.ts | 83 ++++-
cod-server/src/mcp/registry.test.ts | 60 +++-
cod-server/src/mcp/registry.ts | 21 +-
cod-server/src/mcp/request-context.test.ts | 142 +++++++++
cod-server/src/mcp/request-context.ts | 77 +++++
cod-server/src/mcp/schemas.test.ts | 49 ++-
cod-server/src/mcp/schemas.ts | 83 ++++-
cod-server/src/mcp/server-factory.test.ts | 203 ++++++++----
cod-server/src/mcp/server-factory.ts | 121 +++++---
cod-server/src/mcp/tool-titles.ts | 134 ++++++++
20 files changed, 1649 insertions(+), 520 deletions(-)
create mode 100644 cod-server/src/mcp/annotations.test.ts
create mode 100644 cod-server/src/mcp/annotations.ts
create mode 100644 cod-server/src/mcp/brand.ts
delete mode 100644 cod-server/src/mcp/confirm-tool.test.ts
delete mode 100644 cod-server/src/mcp/confirm-tool.ts
create mode 100644 cod-server/src/mcp/request-context.test.ts
create mode 100644 cod-server/src/mcp/request-context.ts
create mode 100644 cod-server/src/mcp/tool-titles.ts
diff --git a/cod-server/src/mcp/annotations.test.ts b/cod-server/src/mcp/annotations.test.ts
new file mode 100644
index 0000000..98c8ae5
--- /dev/null
+++ b/cod-server/src/mcp/annotations.test.ts
@@ -0,0 +1,124 @@
+/**
+ * TOOL_ANNOTATIONS consistency — the machine-checked proof that advertised
+ * behavior hints match the platform's actual classifications.
+ *
+ * Invariants (each maps to an OpenAI review rule):
+ * 1. Full coverage: every registered tool has annotations, no extras.
+ * 2. Dangerous ⇄ destructive: the HITL set and destructiveHint agree in
+ * BOTH directions — mislabeling is a documented plugin rejection reason.
+ * 3. Read-scope gating ⇒ readOnlyHint: a tool registered only under
+ * `*:read` scopes claims read-only.
+ * 4. readOnlyHint claims are justified: only read-scoped tools or the
+ * explicit allowlist may claim it.
+ * 5. openWorldHint is true only for the tools that fetch public URLs.
+ * 6. Reads and documented-idempotent writes claim idempotentHint.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+ TOOL_ANNOTATIONS,
+ READ_ONLY_OVERRIDES,
+ OPEN_WORLD_TOOLS,
+ IDEMPOTENT_WRITE_TOOLS,
+} from "./annotations";
+import { TOOL_NAMES } from "./schemas";
+import { DANGEROUS_TOOLS } from "./elicit";
+import { TOOL_REGISTRY } from "./registry";
+import { TOOL_TITLES } from "./tool-titles";
+
+function readScopeToolNames(): Set {
+ const out = new Set();
+ for (const entry of TOOL_REGISTRY) {
+ if (entry.requires.every((s) => s.endsWith(":read"))) {
+ for (const name of Object.keys(entry.build({} as never, {} as never, {} as never))) {
+ out.add(name);
+ }
+ }
+ }
+ return out;
+}
+
+describe("TOOL_ANNOTATIONS", () => {
+ it("covers exactly the registered tool set (no drift, both directions)", () => {
+ expect(Object.keys(TOOL_ANNOTATIONS).sort()).toEqual(TOOL_NAMES);
+ });
+
+ it("every confirmation-gated (dangerous) tool claims destructiveHint: true", () => {
+ for (const name of DANGEROUS_TOOLS) {
+ expect(TOOL_ANNOTATIONS[name]?.destructiveHint, `${name} is dangerous`).toBe(true);
+ }
+ });
+
+ it("no non-dangerous tool claims destructiveHint: true", () => {
+ for (const name of TOOL_NAMES) {
+ if (!DANGEROUS_TOOLS.has(name)) {
+ expect(TOOL_ANNOTATIONS[name]?.destructiveHint, `${name} is not dangerous`).toBe(false);
+ }
+ }
+ });
+
+ it("tools gated only by read scopes claim readOnlyHint: true", () => {
+ const reads = readScopeToolNames();
+ expect(reads.size).toBeGreaterThan(0);
+ for (const name of reads) {
+ expect(TOOL_ANNOTATIONS[name]?.readOnlyHint, `${name} is read-scoped`).toBe(true);
+ }
+ });
+
+ it("readOnlyHint claims come only from read-scoped tools or the explicit allowlist", () => {
+ const reads = readScopeToolNames();
+ for (const name of TOOL_NAMES) {
+ if (TOOL_ANNOTATIONS[name]?.readOnlyHint === true) {
+ const justified = reads.has(name) || READ_ONLY_OVERRIDES.has(name);
+ expect(justified, `${name} claims readOnly without justification`).toBe(true);
+ }
+ }
+ });
+
+ it("write tools claim readOnlyHint: false", () => {
+ expect(TOOL_ANNOTATIONS["createLandingPage"]?.readOnlyHint).toBe(false);
+ expect(TOOL_ANNOTATIONS["uploadLandingPageImage"]?.readOnlyHint).toBe(false);
+ expect(TOOL_ANNOTATIONS["updateOrderStatus"]?.readOnlyHint).toBe(false);
+ });
+
+ it("the upload tool is the only open-world tool (it fetches public image URLs)", () => {
+ expect(TOOL_ANNOTATIONS["uploadLandingPageImage"]?.openWorldHint).toBe(true);
+ for (const name of TOOL_NAMES) {
+ if (!OPEN_WORLD_TOOLS.has(name)) {
+ expect(TOOL_ANNOTATIONS[name]?.openWorldHint, `${name} is not open-world`).toBe(false);
+ }
+ }
+ });
+
+ it("read tools are idempotent; writes are not unless documented as such", () => {
+ expect(TOOL_ANNOTATIONS["listCustomers"]?.idempotentHint).toBe(true);
+ expect(TOOL_ANNOTATIONS["getLandingPageImageUploadStatus"]?.idempotentHint).toBe(true);
+ expect(TOOL_ANNOTATIONS["createOrder"]?.idempotentHint).toBe(false);
+ expect(TOOL_ANNOTATIONS["deleteLandingPage"]?.idempotentHint).toBe(false);
+
+ // The membership tools are documented idempotent (onConflictDoNothing).
+ for (const name of IDEMPOTENT_WRITE_TOOLS) {
+ expect(TOOL_ANNOTATIONS[name]?.idempotentHint, `${name} is documented idempotent`).toBe(true);
+ expect(TOOL_ANNOTATIONS[name]?.readOnlyHint).toBe(false);
+ }
+ });
+
+ it("the manage-gated status poll is overridden to read-only", () => {
+ expect(TOOL_ANNOTATIONS["getLandingPageImageUploadStatus"]?.readOnlyHint).toBe(true);
+ expect(TOOL_ANNOTATIONS["getLandingPageImageUploadStatus"]?.destructiveHint).toBe(false);
+ });
+});
+
+describe("TOOL_TITLES", () => {
+ it("covers exactly the registered tool set", () => {
+ expect(Object.keys(TOOL_TITLES).sort()).toEqual(TOOL_NAMES);
+ });
+
+ it("titles are short, human-readable, and capitalized", () => {
+ for (const [name, title] of Object.entries(TOOL_TITLES)) {
+ expect(title.length, `${name}`).toBeGreaterThan(3);
+ expect(title.length, `${name}`).toBeLessThanOrEqual(40);
+ expect(title[0], `${name}`).toMatch(/[A-Z]/);
+ }
+ });
+});
diff --git a/cod-server/src/mcp/annotations.ts b/cod-server/src/mcp/annotations.ts
new file mode 100644
index 0000000..ce18a60
--- /dev/null
+++ b/cod-server/src/mcp/annotations.ts
@@ -0,0 +1,90 @@
+/**
+ * Tool annotations — derived, not hand-maintained.
+ *
+ * The MCP `ToolAnnotations` hints (readOnlyHint, destructiveHint,
+ * idempotentHint, openWorldHint) tell clients like ChatGPT how cautious to be
+ * with each tool: they drive confirmation framing and safety behavior.
+ * OpenAI's plugin reference marks them Required; mislabeling is a documented
+ * rejection reason.
+ *
+ * Instead of 96 hand-written entries that drift, annotations are DERIVED from
+ * the two classifications the platform already owns:
+ * • scope gating (TOOL_REGISTRY) — a tool registered ONLY under `*:read`
+ * scopes cannot mutate anything, so it is read-only and idempotent
+ * • risk classification (DANGEROUS_TOOLS) — every confirmation-gated tool
+ * is destructive
+ *
+ * Explicit overrides cover the few tools whose annotation differs from what
+ * scope-gating alone would imply. The consistency invariants are proven by
+ * tests in annotations.test.ts — a new registry tool automatically gets
+ * correct annotations, and a misclassified one fails CI.
+ */
+import type { ToolAnnotations } from "@modelcontextprotocol/server";
+import { TOOL_REGISTRY } from "./registry";
+import { TOOL_NAMES, TOOL_SCHEMAS } from "./schemas";
+import { DANGEROUS_TOOLS } from "./elicit";
+
+/**
+ * Read-only despite being registered under a manage scope. These are pure
+ * reads gated more tightly on purpose (e.g. upload-status polling is only
+ * useful to callers who can upload).
+ */
+const READ_ONLY_OVERRIDES: ReadonlySet = new Set([
+ "getLandingPageImageUploadStatus",
+]);
+
+/**
+ * Tools that reach the open internet. `openWorldHint` is true only for these;
+ * everything else touches a bounded private workspace (the merchant's store).
+ */
+const OPEN_WORLD_TOOLS: ReadonlySet = new Set([
+ "uploadLandingPageImage", // fetches agent-supplied public image URLs
+]);
+
+/**
+ * Write tools documented as safe to repeat with the same arguments — the
+ * group/tag membership operations are idempotent by design (onConflictDoNothing
+ * / silent removal). All read-only tools are idempotent by derivation.
+ */
+const IDEMPOTENT_WRITE_TOOLS: ReadonlySet = new Set([
+ "addCustomerToGroup",
+ "removeCustomerFromGroup",
+ "assignTagToCustomer",
+ "unassignTagFromCustomer",
+]);
+
+/** A registry entry gates read-only access when every required scope is a read scope. */
+function isReadScopeEntry(entry: (typeof TOOL_REGISTRY)[number]): boolean {
+ return entry.requires.every((scope) => scope.endsWith(":read"));
+}
+
+/** Tools selected by at least one write-scope entry — everything the read set does not cover. */
+function writeScopeToolNames(): Set {
+ const out = new Set();
+ for (const entry of TOOL_REGISTRY) {
+ if (!isReadScopeEntry(entry)) {
+ for (const name of Object.keys(entry.build({} as never, {} as never, {} as never))) {
+ out.add(name);
+ }
+ }
+ }
+ return out;
+}
+
+const WRITE_SCOPE_TOOLS = writeScopeToolNames();
+
+function deriveAnnotations(name: string): ToolAnnotations {
+ const isReadOnly = READ_ONLY_OVERRIDES.has(name) || !WRITE_SCOPE_TOOLS.has(name);
+ return {
+ readOnlyHint: isReadOnly,
+ destructiveHint: !isReadOnly && DANGEROUS_TOOLS.has(name),
+ idempotentHint: isReadOnly || IDEMPOTENT_WRITE_TOOLS.has(name),
+ openWorldHint: OPEN_WORLD_TOOLS.has(name),
+ };
+}
+
+export const TOOL_ANNOTATIONS: Record = Object.fromEntries(
+ TOOL_NAMES.map((name) => [name, deriveAnnotations(name)]),
+);
+
+export { READ_ONLY_OVERRIDES, OPEN_WORLD_TOOLS, IDEMPOTENT_WRITE_TOOLS, WRITE_SCOPE_TOOLS };
diff --git a/cod-server/src/mcp/authorize.test.ts b/cod-server/src/mcp/authorize.test.ts
index 5f2e49c..e990904 100644
--- a/cod-server/src/mcp/authorize.test.ts
+++ b/cod-server/src/mcp/authorize.test.ts
@@ -185,6 +185,120 @@ describe("authorize helpers", () => {
expect(html).not.toContain("