diff --git a/cod-client-astro/src/components/layout/nav.tsx b/cod-client-astro/src/components/layout/nav.tsx index bc3a035..ab2a712 100644 --- a/cod-client-astro/src/components/layout/nav.tsx +++ b/cod-client-astro/src/components/layout/nav.tsx @@ -213,7 +213,6 @@ export function useNavSections(): NavSection[] { label: tN("sidebar.mcp"), icon: Sparkles, scope: "mcp:view", - badge: "BETA", }, { kind: "leaf", diff --git a/cod-client-astro/src/features/landing-pages/api.ts b/cod-client-astro/src/features/landing-pages/api.ts index bba3196..763937f 100644 --- a/cod-client-astro/src/features/landing-pages/api.ts +++ b/cod-client-astro/src/features/landing-pages/api.ts @@ -1,4 +1,5 @@ import { apiFetch } from "@/lib/api"; +import type { PresignedUpload } from "@/features/products/api"; import type { CreateLandingPageInput, LandingPage, @@ -104,3 +105,12 @@ export function deleteLandingPageImage(lpId: string, imageId: string) { { method: "DELETE" }, ); } + +export async function getPresignedLandingUploadUrl(contentType: string) { + return ( + await apiFetch>( + "/api/images/presign", + json({ method: "POST", body: JSON.stringify({ contentType, folder: "landing" }) }), + ) + ).data; +} diff --git a/cod-client-astro/src/features/landing-pages/components/LandingPageStudioApp.tsx b/cod-client-astro/src/features/landing-pages/components/LandingPageStudioApp.tsx index e46a806..059bb6c 100644 --- a/cod-client-astro/src/features/landing-pages/components/LandingPageStudioApp.tsx +++ b/cod-client-astro/src/features/landing-pages/components/LandingPageStudioApp.tsx @@ -21,13 +21,13 @@ import { SCOPES } from "../../../../../cod-shared/rbac/scopes"; import { deleteLandingPageImage, getLandingPage, + getPresignedLandingUploadUrl, publishLandingPage, reorderLandingPageImages, saveLandingPageImage, unpublishLandingPage, updateLandingPage, } from "@/features/landing-pages/api"; -import { getPresignedUploadUrl } from "@/features/products/api"; import { landingPageErrorMessage, landingPagePublicUrl } from "@/features/landing-pages/model"; import type { LandingPage, LandingPageImage } from "@/features/landing-pages/types"; @@ -223,7 +223,7 @@ function Gated({ landingPageId }: { landingPageId: string }) { setUploading(true); try { for (const file of arr) { - const { presignedUrl, key, publicUrl } = await getPresignedUploadUrl(file.type); + const { presignedUrl, key, publicUrl } = await getPresignedLandingUploadUrl(file.type); const putRes = await fetch(presignedUrl, { method: "PUT", headers: { "Content-Type": file.type }, 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/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" }), diff --git a/cod-server/src/endpoints/mcp/CONTEXT.md b/cod-server/src/endpoints/mcp/CONTEXT.md index 460e575..00827d0 100644 --- a/cod-server/src/endpoints/mcp/CONTEXT.md +++ b/cod-server/src/endpoints/mcp/CONTEXT.md @@ -41,15 +41,15 @@ A known identity-library bug serializes token expiry as a duration instead of a ### Safety **Dangerous Tools Gate**: -A hard-coded allowlist of tools that always demand human confirmation first — deletes across domains, driver settlements, stock adjustments, order status changes. Risk classification lives in one auditable place. +The hard-coded classification of destructive tools — deletes across domains, driver settlements, stock adjustments, order status changes. Risk classification lives in one auditable place and feeds the `destructiveHint` annotations, so clients frame their own confirmation prompts correctly. _Avoid_: Blacklist, auto-block -**Elicitation Confirmation**: -The protocol-native confirmation dialog rendered by the MCP client itself. A decline is a normal outcome: logged, reported tersely, never thrown. -_Avoid_: Error, rejection failure +**Client-Side Confirmation**: +Human approval for write actions is the client's documented responsibility — ChatGPT requires merchant confirmation before any write action, framed by our annotations. The server never runs a duplicate confirmation round: it labels accurately, gates by OAuth scope, validates inputs, rate-limits, and writes the audit trail. A server-side gate would depend on elicitation support the client may not have, and a fail-closed fallback would break destructive tools entirely in incapable clients. +_Avoid_: Server confirmation, HITL round **Tool Call Audit**: -Every agent invocation lands in the activity trail tagged as via-MCP with its arguments and outcome — including declines and failures — so operations can reconstruct exactly what each agent did. +Every agent invocation lands in the activity trail tagged as via-MCP with its arguments and outcome — failures included — so operations can reconstruct exactly what each agent did. **Connection Revocation**: Cutting an agent's access revokes the provider grant — the grant and every access token under it disappear, and token validation rejects any token whose grant is gone, so live sessions die instantly. Retries are safe; revocation is idempotent. @@ -76,9 +76,7 @@ Terms owned by neighboring contexts — use them, don't redefine them here: **No props, no tools**: If session identity ever fails to attach, the agent starts with an empty tool list rather than crashing — fail-closed by construction. -**Confirmation needs a capable client**: Elicitation support is announced by the MCP client, not advertised by the server; incapable clients skip the dialog entirely, so the gate depends on client capability. - -**Three ways to decline**: Denying the dialog, leaving the confirm box unchecked, or timing out all count identically as decline — recorded, never raised. +**Classification, not gating**: The dangerous-tools set never blocks execution by itself — it drives annotations so the CLIENT's confirmation is framed correctly; the server's own walls are scopes, validation, the rate limit, and the audit trail. **Revocation closes the race window**: Revoking the grant removes the grant itself, and token validation requires the grant to exist, so an in-flight agent call cannot slip through after revocation. 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 * diff --git a/cod-server/src/index.ts b/cod-server/src/index.ts index 0bac806..187eaf4 100644 --- a/cod-server/src/index.ts +++ b/cod-server/src/index.ts @@ -52,8 +52,10 @@ import { authorizeGet, authorizePost } from "@/mcp/authorize"; import { recordMcpLastUsed } from "@/mcp/last-used"; import { ALL_SCOPES } from "../../cod-shared/rbac/scopes"; -// CodCapiWorkflow — MUST be re-exported so Cloudflare can bind it via wrangler.toml [[workflows]]. +// Cloudflare Workflow classes — MUST be re-exported so Cloudflare can bind +// them via wrangler.toml [[workflows]]. export { CodCapiWorkflow } from "@/workflows/capi"; +export { CodLandingPageImageUploadWorkflow } from "@/workflows/landing-page-image-upload"; // OpenAPIHono extends Hono: existing routes/middleware keep working, and // routes registered via app.openapi() validate requests and feed the diff --git a/cod-server/src/lib/image-dimensions.test.ts b/cod-server/src/lib/image-dimensions.test.ts new file mode 100644 index 0000000..0626ba1 --- /dev/null +++ b/cod-server/src/lib/image-dimensions.test.ts @@ -0,0 +1,175 @@ +/** + * Image dimension parsing + magic-byte sniffing. + * + * Fixtures are real binary headers (plus one real 1x1 PNG) — the parser must + * read the same bytes a browser would. Dimension parsing is fail-open: any + * unrecognized or truncated input yields null, never a throw. + */ + +import { describe, it, expect } from "vitest"; +import { parseImageDimensions, sniffImageType } from "./image-dimensions"; + +/** Real 1x1 px PNG file (70 bytes). */ +const REAL_1X1_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", +); + +function pngHeader(width: number, height: number): Buffer { + const b = Buffer.alloc(33); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(b, 0); + b.writeUInt32BE(13, 8); + b.write("IHDR", 12, "ascii"); + b.writeUInt32BE(width, 16); + b.writeUInt32BE(height, 20); + return b; +} + +function jpegHeader(width: number, height: number, withApp0: boolean): Buffer { + const b = Buffer.alloc(64); + let offset = 0; + b.writeUInt16BE(0xffd8, offset); // SOI + offset += 2; + if (withApp0) { + b.writeUInt16BE(0xffe0, offset); // APP0 marker + b.writeUInt16BE(16, offset + 2); // segment length (includes itself) + b.write("JFIF\0", offset + 4, "ascii"); + offset += 2 + 16; + } + b.writeUInt16BE(0xffc0, offset); // SOF0 marker + b.writeUInt16BE(17, offset + 2); // segment length + b.writeUInt8(8, offset + 4); // precision + b.writeUInt16BE(height, offset + 5); + b.writeUInt16BE(width, offset + 7); + return b; +} + +function gifHeader(width: number, height: number): Buffer { + const b = Buffer.alloc(13); + b.write("GIF89a", 0, "ascii"); + b.writeUInt16LE(width, 6); + b.writeUInt16LE(height, 8); + return b; +} + +function webpLossless(width: number, height: number): Buffer { + const b = Buffer.alloc(30); + b.write("RIFF", 0, "ascii"); + b.writeUInt32LE(18, 4); + b.write("WEBP", 8, "ascii"); + b.write("VP8L", 12, "ascii"); + b.writeUInt32LE(5, 16); + b.writeUInt8(0x2f, 20); // lossless signature + const bits = (width - 1) | ((height - 1) << 14); + b.writeUInt32LE(bits, 21); + return b; +} + +function webpExtended(width: number, height: number): Buffer { + const b = Buffer.alloc(32); + b.write("RIFF", 0, "ascii"); + b.writeUInt32LE(20, 4); + b.write("WEBP", 8, "ascii"); + b.write("VP8X", 12, "ascii"); + b.writeUInt32LE(10, 16); + b.writeUInt8(0x10, 20); // flags + b.writeUIntLE(width - 1, 24, 3); + b.writeUIntLE(height - 1, 27, 3); + return b; +} + +function webpLossy(width: number, height: number): Buffer { + const b = Buffer.alloc(32); + b.write("RIFF", 0, "ascii"); + b.writeUInt32LE(20, 4); + b.write("WEBP", 8, "ascii"); + b.write("VP8 ", 12, "ascii"); + b.writeUInt32LE(10, 16); + b.writeUIntLE(0x83, 20, 3); // keyframe tag + b.writeUIntLE(0x2a019d, 23, 3); // sync code 9d 01 2a + b.writeUInt16LE(width, 26); + b.writeUInt16LE(height, 28); + return b; +} + +describe("sniffImageType", () => { + it("recognizes a real PNG file", () => { + expect(sniffImageType(new Uint8Array(REAL_1X1_PNG))).toBe("image/png"); + }); + + it("recognizes jpeg, gif, and webp signatures", () => { + expect(sniffImageType(new Uint8Array(jpegHeader(10, 10, false)))).toBe("image/jpeg"); + expect(sniffImageType(new Uint8Array(gifHeader(10, 10)))).toBe("image/gif"); + expect(sniffImageType(new Uint8Array(webpLossless(10, 10)))).toBe("image/webp"); + expect(sniffImageType(new Uint8Array(webpExtended(10, 10)))).toBe("image/webp"); + expect(sniffImageType(new Uint8Array(webpLossy(10, 10)))).toBe("image/webp"); + }); + + it("returns null for non-image bytes", () => { + expect(sniffImageType(new Uint8Array(Buffer.from("")))).toBeNull(); + expect(sniffImageType(new Uint8Array(0))).toBeNull(); + expect(sniffImageType(new Uint8Array([0x89, 0x50, 0x4e]))).toBeNull(); + }); +}); + +describe("parseImageDimensions", () => { + it("parses a real 1x1 PNG as 1x1", () => { + expect(parseImageDimensions(new Uint8Array(REAL_1X1_PNG))).toEqual({ width: 1, height: 1 }); + }); + + it("parses PNG IHDR (big-endian) — phone-width creative 1080x1350", () => { + expect(parseImageDimensions(new Uint8Array(pngHeader(1080, 1350)))).toEqual({ + width: 1080, + height: 1350, + }); + }); + + it("parses JPEG SOF0 directly after SOI", () => { + expect(parseImageDimensions(new Uint8Array(jpegHeader(1920, 1080, false)))).toEqual({ + width: 1920, + height: 1080, + }); + }); + + it("skips APP segments before the JPEG frame header", () => { + expect(parseImageDimensions(new Uint8Array(jpegHeader(1080, 1350, true)))).toEqual({ + width: 1080, + height: 1350, + }); + }); + + it("parses GIF logical screen size (little-endian)", () => { + expect(parseImageDimensions(new Uint8Array(gifHeader(386, 200)))).toEqual({ + width: 386, + height: 200, + }); + }); + + it("parses WebP lossless (VP8L) packed dimensions", () => { + expect(parseImageDimensions(new Uint8Array(webpLossless(386, 200)))).toEqual({ + width: 386, + height: 200, + }); + }); + + it("parses WebP extended (VP8X) canvas size", () => { + expect(parseImageDimensions(new Uint8Array(webpExtended(4096, 2304)))).toEqual({ + width: 4096, + height: 2304, + }); + }); + + it("parses WebP lossy (VP8) keyframe dimensions", () => { + expect(parseImageDimensions(new Uint8Array(webpLossy(320, 240)))).toEqual({ + width: 320, + height: 240, + }); + }); + + it("fails open to null on truncated and unrecognized input", () => { + const truncatedPng = new Uint8Array(REAL_1X1_PNG.subarray(0, 15)); + expect(parseImageDimensions(truncatedPng)).toBeNull(); + expect(parseImageDimensions(new Uint8Array(Buffer.from("not an image at all")))).toBeNull(); + expect(parseImageDimensions(new Uint8Array(0))).toBeNull(); + }); +}); diff --git a/cod-server/src/lib/image-dimensions.ts b/cod-server/src/lib/image-dimensions.ts new file mode 100644 index 0000000..1505652 --- /dev/null +++ b/cod-server/src/lib/image-dimensions.ts @@ -0,0 +1,138 @@ +/** + * Image magic-byte sniffing and header dimension parsing. + * + * Reads only the leading bytes of png/jpeg/webp/gif files — no dependency, no + * full decode. Dimension parsing is fail-open by design: an unrecognized or + * truncated header yields null, never an error, so an unmeasurable image + * still saves (the storefront renders it without reserved layout space). + */ + +export type SniffedImageType = "image/png" | "image/jpeg" | "image/gif" | "image/webp"; + +export interface ImageDimensions { + width: number; + height: number; +} + +function isPng(bytes: Uint8Array): boolean { + return ( + bytes.length >= 8 && + bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && + bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a + ); +} + +function isJpeg(bytes: Uint8Array): boolean { + return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; +} + +function isGif(bytes: Uint8Array): boolean { + return ( + bytes.length >= 6 && + bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 && + (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61 + ); +} + +function isWebp(bytes: Uint8Array): boolean { + return ( + bytes.length >= 12 && + bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && + bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 + ); +} + +export function sniffImageType(bytes: Uint8Array): SniffedImageType | null { + if (isPng(bytes)) return "image/png"; + if (isJpeg(bytes)) return "image/jpeg"; + if (isGif(bytes)) return "image/gif"; + if (isWebp(bytes)) return "image/webp"; + return null; +} + +function pngDimensions(bytes: Uint8Array): ImageDimensions | null { + // IHDR must be the first chunk: signature(8) + length(4), then "IHDR"(4) at + // offset 12, then big-endian width at 16 and height at 20. + if (bytes.length < 24) return null; + if (bytes[12] !== 0x49 || bytes[13] !== 0x48 || bytes[14] !== 0x44 || bytes[15] !== 0x52) { + return null; + } + const width = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19]; + const height = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23]; + return width > 0 && height > 0 ? { width, height } : null; +} + +function jpegDimensions(bytes: Uint8Array): ImageDimensions | null { + // Scan segment markers for a start-of-frame (SOF0..SOF15 minus DHT/JPG/DAC); + // the frame header carries big-endian height then width after the length. + let i = 2; + while (i + 9 < bytes.length) { + if (bytes[i] !== 0xff) { + i += 1; + continue; + } + const marker = bytes[i + 1]; + if (marker === 0xff) { + i += 1; + continue; + } + // Standalone markers carry no length field. + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + i += 2; + continue; + } + if (i + 3 >= bytes.length) return null; + const segmentLength = (bytes[i + 2] << 8) | bytes[i + 3]; + if ( + marker >= 0xc0 && marker <= 0xcf && + marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc + ) { + const height = (bytes[i + 5] << 8) | bytes[i + 6]; + const width = (bytes[i + 7] << 8) | bytes[i + 8]; + return width > 0 && height > 0 ? { width, height } : null; + } + if (segmentLength < 2) return null; + i += 2 + segmentLength; + } + return null; +} + +function gifDimensions(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 10) return null; + const width = bytes[6] | (bytes[7] << 8); + const height = bytes[8] | (bytes[9] << 8); + return width > 0 && height > 0 ? { width, height } : null; +} + +function webpDimensions(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 30) return null; + const fourcc = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]); + if (fourcc === "VP8X") { + // Extended format: 1-based canvas size as u24le at offsets 24 (width-1) and 27 (height-1). + const width = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)); + const height = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16)); + return { width, height }; + } + if (fourcc === "VP8 ") { + // Lossy keyframe: frame tag (20-22), sync code (23-25), then u16le width/height. + const width = (bytes[26] | (bytes[27] << 8)) & 0x3fff; + const height = (bytes[28] | (bytes[29] << 8)) & 0x3fff; + return width > 0 && height > 0 ? { width, height } : null; + } + if (fourcc === "VP8L" && bytes.length >= 25 && bytes[20] === 0x2f) { + // Lossless: signature 0x2f then packed u32le — width-1 in the low 14 bits, height-1 in the next 14. + const bits = bytes[21] | (bytes[22] << 8) | (bytes[23] << 16) | (bytes[24] << 24); + const width = (bits & 0x3fff) + 1; + const height = ((bits >>> 14) & 0x3fff) + 1; + return { width, height }; + } + return null; +} + +export function parseImageDimensions(bytes: Uint8Array): ImageDimensions | null { + if (isPng(bytes)) return pngDimensions(bytes); + if (isJpeg(bytes)) return jpegDimensions(bytes); + if (isGif(bytes)) return gifDimensions(bytes); + if (isWebp(bytes)) return webpDimensions(bytes); + return null; +} diff --git a/cod-server/src/lib/landing-image-upload.ts b/cod-server/src/lib/landing-image-upload.ts new file mode 100644 index 0000000..c0db5fd --- /dev/null +++ b/cod-server/src/lib/landing-image-upload.ts @@ -0,0 +1,78 @@ +/** + * Shared minting rules and limits for MCP-agent landing page image uploads. + * + * Used by BOTH the upload tool (validates input, mints keys, enqueues the + * workflow) and the background workflow (re-verifies, stores, records) — one + * module so the tool can never mint a key or accept a content type the + * workflow would reject. + */ + +/** Decoded-byte cap — matches the browser proxy-upload cap (images endpoint). */ +export const MAX_IMAGE_BYTES = 8 * 1024 * 1024; + +/** Platform image whitelist — identical to images/presign.ts ALLOWED_TYPES. */ +export const IMAGE_CONTENT_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/gif", +] as const; + +export type ImageContentType = (typeof IMAGE_CONTENT_TYPES)[number]; + +const MIME_TO_EXT: Record = { + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", +}; + +/** Canonicalize the "image/jpg" alias so it compares equal to "image/jpeg". */ +export function canonicalImageContentType(contentType: string): string { + return contentType === "image/jpg" ? "image/jpeg" : contentType; +} + +export function extFromImageContentType(contentType: string): string { + return MIME_TO_EXT[contentType] ?? "jpg"; +} + +/** Server-generated key shape: landing/. — traversal-proof by construction. */ +export const LANDING_IMAGE_R2_KEY_PATTERN = /^landing\/[a-f0-9]{32}\.(jpg|png|webp|gif)$/; + +/** + * Mint the R2 key and the workflow instance ID from ONE uuid so the job, + * its storage object, and its audit rows share a traceable identity: + * r2Key = landing/. + * instanceId = lpimg- (matches the Workflows ID charset, ≤ 100 chars) + */ +export function mintLandingImageUploadIds(contentType: string): { + hex: string; + r2Key: string; + instanceId: string; +} { + const hex = crypto.randomUUID().replace(/-/g, ""); + return { + hex, + r2Key: `landing/${hex}.${extFromImageContentType(contentType)}`, + instanceId: `lpimg-${hex}`, + }; +} + +/** Base64 input cap: 8 MB decoded → ~11.18 M base64 chars, plus slack for + * padding and tolerated whitespace. Rejects oversized payloads before decode. */ +export const BASE64_MAX_INPUT_LENGTH = + Math.ceil((MAX_IMAGE_BYTES / 3) * 4) + 1024; + +/** + * Decode agent-supplied base64 image bytes. Tolerates a `data:;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/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("