Cookbook (Ejemplos prácticos)

Muestra diversos escenarios como ScriptDefinition completos. Para el fundamento de la sintaxis, consulte el Catálogo de statements y las Expresiones de valor; para la ejecución y las restricciones, consulte Semántica de ejecución, restricciones y seguridad. En todos los ejemplos, el valor escrito en fields es un mapa de locales ({ "<locale>": valor }), y el locale de los ejemplos se ha unificado en en-US. Todos los ejemplos se ejecutan en línea en la ruta que atiende la petición de invocación y devuelven el resultado en el cuerpo de la respuesta de esa invocación. En los ejemplos que tienen una llamada externa (Http, EmailSend), el timeoutMs de ese statement se suma al presupuesto de tiempo de ejecución (en Http, × (1 + retry)) y, si ese statement está dentro de una iteración (Loop, ResourceForEach), se multiplica por el límite de iteraciones (presupuesto de tiempo).

Índice

CRUD básico

1. Crear y publicar Content

{ "method": "Post",
  "statements": [
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
      "fields": { "title": { "en-US": "{ /payload/fields/title }" }, "body": { "en-US": "{ /payload/fields/body }" } },
      "publish": true, "name": "post" },
    { "type": "Return", "value": { "id": "{ /post/sys/id }" }, "statusCode": 201 } ] }

2. Actualizar con un valor calculado (recuento de vistas +1)

{ "method": "Post",
  "statements": [
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
      "fields": { "viewCount": { "en-US": { "$+": [ "{ /payload/fields/viewCount }", 1 ] } } } } ] }

3. Reunir y devolver mi lista de pedidos

{ "method": "Get",
  "statements": [
    { "type": "SetVar", "var": "orders", "value": [] },
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "createdBy": ":self" }, "order": "-sys.createdAt", "from": "Current", "advanced": false,
      "limit": 20, "name": "order",
      "onEach": [
        { "type": "SetVar", "var": "orders", "value": { "$merge": [ "{ /vars/orders }", [ "{ /order }" ] ] } } ] },
    { "type": "Return", "value": { "orders": "{ /vars/orders }" } } ] }

Con createdBy: ":self" recorre «solo lo mío» y reúne los elementos con SetVar para devolverlos. Como ResourceForEach no vincula el resultado del recorrido como colección, para devolverlo como lista se reúne así de forma manual. En el presupuesto de tiempo, el recorrido se contabiliza como el tiempo declarado por onEach multiplicado por el número de elementos procesados. Aquí onEach no tiene ninguna llamada externa, así que el tiempo declarado es 0 y el límite efectivo es el presupuesto básico de 30 segundos; si se pone una llamada externa en onEach, esa multiplicación entra tal cual en el presupuesto y, al alcanzar el máximo de 180 segundos, la ejecución se detiene ahí (presupuesto de tiempo). Cuando basta con leer la lista y devolverla, es preferible llamar directamente a la API de listado de CDA/CMA desde el frontend en lugar de recorrer con un Script.

4. Leer uno, aplicar un guard y aprobar

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "target": { "sys": { "id": "{ /payload/fields/orderId }" } }, "name": "order" },
    { "type": "If", "condition": { "!=": [ "{ /order/fields/status/en-US }", "pending" ] },
      "then": [ { "type": "Return", "value": { "reason": "not pending" }, "isError": true, "statusCode": 409 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
      "fields": { "status": { "en-US": "approved" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

Al vincular un único elemento a un nombre con ResourceRead, se referencia directamente como { /order/fields/... }. Si no existe, la lectura da error (se puede envolver en Try).

Búsqueda y upsert

5. slug upsert (find-then-upsert)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
      "where": { "fields.slug": { "eq": "{ /payload/fields/slug }" } }, "name": "found" },
    { "type": "If", "condition": { "!!": "{ /found/sys/id }" },
      "then": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /found/sys/id }" } },
          "fields": { "body": { "en-US": "{ /payload/fields/body }" } } },
        { "type": "Return", "value": { "id": "{ /found/sys/id }", "op": "updated" } } ],
      "else": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
          "fields": { "slug": { "en-US": "{ /payload/fields/slug }" }, "body": { "en-US": "{ /payload/fields/body }" } }, "name": "created" },
        { "type": "Return", "value": { "id": "{ /created/sys/id }", "op": "created" }, "statusCode": 201 } ] } ] }

