From 9ffefb33a6fc6b9a8260a675258301ddee475f7b Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Thu, 21 May 2026 17:59:21 -0300 Subject: [PATCH] feat(vtex)!: forward Set-Cookie through cart-adjacent actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the framework-level VTEX Set-Cookie propagation gap for sites consuming `@decocms/apps` via `createServerFn` action handlers. VTEX's `checkout.vtex.com` and `CheckoutOrderFormOwnership` cookies were silently dropped on the action path, letting the storefront's local orderForm reference drift away from VTEX's server-side orderForm. Three fixes in one PR: 1. Cart-adjacent actions switched to `vtexFetchWithCookies`. Seven `/api/checkout/...` actions previously used the plain `vtexFetch` helper, which does not capture VTEX's `Set-Cookie` headers onto `RequestContext.responseHeaders`. The bridge in generated `invoke.gen.ts` had nothing to forward. Updated: `simulateCart`, `setShippingPostalCode` (per VTEX docs, can rotate the ownership cookie), `getInstallments`, `updateItemPrice`, `changeToAnonymousUser`, `clearOrderFormMessages`, `getSellersByRegion`. 2. Headers-aware merge in `vtexFetchWithCookies`. The previous implementation cast `init.headers` to `Record` and spread it into a new object to inject the auto-forwarded `Cookie` header. When the caller passed a `Headers` instance, the spread collapsed to `{}` and silently wiped every other header the caller had set (auth, content-type, trace context). Replaced with a `withCookieHeader(headers, cookieValue)` helper backed by the `Headers` constructor — the same Headers-aware fix that landed in #53 for `vtexFetchResponse`, applied here for completeness. 3. Restore `vtex/invoke.ts` as the generator contract. The file was deleted on 2026-03-30 (commit 0303cbb) on the assumption that `setupApps()` would auto-register handlers from the manifest. But the framework's `@decocms/start/scripts/generate-invoke.ts` still scans this file as its source of truth, so sites running `npm run generate:invoke` against any apps version >= 1.15.0 hit a hard "invoke.ts not found" error. Restored at the current action shapes (single-props-object call convention, no `VtexFetchResult` unwrapping). All 17 actions previously exposed are re-exposed here. Paired with a matching `@decocms/start` major that emits a `forwardResponseCookies()` bridge in `invoke.gen.ts`. Sites get the full fix after bumping both packages and re-running `npm run generate:invoke`. BREAKING CHANGE: requires the matching `@decocms/start` major containing the `forwardResponseCookies()` emit. Sites bumping `@decocms/apps` alone gain the per-action cookie capture but won't propagate Set-Cookies to the browser until they also bump `@decocms/start` and regenerate `src/server/invoke.gen.ts`. Co-authored-by: Cursor --- .../client-set-cookie-forward.test.ts | 208 ++++++++++++++++++ vtex/actions/checkout.ts | 51 +++-- vtex/client.ts | 51 ++++- vtex/invoke.ts | 181 +++++++++++++++ 4 files changed, 470 insertions(+), 21 deletions(-) create mode 100644 vtex/__tests__/client-set-cookie-forward.test.ts create mode 100644 vtex/invoke.ts diff --git a/vtex/__tests__/client-set-cookie-forward.test.ts b/vtex/__tests__/client-set-cookie-forward.test.ts new file mode 100644 index 0000000..f4a2ffd --- /dev/null +++ b/vtex/__tests__/client-set-cookie-forward.test.ts @@ -0,0 +1,208 @@ +/** + * Regression tests for the Set-Cookie propagation chain through + * `vtexFetchWithCookies`. Without this chain, VTEX's `checkout.vtex.com` + * and `CheckoutOrderFormOwnership` cookies never reach the browser via + * `createServerFn` actions, the storefront's local `__orderFormId` + * drifts away from VTEX's server-side orderForm, and the user lands + * on `/checkout` with an empty cart. + * + * Two failure modes covered here: + * + * (1) Inbound capture — VTEX `Set-Cookie` headers must be appended + * to `RequestContext.responseHeaders`, skipping the two IS + * cookies that the middleware owns (`vtex_is_session`, + * `vtex_is_anonymous`), and the `domain=` attribute must be + * stripped so the browser scopes the cookie to the storefront. + * + * (2) Outbound merge — when the caller passes `init.headers` as a + * `Headers` instance (the `createVtexCheckoutProxy` factory does + * this through `getVtexFetch()`), spreading it as a plain object + * collapses to `{}` and silently wipes every other header the + * caller set. The Headers-aware merge in `vtexFetchWithCookies` + * keeps the bug from sneaking back in. + */ + +import { RequestContext } from "@decocms/start/sdk/requestContext"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureVtex, setVtexFetch, vtexFetchWithCookies } from "../client"; + +function mockResponse(opts?: { body?: unknown; status?: number; setCookies?: string[] }): Response { + const status = opts?.status ?? 200; + const headers = new Headers(); + for (const c of opts?.setCookies ?? []) headers.append("set-cookie", c); + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + headers, + json: () => Promise.resolve(opts?.body ?? {}), + } as Response; +} + +function headerValue(init: RequestInit | undefined, name: string): string | undefined { + const headers = init?.headers; + if (!headers) return undefined; + if (headers instanceof Headers) return headers.get(name) ?? undefined; + if (Array.isArray(headers)) { + const found = headers.find(([k]) => k.toLowerCase() === name.toLowerCase()); + return found?.[1]; + } + const rec = headers as Record; + const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? rec[key] : undefined; +} + +function withRequest( + cookieHeader: string | null, + fn: (ctx: { responseHeaders: Headers }) => Promise, +): Promise { + const reqHeaders = new Headers(); + if (cookieHeader) reqHeaders.set("cookie", cookieHeader); + const responseHeaders = new Headers(); + const fakeCtx = { + request: { headers: reqHeaders } as unknown as Request, + signal: new AbortController().signal, + responseHeaders, + bag: new Map(), + startedAt: Date.now(), + }; + const spy = vi + .spyOn(RequestContext, "current", "get") + .mockReturnValue(fakeCtx as unknown as ReturnType); + return fn({ responseHeaders }).finally(() => spy.mockRestore()); +} + +describe("vtexFetchWithCookies — inbound Set-Cookie capture", () => { + let lastInit: RequestInit | undefined; + + beforeEach(() => { + configureVtex({ account: "testaccount" }); + lastInit = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("captures upstream Set-Cookie into RequestContext.responseHeaders", async () => { + setVtexFetch(((_url: string, init?: RequestInit) => { + lastInit = init; + return Promise.resolve( + mockResponse({ + setCookies: [ + "checkout.vtex.com=__ofid=abc123; Path=/; HttpOnly; Secure", + "CheckoutOrderFormOwnership=def456; Path=/; HttpOnly", + ], + }), + ); + }) as typeof fetch); + + const captured = await withRequest("vtex_segment=seg1", async ({ responseHeaders }) => { + await vtexFetchWithCookies("/api/checkout/pub/orderForm"); + return responseHeaders.getSetCookie(); + }); + + expect(captured).toHaveLength(2); + expect(captured.some((c) => c.startsWith("checkout.vtex.com="))).toBe(true); + expect(captured.some((c) => c.startsWith("CheckoutOrderFormOwnership="))).toBe(true); + }); + + it("strips the Domain= attribute so the browser scopes to the storefront host", async () => { + setVtexFetch((() => + Promise.resolve( + mockResponse({ + setCookies: [ + "checkout.vtex.com=__ofid=abc; Domain=.vtexcommercestable.com.br; Path=/; HttpOnly", + ], + }), + )) as typeof fetch); + + const captured = await withRequest("foo=bar", async ({ responseHeaders }) => { + await vtexFetchWithCookies("/api/checkout/pub/orderForm"); + return responseHeaders.getSetCookie(); + }); + + expect(captured[0]).not.toMatch(/domain=/i); + expect(captured[0]).toContain("checkout.vtex.com=__ofid=abc"); + }); + + it("skips Intelligent Search cookies (managed by middleware, not actions)", async () => { + setVtexFetch((() => + Promise.resolve( + mockResponse({ + setCookies: [ + "checkout.vtex.com=__ofid=abc; Path=/", + "vtex_is_session=ignore-me; Path=/", + "vtex_is_anonymous=ignore-me-too; Path=/", + "CheckoutOrderFormOwnership=def; Path=/", + ], + }), + )) as typeof fetch); + + const captured = await withRequest("foo=bar", async ({ responseHeaders }) => { + await vtexFetchWithCookies("/api/checkout/pub/orderForm"); + return responseHeaders.getSetCookie(); + }); + + expect(captured.some((c) => c.startsWith("checkout.vtex.com="))).toBe(true); + expect(captured.some((c) => c.startsWith("CheckoutOrderFormOwnership="))).toBe(true); + expect(captured.some((c) => c.startsWith("vtex_is_session="))).toBe(false); + expect(captured.some((c) => c.startsWith("vtex_is_anonymous="))).toBe(false); + }); + + it("does not crash when called outside a RequestContext", async () => { + setVtexFetch((() => + Promise.resolve( + mockResponse({ + setCookies: ["checkout.vtex.com=__ofid=abc; Path=/"], + }), + )) as typeof fetch); + await expect(vtexFetchWithCookies("/api/checkout/pub/orderForm")).resolves.toBeDefined(); + }); + + // Regression for Hole B: when init.headers is a Headers instance, + // the previous Record-cast + spread collapsed it to `{}`, wiping + // every other header (auth, content-type) the caller set. After + // the fix, the Headers-aware merge preserves all of them. + it("preserves other caller headers when init.headers is a Headers instance", async () => { + setVtexFetch(((_url: string, init?: RequestInit) => { + lastInit = init; + return Promise.resolve(mockResponse()); + }) as typeof fetch); + + await withRequest("vtex_segment=abc; foo=bar", async () => { + await vtexFetchWithCookies("/api/checkout/pub/orderForm", { + headers: new Headers({ + "X-Custom-Trace": "trace-id", + "X-VTEX-Operation": "test-op", + }), + }); + }); + + expect(headerValue(lastInit, "x-custom-trace")).toBe("trace-id"); + expect(headerValue(lastInit, "x-vtex-operation")).toBe("test-op"); + // Caller didn't pass a Cookie — auto-injection picks up the + // request's cookie and forwards it without dropping the + // other headers. + expect(headerValue(lastInit, "cookie")).toBeDefined(); + }); + + it("preserves an existing Cookie header passed via Headers and sanitises it in place", async () => { + setVtexFetch(((_url: string, init?: RequestInit) => { + lastInit = init; + return Promise.resolve(mockResponse()); + }) as typeof fetch); + + await withRequest("vtex_segment=abc", async () => { + await vtexFetchWithCookies("/api/checkout/pub/orderForm", { + headers: new Headers({ + "X-Custom": "keep-me", + cookie: "checkout.vtex.com=__ofid=xyz; vtex_segment=mine", + }), + }); + }); + + expect(headerValue(lastInit, "x-custom")).toBe("keep-me"); + expect(headerValue(lastInit, "cookie")).toContain("checkout.vtex.com=__ofid=xyz"); + }); +}); diff --git a/vtex/actions/checkout.ts b/vtex/actions/checkout.ts index 384f1d8..5aeadbe 100644 --- a/vtex/actions/checkout.ts +++ b/vtex/actions/checkout.ts @@ -7,7 +7,7 @@ * @see https://developers.vtex.com/docs/api-reference/checkout-api */ -import { getVtexConfig, vtexFetch, vtexFetchWithCookies } from "../client"; +import { getVtexConfig, vtexFetchWithCookies } from "../client"; import type { OrderForm } from "../types"; export const DEFAULT_EXPECTED_SECTIONS = [ @@ -179,7 +179,12 @@ export async function simulateCart(props: SimulateCartProps) { const { items, postalCode, country, RnbBehavior = 1 } = props; const config = getVtexConfig(); const params = appendSc(new URLSearchParams({ RnbBehavior: String(RnbBehavior) })); - return vtexFetch(`/api/checkout/pub/orderForms/simulation?${params}`, { + // Uses vtexFetchWithCookies so any Set-Cookie VTEX returns on the + // orderForm-scoped simulation reaches the browser via RequestContext. + // Without this, the segment/ownership cookies VTEX may rotate during + // simulation are dropped, and the storefront's local orderFormId + // drifts away from VTEX's checkout.vtex.com server cookie. + return vtexFetchWithCookies(`/api/checkout/pub/orderForms/simulation?${params}`, { method: "POST", body: JSON.stringify({ items, @@ -352,7 +357,7 @@ export interface UpdateItemPriceProps { export async function updateItemPrice(props: UpdateItemPriceProps): Promise { const { orderFormId, itemIndex, price } = props; - return vtexFetch( + return vtexFetchWithCookies( `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/price`, { method: "PUT", body: JSON.stringify({ price }) }, ); @@ -403,7 +408,9 @@ export async function getInstallments(props: GetInstallmentsProps) { const { orderFormId, paymentSystem } = props; const params = new URLSearchParams({ paymentSystem: String(paymentSystem) }); appendSc(params); - return vtexFetch(`/api/checkout/pub/orderForm/${orderFormId}/installments?${params}`); + return vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/installments?${params}`, + ); } // --------------------------------------------------------------------------- @@ -434,7 +441,9 @@ export interface ChangeToAnonymousUserProps { export async function changeToAnonymousUser(props: ChangeToAnonymousUserProps): Promise { const { orderFormId } = props; - return vtexFetch(`/api/checkout/changeToAnonymousUser/${orderFormId}`); + // This endpoint rotates the orderForm ownership cookies — must use + // vtexFetchWithCookies so the new cookies reach the browser. + return vtexFetchWithCookies(`/api/checkout/changeToAnonymousUser/${orderFormId}`); } export interface ClearOrderFormMessagesProps { @@ -445,10 +454,13 @@ export async function clearOrderFormMessages( props: ClearOrderFormMessagesProps, ): Promise { const { orderFormId } = props; - return vtexFetch(`/api/checkout/pub/orderForm/${orderFormId}/messages/clear`, { - method: "POST", - body: JSON.stringify({}), - }); + return vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/messages/clear`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); } // --------------------------------------------------------------------------- @@ -477,7 +489,7 @@ export async function getSellersByRegion( const params = new URLSearchParams({ country: "BRA", postalCode }); const sc = salesChannel ?? getVtexConfig().salesChannel; if (sc) params.set("sc", sc); - const resp = await vtexFetch(`/api/checkout/pub/regions/?${params}`); + const resp = await vtexFetchWithCookies(`/api/checkout/pub/regions/?${params}`); return resp[0]?.sellers?.length > 0 ? resp[0] : null; } @@ -490,12 +502,19 @@ export interface SetShippingPostalCodeProps { export async function setShippingPostalCode(props: SetShippingPostalCodeProps): Promise { const { orderFormId, postalCode, country = "BRA" } = props; try { - await vtexFetch(`/api/checkout/pub/orderForm/${orderFormId}/attachments/shippingData`, { - method: "POST", - body: JSON.stringify({ - selectedAddresses: [{ postalCode, country }], - }), - }); + // VTEX docs note that /attachments/shippingData can rotate the + // CheckoutOrderFormOwnership cookie. vtexFetchWithCookies ensures + // any such Set-Cookie reaches the browser via RequestContext, + // keeping the storefront and VTEX bound to the same orderForm. + await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/attachments/shippingData`, + { + method: "POST", + body: JSON.stringify({ + selectedAddresses: [{ postalCode, country }], + }), + }, + ); return true; } catch { return false; diff --git a/vtex/client.ts b/vtex/client.ts index 01d5fef..01c2b83 100644 --- a/vtex/client.ts +++ b/vtex/client.ts @@ -223,6 +223,40 @@ function hasCookieHeader(headers: HeadersInit | undefined): boolean { return Object.keys(headers).some((k) => k.toLowerCase() === "cookie"); } +/** + * Read the cookie header value from any HeadersInit shape. + * Returns undefined when no cookie header is set. + */ +function readCookieHeader(headers: HeadersInit | undefined): string | undefined { + if (!headers) return undefined; + if (headers instanceof Headers) return headers.get("cookie") ?? undefined; + if (Array.isArray(headers)) { + const found = headers.find(([k]) => k.toLowerCase() === "cookie"); + return found?.[1]; + } + const rec = headers as Record; + const key = Object.keys(rec).find((k) => k.toLowerCase() === "cookie"); + return key ? rec[key] : undefined; +} + +/** + * Return a new Headers instance that copies `headers` and replaces the + * `cookie` value with `cookieValue` (or removes it when undefined). + * Centralises the "merge cookie into existing init.headers" operation so + * we never spread a Headers instance as a plain object — that collapses + * to {} because Headers has no own enumerable entries, and silently + * wipes every other header the caller set. See PR #53. + */ +function withCookieHeader( + headers: HeadersInit | undefined, + cookieValue: string | undefined, +): Headers { + const next = new Headers(headers ?? {}); + if (cookieValue) next.set("cookie", cookieValue); + else next.delete("cookie"); + return next; +} + export async function vtexFetchResponse( path: string, init?: InstrumentedFetchInit, @@ -354,22 +388,29 @@ export async function vtexFetchWithCookies( // otherwise poison every checkout call for the affected user. The drop // report is emitted via warnDroppedCookies() so we have observability the // next time a tag misbehaves. - const existingHeaders = init?.headers as Record | undefined; - if (!existingHeaders?.cookie) { + // + // Headers normalisation: callers pass either Headers, [name,value][], + // or Record. We must NEVER spread a Headers instance as + // a plain object — it collapses to {} and silently drops every other + // header the caller set (auth, content-type, etc.). withCookieHeader() + // funnels every shape through the Headers constructor and is the only + // safe way to rewrite the cookie value. + const callerCookie = readCookieHeader(init?.headers); + if (!callerCookie) { const ctx = RequestContext.current; const raw = ctx?.request.headers.get("cookie"); if (raw) { const { cookies, dropped } = sanitizeOutboundCookieHeader(raw); if (dropped.length) warnDroppedCookies(dropped, vtexHost()); if (cookies) { - init = { ...init, headers: { ...existingHeaders, cookie: cookies } }; + init = { ...init, headers: withCookieHeader(init?.headers, cookies) }; } } } else { // Caller passed an explicit cookie — sanitize it too. - const { cookies, dropped } = sanitizeOutboundCookieHeader(existingHeaders.cookie); + const { cookies, dropped } = sanitizeOutboundCookieHeader(callerCookie); if (dropped.length) warnDroppedCookies(dropped, vtexHost()); - init = { ...init, headers: { ...existingHeaders, cookie: cookies } }; + init = { ...init, headers: withCookieHeader(init?.headers, cookies) }; } const response = await vtexFetchResponse(path, init); diff --git a/vtex/invoke.ts b/vtex/invoke.ts new file mode 100644 index 0000000..1712e12 --- /dev/null +++ b/vtex/invoke.ts @@ -0,0 +1,181 @@ +/** + * Typed `invoke.vtex.actions.*` object — generator contract. + * + * This file is the source of truth that `@decocms/start/scripts/generate-invoke.ts` + * scans to emit the site-local `src/server/invoke.gen.ts`. The generator: + * + * 1. parses the imports in this file to learn which action lives where, + * 2. walks `invoke.vtex.actions.*` and extracts each entry's: + * - validated input type (the arrow function's first parameter), + * - imported action function (matched against the import map by name), + * - output type (the outermost `as` cast's `Promise<...>` payload), + * 3. emits a top-level `createServerFn` per action so TanStack Start's + * compiler can transform `.handler()` into client RPC stubs (the + * compiler only walks top-level decls, not factory-returned ones). + * + * Every action gets a `forwardResponseCookies()` call in the generated + * handler — that bridges `Set-Cookie` headers captured by + * `vtexFetchWithCookies` into TanStack Start's HTTP response. Without it, + * `checkout.vtex.com` and `CheckoutOrderFormOwnership` never reach the + * browser, and the storefront's mini-cart drifts away from VTEX's + * server-side orderForm. + * + * To add a new action: + * 1. Add an entry below with the input/output types and the action call, + * 2. From a site repo: `npm run generate:invoke`. + */ +import { createInvokeFn } from "@decocms/start/sdk/createInvoke"; +import { + addCouponToCart, + addItemsToCart, + getOrCreateCart, + getSellersByRegion, + type RegionResult, + type SimulationItem, + setShippingPostalCode, + simulateCart, + updateCartItems, + updateOrderFormAttachment, +} from "./actions/checkout"; +import { + type CreateDocumentResult, + createDocument, + getDocument, + patchDocument, + searchDocuments, + type UploadAttachmentOpts, + uploadAttachment, +} from "./actions/masterData"; +import { type NotifyMeProps, notifyMe } from "./actions/misc"; +import { type SubscribeProps, subscribe } from "./actions/newsletter"; +import { createSession, editSession, type SessionData } from "./actions/session"; +import type { OrderForm } from "./types"; + +// --------------------------------------------------------------------------- +// invoke.vtex.actions — typed server functions callable from client +// +// Action bodies receive the validated input object directly and pass it +// straight to the action function (which expects a single `props` object). +// The arrow function body is what the generator parses for "which action +// is this entry calling" — keep the call site shaped as `actionName(data)` +// so the matcher in `generate-invoke.ts` picks the right importedFn. +// --------------------------------------------------------------------------- + +export const invoke = { + vtex: { + actions: { + // -- Cart (OrderForm CRUD) -------------------------------------------- + + getOrCreateCart: createInvokeFn((data: { orderFormId?: string }) => + getOrCreateCart(data), + ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise, + + addItemsToCart: createInvokeFn( + (data: { + orderFormId: string; + orderItems: Array<{ + id: string; + seller: string; + quantity: number; + }>; + }) => addItemsToCart(data), + ) as unknown as (ctx: { + data: { + orderFormId: string; + orderItems: Array<{ + id: string; + seller: string; + quantity: number; + }>; + }; + }) => Promise, + + updateCartItems: createInvokeFn( + (data: { orderFormId: string; orderItems: Array<{ index: number; quantity: number }> }) => + updateCartItems(data), + ) as unknown as (ctx: { + data: { orderFormId: string; orderItems: Array<{ index: number; quantity: number }> }; + }) => Promise, + + addCouponToCart: createInvokeFn((data: { orderFormId: string; text: string }) => + addCouponToCart(data), + ) as unknown as (ctx: { data: { orderFormId: string; text: string } }) => Promise, + + simulateCart: createInvokeFn( + (data: { items: SimulationItem[]; postalCode: string; country?: string }) => + simulateCart(data), + ), + + // -- Shipping / Region ------------------------------------------------ + + getSellersByRegion: createInvokeFn((data: { postalCode: string; salesChannel?: string }) => + getSellersByRegion(data), + ) as unknown as (ctx: { + data: { postalCode: string; salesChannel?: string }; + }) => Promise, + + setShippingPostalCode: createInvokeFn( + (data: { orderFormId: string; postalCode: string; country?: string }) => + setShippingPostalCode(data), + ) as unknown as (ctx: { + data: { orderFormId: string; postalCode: string; country?: string }; + }) => Promise, + + updateOrderFormAttachment: createInvokeFn( + (data: { orderFormId: string; attachment: string; body: Record }) => + updateOrderFormAttachment(data), + ) as unknown as (ctx: { + data: { orderFormId: string; attachment: string; body: Record }; + }) => Promise, + + // -- Session ---------------------------------------------------------- + + createSession: createInvokeFn((data: Record) => createSession({ data })), + + editSession: createInvokeFn((data: { public: Record }) => + editSession(data), + ) as unknown as (ctx: { + data: { public: Record }; + }) => Promise, + + // -- MasterData ------------------------------------------------------- + + createDocument: createInvokeFn((data: { entity: string; data: Record }) => + createDocument(data), + ) as unknown as (ctx: { + data: { entity: string; data: Record }; + }) => Promise, + + getDocument: createInvokeFn((data: { entity: string; documentId: string }) => + getDocument(data), + ), + + patchDocument: createInvokeFn( + (data: { entity: string; documentId: string; data: Record }) => + patchDocument(data), + ) as unknown as (ctx: { + data: { entity: string; documentId: string; data: Record }; + }) => Promise, + + searchDocuments: createInvokeFn((data: { entity: string; filter: string }) => + searchDocuments(data), + ), + + uploadAttachment: createInvokeFn((data: UploadAttachmentOpts) => + uploadAttachment(data), + ) as unknown as (ctx: { data: UploadAttachmentOpts }) => Promise<{ ok: true }>, + + // -- Newsletter ------------------------------------------------------- + + subscribe: createInvokeFn((data: SubscribeProps) => subscribe(data)) as unknown as (ctx: { + data: SubscribeProps; + }) => Promise, + + // -- Misc ------------------------------------------------------------- + + notifyMe: createInvokeFn((data: NotifyMeProps) => notifyMe(data)) as unknown as (ctx: { + data: NotifyMeProps; + }) => Promise, + }, + }, +} as const;