/** * Shopify Admin API operations — single source of truth across the api, the * dashboard and downstream consumers (MCP server). * * ## Ten, out of several hundred * * The Admin API has hundreds of mutations. This node exposes ten, and the * choice is argued in `api/docs/ADR-0007-shopify.md`: a menu of everything is * how a node becomes unusable. * * The first four cover what people automate on day one — put a customer in the * store, tag something, sync stock, and read back what the store already * knows. The six added on 2026-09-08 cover what they ask for on day two: the * order lifecycle (draft → complete → fulfil → cancel), custom data * (metafields) and putting a product in the catalogue. * * Two READS joined on 2026-09-28 with the AI toolkit (`shopify-toolkit.ts`): * `getOrder` and `listLocations`. An agent that can create a draft order but * cannot see what an order contains, or in which location stock lives, writes * blind. `findRecords` also brings each product's variants since then — the * `variantId` a draft order needs and the `inventoryItemId` inventory needs * were nowhere to be found before. * * ⚠️ **Every entry below carries the OAuth scope it needs**, in * `SHOPIFY_OPERATION_SCOPES`. That is not documentation: a scope missing from * the credential answers HTTP 403 on a call that looks perfectly formed, and * on Shopify a scope is granted at INSTALL time — adding one to the app means * every existing credential has to be reconnected before it can use the new * operation. * * ## GraphQL only, and the version is not ours to drift on * * REST went legacy in October 2024 and public apps have been GraphQL-only * since April 2025, so there is no REST path here to fall back to. Every * `apiRoute` below is a GraphQL mutation or query name, checkable against * `shopify.dev/docs/api/admin-graphql`. The API version lives in ONE constant * on the api side (`SHOPIFY_API_VERSION`) — never interpolated per call site. * * ## What was verified, and what was not (2026-08-25) * * Checked against the Admin GraphQL reference, not from memory: * * - `tagsAdd(id: ID!, tags: [String!]!)` and `tagsRemove` with the same * shape. Both accept Order, DraftOrder, Customer, Product, Article and * DiscountNode. * - `customerCreate(input: CustomerInput!)` and `customerUpdate(input: * CustomerInput!)` — for the update, **the id goes inside the input**, not * as a sibling argument. * - `inventoryAdjustQuantities(input: InventoryAdjustQuantitiesInput!)`, whose * input requires `reason`, `name` and a `changes[]` of * `{ delta, inventoryItemId, locationId }`. * * ⚠️ **NOT verified, and deliberately left loose below** — do not turn either * into a hard-coded dropdown until someone reads the enumeration: * * - The full vocabulary of `reason`. Only `"correction"` is documented by * example. * - The full vocabulary of `name`. Only `"available"` is documented by * example; the docs mention `on_hand` moving as a consequence, which is not * the same as it being a valid input. * - The exact search syntax for finding a customer by email * (`customers(query: "email:…")`). It is the obvious shape and almost * certainly right, which is exactly why it is worth confirming rather than * assuming. * * ⚠️ **`@idempotent` is mandatory.** As of API version 2026-04, * `inventoryAdjustQuantities` must be sent with the `@idempotent` directive * and an idempotency key. A stock adjustment is the one operation here where a * silent retry is a real inventory error, so this is not boilerplate. * * ## What was verified for the six added on 2026-09-08 * * Read off the Admin GraphQL reference for the current version, not recalled. * The pages, and the one fact from each that a from-memory version gets wrong: * * - `draftOrderCreate(input: DraftOrderInput!)` — `lineItems` is the only * required field of the input, and each item is `{ variantId, quantity }` * OR `{ title, originalUnitPrice, quantity }` for a custom line. * - `draftOrderComplete(id: ID!, …)` — the id is a **sibling argument**, not * inside an input. `paymentPending` is deprecated and is not offered here. * - `fulfillmentCreate(fulfillment: FulfillmentInput!)` — takes * `lineItemsByFulfillmentOrder`, NOT an order id. There is no mutation that * fulfils "an order": fulfilment hangs off FulfillmentOrder, so the * executor has to look them up first. That indirection is the whole reason * ADR-0007 left this out of v1. * - `orderCancel(orderId:, reason:, restock:, …)` — `reason` and `restock` are * **required arguments**, and the payload's errors arrive in * `orderCancelUserErrors`; plain `userErrors` is deprecated there. * - `metafieldsSet(metafields: [MetafieldsSetInput!]!)` — takes a LIST, and * each entry needs all of `ownerId`, `namespace`, `key`, `type`, `value`. * `type` is not guessable from the value: `single_line_text_field` and * `number_integer` are different metafields. * - `productCreate(product: ProductCreateInput!)` — the argument is `product`, * not `input`; `input: ProductInput` is the deprecated spelling. It creates * ONE default variant; more variants are `productVariantsBulkCreate`, which * is a surface of its own and is not offered here. */ export declare const SHOPIFY_OPERATIONS: readonly ["upsertCustomer", "setTags", "adjustInventory", "findRecords", "createDraftOrder", "completeDraftOrder", "fulfillOrder", "cancelOrder", "setMetafield", "createProduct", "getOrder", "listLocations"]; export type ShopifyOperation = (typeof SHOPIFY_OPERATIONS)[number]; /** Type guard — for DTOs and AI tool calls, where the input is untrusted. */ export declare function isShopifyOperation(value: unknown): value is ShopifyOperation; /** * The resources `tagsAdd` / `tagsRemove` accept. Taken from the mutation * reference — this list is the API's, not a subset we chose. * * `article` and `discountNode` are left out of the node's own picker on * purpose: neither is something a HostWebhook flow tags in practice, and every * extra option is a field the user has to read past. */ export declare const SHOPIFY_TAGGABLE_RESOURCES: readonly ["order", "customer", "product", "draftOrder"]; export type ShopifyTaggableResource = (typeof SHOPIFY_TAGGABLE_RESOURCES)[number]; /** What `findRecords` can read back. */ export declare const SHOPIFY_SEARCHABLE_RESOURCES: readonly ["orders", "customers", "products"]; export type ShopifySearchableResource = (typeof SHOPIFY_SEARCHABLE_RESOURCES)[number]; /** * `OrderCancelReason`, the whole enum, in Shopify's own spelling. * * A dropdown and not free text — and that is the opposite of the call made for * `adjustInventory`'s `reason`, on purpose. The difference is not taste: the * inventory vocabulary was NOT verifiable from the reference (see the note * above), so a dropdown there would have been a list of invented values. This * one IS the enumeration, read off `enums/OrderCancelReason`, and an argument * of type `OrderCancelReason!` rejects anything outside it — so free text here * would only mean the user finds out by failing. */ export declare const SHOPIFY_CANCEL_REASONS: readonly ["CUSTOMER", "DECLINED", "FRAUD", "INVENTORY", "OTHER", "STAFF"]; export type ShopifyCancelReason = (typeof SHOPIFY_CANCEL_REASONS)[number]; /** `ProductStatus`, the whole enum. Same reasoning as the cancel reasons. */ export declare const SHOPIFY_PRODUCT_STATUSES: readonly ["ACTIVE", "DRAFT", "ARCHIVED"]; export type ShopifyProductStatus = (typeof SHOPIFY_PRODUCT_STATUSES)[number]; export interface ShopifyParamSpec { /** Field key — also the property name on operationConfig. */ name: string; label: string; /** Param type, so the dashboard renders the right control. `location` is a * picker backed by a live lookup of the store's locations; `gid` is a * Shopify global id, which is almost always templated from the payload * rather than typed. */ type: 'gid' | 'location' | 'email' | 'string' | 'number' | 'tags' | 'taggableResource' | 'searchableResource' | 'cancelReason' | 'productStatus' | 'json' | 'boolean'; required?: boolean; description: string; placeholder?: string; } export interface ShopifyOperationSpec { label: string; /** * For the canvas card, where 260px fit [2026-09-30]: only when `label` * doesn't. It lives next to the label so the two can't drift apart. */ labelShort?: string; description: string; /** The GraphQL operation this maps to, so the mapping is checkable. */ apiRoute: string; params: ShopifyParamSpec[]; } export declare const SHOPIFY_OPERATION_SPECS: Record; /** * The OAuth scope each operation needs, so a missing one is named BEFORE the * call instead of arriving as an HTTP 403 on a request that looks fine. * * ## The list is "any one of", not "all of" * * That is `fulfillOrder`'s doing and it is not a generalisation for its own * sake: which fulfilment-order scope applies depends on where the order is * fulfilled from — the merchant's own locations, a third-party service, or the * app itself acting as one. Shopify accepts the mutation if the token carries * ANY of the three. Requiring all three would refuse a store that is correctly * set up. * * ## An empty list means "not checkable from the operation alone" * * `setTags` tags an order, a customer or a product through the same mutation, * and `setMetafield` writes onto whichever resource the owner id points at — * so the scope depends on a FIELD, not on the operation. Guessing would be * worse than not checking: a wrong guess blocks a call Shopify would have * accepted, and a pre-flight check that produces false refusals gets deleted. * Those fall through to Shopify's own 403, which `describeShopifyError` * already explains. * * ⚠️ Read against `shopify.dev/docs/api/usage/access-scopes` and each * mutation's own "Access requirements". Two things follow from that page and * are relied on by `scopesQueFaltanEnShopify`: * * - **A write scope includes read.** `write_orders` grants `read_orders`, so * a required `read_x` is satisfied by a granted `write_x`. * - **Scopes are granted at install.** Adding one to the app does not give * it to credentials that already exist; those have to be reconnected. */ export declare const SHOPIFY_OPERATION_SCOPES: Record; /** * Lo escrito en «Tags» → la lista que espera Shopify (`[String!]!`). * * ## Por qué (2026-09-28) * * El ejecutor partía por comas cualquier texto. Una lista que llegaba como * JSON —`["vip","mx"]`, que es lo que da `{{payload.tags}}` cuando el payload * trae un array— creaba las etiquetas `["vip"` y `"mx"]`. Medido en la tienda * de pruebas: el mismo fallo que tenía Mailchimp. * * - Un array: sus textos. * - Un texto que empieza por `[`: JSON, entero; roto → error, nunca partido. * - Cualquier otro texto: separado por comas. * * Sin vacíos ni repetidas, en el orden en que llegan. */ export declare function etiquetasDeShopify(valor: unknown): string[]; /** * El tipo de un id global de Shopify: `gid://shopify/Order/1` → `Order`. * `null` si no tiene esa forma. */ export declare function tipoDeGidDeShopify(valor: unknown): string | null; /** El tipo de gid que corresponde a cada recurso etiquetable. */ export declare const SHOPIFY_GID_DEL_RECURSO: Readonly>; /** * Which of an operation's scopes the credential does NOT have. * * Empty means "nothing to say" — and it says that in three different * situations, all of which have to fail OPEN: * * 1. The operation declares no scope (the resource-dependent ones). * 2. The credential has a scope string and it covers one of the alternatives. * 3. **The credential has no scope string at all.** Shopify returns the * granted scopes on the token exchange, but a credential stored before * that was read, or one whose metadata was pruned, has an empty string — * and refusing to run a node because we cannot see its permissions would * break working flows to prevent a maybe. Shopify is the authority here; * this check only saves the round trip when it can prove the answer. * * @param concedidos what Shopify granted, as it sends it: a comma-separated * string, or the already-split list. */ export declare function scopesQueFaltanEnShopify(concedidos: string | readonly string[] | null | undefined, requeridos: readonly string[]): string[];