ResourceFind vincula directamente la primera coincidencia (o null si no hay ninguna), y { "!!": "{ /found/sys/id }" } bifurca según si existe.

6. Clave de campo dinámica y patch de locale dinámico

{ "method": "Patch",
  "statements": [
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
      "fields": { "{ /payload/fields/fieldKey }": { "{ /payload/fields/locale }": "{ /payload/fields/value }" } } } ] }

Tanto la clave del campo como la clave del bucket de locale son referencias { /ptr }. Se usa para colocar una traducción en un bucket de locale concreto.

API externa

7. Guard de crédito, cobro por adelantado (CAS), llamada al LLM, reembolso en caso de fallo (ejemplo destacado)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_wallet" } },
      "where": { "createdBy": ":self" }, "name": "wallet" },
    { "type": "If", "condition": { "<": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "insufficient credit" }, "isError": true, "statusCode": 402 } ] },
    { "type": "Try",
      "body": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
          "version": "{ /wallet/sys/version }",
          "fields": { "balance": { "en-US": { "$-": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } },
          "name": "charged" } ],
      "catch": [ { "type": "Return", "value": { "ok": false, "reason": "version conflict, reintentar" }, "isError": true, "statusCode": 409 } ] },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
          "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
          "body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 15000, "name": "resp" },
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "{ /resp/body/choices/0/message/content }" } }, "name": "out" },
        { "type": "Return", "value": { "ok": true, "id": "{ /out/sys/id }", "remaining": "{ /charged/fields/balance/en-US }" } } ],
      "catch": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
          "fields": { "balance": { "en-US": { "$+": [ "{ /charged/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } } },
        { "type": "Return", "value": { "ok": false, "reason": "generation failed, refunded" }, "isError": true, "statusCode": 502 } ] } ] }

Primero, un guard comprueba si el saldo es suficiente y luego descuenta antes de la llamada externa. El descuento aplica un bloqueo optimista (CAS) sobre el sys.version de la wallet. Si otra ejecución cambió la wallet entre la lectura del saldo y el descuento, aborta por discrepancia de versión y catch devuelve 409. Como no se ha hecho ninguna llamada externa, las solicitudes concurrentes no se descuentan por duplicado. Solo después de confirmar el descuento se llama al LLM y, si esa llamada falla, catch vuelve a sumar el importe descontado (cost) para emitir un reembolso (compensación) y luego devuelve 502. El orden consiste en confirmar el cargo antes de la llamada externa irreversible y compensar solo en caso de fallo. La clave secreta se coloca en una cabecera secret:true. Para conocer los límites de la compensación, consulte Sin transacciones y compensación en Semántica de ejecución.

8. Convertir una imagen (URL) en Media y adjuntarla a Content

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "title": { "en-US": "{ /payload/fields/prompt }" },
                  "file":  { "en-US": { "source": "{ /gen/body/data/0/url }", "encoding": "url" } } }, "name": "img" },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_artwork" } },
      "fields": { "prompt": { "en-US": "{ /payload/fields/prompt }" }, "image": { "en-US": "{ /img/sys/id }" } } } ] }

Se crea el Media con un name y ResourceCreate (Content) coloca { /img/sys/id } en el campo de referencia. Media usa el mismo modelo de fields que Content. file es la instrucción de ingesta { source, encoding }. La ingesta de archivo no declara ningún tiempo, así que sale del presupuesto básico de 30 segundos, y tampoco se incluye en el límite de llamadas externas por definición (restricciones estáticas).

9. Imagen base64 a Media

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "file": { "en-US": { "source": "{ /gen/body/data/0/b64_json }", "encoding": "base64" } } } } ] }

10. Publicación o eliminación condicional tras la moderación

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.mod.com/check",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "text": "{ /payload/fields/body }" }, "name": "mod" },
    { "type": "If", "condition": { "==": [ "{ /mod/body/flagged }", true ] },
      "then": [ { "type": "ResourceDelete",  "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ],
      "else": [ { "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] } ] }

11. try/catch: fallback en caso de fallo externo

{ "method": "Post",
  "statements": [
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://primary.api/gen",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
          "body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 8000, "name": "resp" },
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "{ /resp/body/text }" }, "source": { "en-US": "primary" } } } ],
      "catch": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "Generación fallida" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ] }

12. Rellenar con IA el resumen y las etiquetas de una entrada

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
      "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/body }", "response_format": { "type": "json_object" } },
      "timeoutMs": 15000, "name": "resp" },
 
    { "type": "Try",
      "body": [
        { "type": "ParseJson", "name": "ai", "value": "{ /resp/body/choices/0/message/content }" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
          "fields": { "summary": { "en-US": "{ /ai/summary }" },
                      "tags":    { "en-US": "{ /ai/tags }" } } },
        { "type": "Return", "value": { "ok": true, "tags": "{ /ai/tags }" } } ],
      "catch": [
        { "type": "Return", "value": { "ok": false, "reason": "model did not return JSON" }, "isError": true, "statusCode": 502 } ] } ] }

Cuando se crea una entrada, el modelo rellena su resumen y sus etiquetas (se engancha a Content.Create como acción vinculada de un Webhook). Aquí la respuesta llega en dos capas. El responseType de Http es Json por defecto, así que el sobre de la respuesta de la API ya es un objeto, pero la respuesta que ha producido el modelo está dentro, en choices/0/message/content, como cadena. Por eso hay que quitar una capa más con ParseJson antes de poder sacar los valores como { /ai/summary } y { /ai/tags }. Las etiquetas se escriben tal cual, como array, en un campo Array (elementos de tipo ShortText).

Aunque se fije el contrato con salida estructurada (response_format), llega algo que no es JSON cuando la respuesta se corta por el límite de longitud o el modelo rechaza la petición. Por eso el parseo va envuelto en Try, que convierte el fallo de parseo en un 502. El mensaje de fallo lleva el texto que se intentó parsear, así que se puede ver qué llegó. Si el sobre de una API ya no es JSON, dé a Http un responseType: "Text" y pase { /resp/body } directamente (véase Http y ParseJson).

Paralelo

13. Combinar dos llamadas externas paralelas en un Content

{ "method": "Post",
  "statements": [
    { "type": "Parallel", "branches": [
      [ { "type": "Http", "method": "GET", "url": "https://api.a.com/x", "name": "a" } ],
      [ { "type": "Http", "method": "GET", "url": "https://api.b.com/y", "name": "b" } ] ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_merged" } },
      "fields": { "left": { "en-US": "{ /a/body/value }" }, "right": { "en-US": "{ /b/body/value }" } } } ] }

14. Revisión de registro: puntuaciones en paralelo seguidas de una decisión basada en and

{ "method": "Post",
  "statements": [
    { "type": "Parallel", "branches": [
      [ { "type": "Http", "method": "POST", "url": "https://api.fraud.com/score",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
          "body": { "email": "{ /payload/fields/email }" }, "name": "fraud" } ],
      [ { "type": "Http", "method": "GET", "url": "https://api.credit.com/v1/{ /payload/fields/userId }/score",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ], "name": "credit" } ] ] },
    { "type": "If",
      "condition": { "and": [ { "<": [ "{ /fraud/body/risk }", 0.5 ] }, { ">=": [ "{ /credit/body/score }", 700 ] } ] },
      "then": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
          "fields": { "email": { "en-US": "{ /payload/fields/email }" }, "status": { "en-US": "approved" } } },
        { "type": "Return", "value": { "decision": "approved" }, "statusCode": 201 } ],
      "else": [ { "type": "Return", "value": { "decision": "manual-review" }, "statusCode": 202 } ] } ] }

También se puede insertar { /ptr } en la ruta de la URL. Los resultados de las ramas se referencian tras la unión. Hay dos llamadas externas. El número de llamadas externas que puede contener una definición es un límite por plan (véase Planes de precios), así que compruebe que está dentro de ese límite.

Bucles y agregación

En el presupuesto de tiempo, Loop y ResourceForEach se contabilizan como el tiempo declarado por el body (onEach) multiplicado por el límite de iteraciones. En los ejemplos de esta sección el body no tiene llamadas externas, así que el tiempo declarado es 0 y el límite efectivo es el presupuesto básico de 30 segundos. Ese es también el punto en el que la iteración topa realmente (presupuesto de tiempo, Loop).

15. N Content a partir de una entrada de array (Loop over)

{ "method": "Post",
  "statements": [
    { "type": "Loop", "over": "{ /payload/fields/items }", "name": "item", "maxIterations": 100,
      "body": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_item" } },
          "fields": { "name": { "en-US": "{ /item/name }" }, "qty": { "en-US": "{ /item/qty }" } } } ] } ] }

16. counted loop (for): sembrar slots

{ "method": "Post",
  "statements": [
    { "type": "Loop", "for": { "from": 1, "to": 5 }, "name": "i", "maxIterations": 100,
      "body": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_slot" } },
          "fields": { "index": { "en-US": "{ /i }" }, "status": { "en-US": "open" } } } ] } ] }

for abarca de from a to, ambos incluidos (literales enteros, step por defecto 1). name vincula el contador actual a { /i }.

17. Eliminación en cascada (ForEach, Delete)

{ "method": "Delete",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_comment" } },
      "where": { "fields.postId": { "eq": "{ /payload/sys/id }" } }, "from": "Current", "advanced": false, "name": "comment",
      "onEach": [ { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /comment/sys/id }" } } } ] },
    { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] }

Como ResourceForEach pagina internamente las coincidencias y elimina cada elemento, borra todos los comentarios que cumplen la condición (hasta el límite de la plataforma) sin paginación manual y luego borra el propio post. Como onEach no declara ningún tiempo, el presupuesto de tiempo de ejecución de esta definición es de 30 segundos.

18. Acumulación en bucle: suma con SetVar

{ "method": "Post",
  "statements": [
    { "type": "SetVar", "var": "total", "value": 0 },
    { "type": "Loop", "over": "{ /payload/fields/items }", "name": "row", "maxIterations": 100,
      "body": [ { "type": "SetVar", "var": "total", "value": { "$+": [ "{ /vars/total }", "{ /row/qty }" ] } } ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_summary" } },
      "fields": { "totalQty": { "en-US": "{ /vars/total }" } } } ] }

19. Procesar en lote todos los elementos que cumplen una condición

{ "method": "Post",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
      "where": { "fields.status": { "eq": "draft" } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "post",
      "onEach": [
        { "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /post/sys/id }" } } } ] } ] }

Como ResourceForEach pagina internamente las coincidencias, no hace falta un bucle con cursor (Loop while + acumulación con SetVar). Encuentra todos los draft que cumplen la condición y publica cada uno. Si son tantos que es difícil recorrerlos por completo, fije con limit el máximo a procesar de una vez y deje el where con una condición de «no procesado» para continuar el procesamiento reejecutando.

20. Recopilar ids por lotes desde una lista de correos electrónicos (merge)

{ "method": "Post",
  "statements": [
    { "type": "SetVar", "var": "ids",     "value": [] },
    { "type": "SetVar", "var": "missing", "value": [] },
    { "type": "Loop", "over": "{ /payload/fields/emails }", "name": "email", "maxIterations": 100,
      "body": [
        { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
          "where": { "fields.email": { "eq": "{ /email }" } }, "name": "acc" },
        { "type": "If", "condition": { "!!": "{ /acc/sys/id }" },
          "then": [ { "type": "SetVar", "var": "ids",     "value": { "$merge": [ "{ /vars/ids }",     [ "{ /acc/sys/id }" ] ] } } ],
          "else": [ { "type": "SetVar", "var": "missing", "value": { "$merge": [ "{ /vars/missing }", [ "{ /email }" ] ] } } ] } ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_campaign" } },
      "fields": { "recipients": { "en-US": "{ /vars/ids }" }, "unresolved": { "en-US": "{ /vars/missing }" } } } ] }

Una lectura (ResourceFind) no es una llamada externa, por lo que se permite dentro del body de un Loop. La presencia y la ausencia se acumulan por separado con merge.

Saga y concurrencia

21. Saga de pago (Try/catch/finally)

{ "method": "Post",
  "statements": [
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "fields": { "sku": { "en-US": "{ /payload/fields/sku }" }, "status": { "en-US": "reserved" } },
      "publish": false, "name": "order" },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.pay.com/charge",
          "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
          "body": { "amount": "{ /payload/fields/amount }", "ref": "{ /order/sys/id }" }, "timeoutMs": 10000, "name": "pay" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "status": { "en-US": "paid" }, "txId": { "en-US": "{ /pay/body/transactionId }" } }, "publish": true },
        { "type": "Return", "value": { "orderId": "{ /order/sys/id }", "status": "paid" }, "statusCode": 201 } ],
      "catch": [
        { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } } },
        { "type": "Return", "value": { "reason": "payment failed", "detail": "{ /error/message }" }, "isError": true, "statusCode": 402 } ],
      "finally": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_paylog" } },
          "fields": { "orderRef": { "en-US": "{ /order/sys/id }" }, "amount": { "en-US": "{ /payload/fields/amount }" } } } ] } ] }

Tras la reserva (draft): si el pago tiene éxito, se confirma, se publica y se devuelve 201; si falla, catch elimina la reserva (compensación) y devuelve 402; finally registra siempre. La compensación basada en eliminación produce un nuevo sys.id, por lo que entra dentro de la limitación en la que las referencias se rompen (véase Sin transacciones y compensación en Semántica de ejecución).

22. CAS con bloqueo optimista

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_stock" } },
      "where": { "fields.sku": { "eq": "{ /payload/fields/sku }" } }, "name": "stock" },
    { "type": "If", "condition": { "<": [ "{ /stock/fields/qty/en-US }", "{ /payload/fields/amount }" ] },
      "then": [ { "type": "Return", "value": { "reason": "out of stock" }, "isError": true, "statusCode": 409 } ] },
    { "type": "Try",
      "body": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /stock/sys/id }" } },
          "version": "{ /stock/sys/version }",
          "fields": { "qty": { "en-US": { "$-": [ "{ /stock/fields/qty/en-US }", "{ /payload/fields/amount }" ] } } } },
        { "type": "Return", "value": { "ok": true } } ],
      "catch": [
        { "type": "Return", "value": { "reason": "version conflict, reintentar" }, "isError": true, "statusCode": 409 } ] } ] }

Se lee el stock para obtener un sys.version reciente y luego se descuenta con esa versión (version). Si otra ejecución cambió el valor entre la lectura y la escritura, aborta por discrepancia de versión y catch devuelve 409. El guard de falta de stock queda fuera de Try (una salida anticipada normal).

Email

23. Correo de notificación al comprador de cada pedido (ForEach + EmailSend)

{ "method": "Post",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "fields.notified": { "ne": true } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "order",
      "onEach": [
        { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
          "toServiceUser": { "sys": { "id": "{ /order/fields/buyer/en-US/sys/id }" } },
          "subject": "Tu envío ha comenzado",
          "body": "<p>El envío del producto que pediste ha comenzado.</p>" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "notified": { "en-US": true } } } ] } ] }

Recorre los pedidos a los que aún no se ha enviado la notificación (aquellos cuyo fields.notified no es true), envía un correo al comprador de cada pedido y marca notified justo después. Como EmailSend es de un destinatario por correo, el envío múltiple se hace así, elemento a elemento, con ResourceForEach (onEach puede contener llamadas externas). Al darlo con toServiceUser, la dirección del miembro no entra en el espacio de variables del Script y se resuelve justo antes del envío. Como el where se deja como «no procesado» y al final de onEach se marca como completado, aunque se corte a mitad, al reejecutar continúa desde los pedidos restantes (si el marcado falla justo después de un efecto secundario con éxito, ese registro puede duplicarse en la siguiente ejecución; at-least-once).

Verificación de firma

Cuando una pasarela de pago (PG, MoR) envía un webhook, adjunta una firma al cuerpo. Quien lo recibe debe comprobar, antes de hacer cualquier otra cosa, que esa firma se reproduce con la clave secreta que él tiene. Los dos ejemplos de abajo son los dos métodos que se dan en la práctica: uno genera un código con la clave secreta (keyed) y el otro concatena los campos y la clave secreta y calcula un digest.

24. Verificar la firma de un webhook (desempaquetar la cabecera envuelta, replay window)

{ "method": "Post",
  "statements": [
    { "type": "Regex", "name": "sig", "mode": "Capture",
      "pattern": "^t=(\\d+),v1=([0-9a-f]{64})$", "value": "{ /headers/x-provider-signature }" },
    { "type": "If", "condition": { "==": [ "{ /sig }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "malformed signature header" }, "isError": true, "statusCode": 400 } ] },
 
    { "type": "Signature", "name": "verified", "algorithm": "SHA256",
      "secret": "whsec_9f2c1b7ae4",
      "value": "{ /sig/1 }.{ /rawPayload }", "expected": "{ /sig/2 }" },
    { "type": "If", "condition": { "!": "{ /verified }" },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
 
    { "type": "If", "condition": { ">=": [ { "-": [ "{ /now/seconds }", "{ /sig/1 }" ] }, 300 ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "timestamp outside the replay window" }, "isError": true, "statusCode": 401 } ] },
 
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "fields.orderId": { "eq": "{ /payload/data/orderId }" } }, "name": "order" },
    { "type": "If", "condition": { "==": [ "{ /order }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "unknown order" }, "isError": true, "statusCode": 404 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
      "fields": { "status": { "en-US": "paid" }, "paidAt": { "en-US": "{ /now/iso }" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

Como el proveedor envía la marca de tiempo y el código juntos en una sola cabecera (t=1492774577,v1=<hex de 64 caracteres>), no se puede construir el mensaje que se va a firmar antes de desempaquetar la cabecera. De ahí este orden.

  1. El Capture de Regex desempaqueta la cabecera y la separa en { /sig/1 } (la marca de tiempo) y { /sig/2 } (el código). El índice 0 es la coincidencia completa y desde 1 están los grupos de captura. Si el formato no encaja, { /sig } es null y ahí mismo se responde con un 400.
  2. Signature toma como mensaje "<marca de tiempo>.<cuerpo original>", genera el código y lo compara con { /sig/2 }. La clave está en tomar el mensaje del original previo al parseo ({ /rawPayload }). Al volver a convertir en cadena el /payload parseado, los espacios en blanco y la notación de los números quedan normalizados y ya no se recuperan los bytes que firmó la contraparte. Poniendo los dos punteros seguidos en la cadena se concatenan tal cual, así que no hace falta ningún operador.
  3. Si { /verified } es false, es un 401. Que la firma sea incorrecta y que no venga la cabecera son una misma cosa, false (no se le indica a quien envía cuál de las dos falla).
  4. Aunque la firma sea correcta, se rechaza una petición vieja. { /now/seconds } es el instante en que arrancó esta ejecución, así que se mira si su diferencia con la marca de tiempo que lleva la firma supera la replay window (aquí, 300 segundos). La marca de tiempo ha llegado como cadena desde la cabecera, pero la operación aritmética la convierte en número.
  5. Solo después de pasar hasta aquí se busca el pedido y se cambia su estado.

Como no hay ninguna llamada externa, no se declara ningún tiempo, así que termina dentro del presupuesto básico de 30 segundos y el proveedor recibe la respuesta en el momento. El secret que se usa para la verificación, a diferencia del secret: true de las cabeceras de Http, no se almacena cifrado, así que mantenga acotados los roles que pueden leer este Script (las cabeceras secret del modelo de seguridad).

Hay dos formas de permitir que el proveedor llame a esta ventanilla, y lo que las separa es si ese proveedor puede enviar cabeceras personalizadas.

  • Si puede enviarlas, se emite un token que solo contiene el permiso Execute de ese Script, se le pide que lo ponga en la cabecera Authorization y que llame a /execute. El rol que se acota a un único Script se trata en el permiso script de SpaceRole, y el token en Space Access Token. Esta es la opción por defecto.
  • Si no puede enviarlas (un proveedor que solo permite registrar la URL de callback y no tiene ninguna opción para añadir cabeceras), se activa el anonymousCallEnabled de ese Script y se registra como callback la dirección /execute/anonymous. En ese caso la ejecución pasa a ser con la identidad del autor, así que el updatedBy del pedido que modifica este Script también es el autor y, al no haber autenticación, la verificación de firma de arriba es la única autenticación de esta ventanilla. Las condiciones y las reglas de guardado se tratan en Llamada anónima.

La definición anterior cumple tal cual las condiciones de un Script anónimo: no usa el filtro createdBy: ":self" y las peticiones que no pasan la firma ni la replay window se cortan con Return antes de tocar nada.

25. Verificar una firma hash sin clave

{ "method": "Post",
  "statements": [
    { "type": "Hash", "name": "expectedSign", "algorithm": "SHA256", "encoding": "HexUpper",
      "value": "{ /payload/orderId }{ /payload/amount }9f2c1b7ae4" },
    { "type": "If", "condition": { "!=": [ "{ /expectedSign }", "{ /payload/signature }" ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/orderRef }" } },
      "fields": { "status": { "en-US": "paid" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

Es el método que, en lugar de HMAC, "concatena unos campos determinados y la clave secreta y calcula su SHA256". Hash no tiene campo secret: la clave secreta (9f2c1b7ae4) se escribe directamente dentro de value, en la posición en la que la coloca ese esquema. Como cada esquema pone la clave delante, detrás o en medio, así se expresan todas las posiciones.

El encoding se ajusta a la notación de la contraparte (Hex, HexUpper, Base64, Base64Url). A diferencia de Signature, el resultado es una cadena, así que la comparación hay que hacerla uno mismo, y esa comparación es una comparación de igualdad normal. Como el límite de value es de 128 caracteres, para los esquemas que calculan sobre todo el cuerpo se usa Signature.

Consulta de miembros

26. Buscar un miembro por correo y darle un cupón con correo de aviso

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "ServiceUser",
      "where": { "sys.email": { "eq": "{ /payload/fields/email }" } }, "name": "member" },
    { "type": "If", "condition": { "==": [ "{ /member }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "member not found" }, "isError": true, "statusCode": 404 } ] },
 
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_coupon" } },
      "fields": { "code": { "en-US": "WELCOME-{ /member/sys/id }" },
                  "owner": { "en-US": "{ /member/sys/id }" } }, "publish": false, "name": "coupon" },
 
    { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
      "toServiceUser": { "sys": { "id": "{ /member/sys/id }" } },
      "subject": "Se ha emitido tu cupón",
      "body": "<p>Le hemos entregado a { /member/nickname } el cupón { /coupon/fields/code/en-US }.</p>" },
    { "type": "Return", "value": { "ok": true, "memberId": "{ /member/sys/id }" } } ] }

Busca a un miembro con un solo correo electrónico y usa su sys.id como propietario del cupón y como destinatario del mensaje. Las reglas al leer el directorio de miembros son estas.

  • Como sys.email se almacena cifrado, solo acepta operadores de coincidencia exacta (eq, ne, in, nin). Con otro operador, como prefix, no se obtienen 0 resultados en silencio: la ejecución falla.
  • Si no hay coincidencia, ResourceFind vincula null, así que la existencia se bifurca igual que al buscar un Content.
  • Los campos de un miembro, a diferencia de los de Content y Media, no son un mapa de locales. Se referencian tal cual, como en { /member/nickname }.
  • Al enviar el correo no se extrae la dirección: se pasa el sys.id en toServiceUser. Como el motor resuelve la dirección justo antes del envío, la dirección del miembro no entra en el espacio de variables del Script.
  • Para guardar esta definición, el settings del SpaceRole del autor debe tener SETTING_SERVICE_LOGIN. Los statements que crean, modifican o eliminan miembros no se guardan con ningún rol (Lectura del directorio de miembros).

EmailSend suma su timeoutMs (10 segundos si no se declara) al presupuesto de tiempo de ejecución y cuenta como una sola llamada en el límite de llamadas externas por definición.