クックブック (実践例集)

さまざまなシナリオを完結した ScriptDefinition として示します。文法の根拠は Statement カタログ値式、実行と制約は 実行セマンティクス、制約、セキュリティ を参照してください。すべての例は書き込み fields の値がロケールマップ({ "<locale>": 値 })であり、例のロケールは en-US に統一しています。すべての例は呼び出しリクエストを処理する経路でインラインに実行され、呼び出しのレスポンス本文として結果を返します。外部呼び出し(HttpEmailSend)がある例は、その文の timeoutMs が実行の時間予算に加算され(Http× (1 + retry))、その文が繰り返し(LoopResourceForEach)の中にあれば繰り返しの上限の分だけ掛けられます(時間予算)。

目次

基本 CRUD

1. 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. 計算値で update (閲覧数 +1)

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

3. 自分の注文一覧をまとめて返す

{ "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 }" } } ] }

createdBy: ":self" で「自分のものだけ」を巡回し、項目を SetVar で集めて返します。ResourceForEach は巡回の結果をコレクションとしてバインドしないため、一覧として返すにはこのように自分で集めます。巡回は時間予算では、onEach が宣言した時間に処理する項目数を掛けた値として計上されます。ここでは onEach に外部呼び出しがないため宣言時間が 0 で、30 秒の基本予算が実質的な上限になり、onEach に外部呼び出しを置くとその掛け算がそのまま予算に入って、上限の 180 秒に達するとそこで中断されます(時間予算)。一覧を読んで返すだけでよいときは、Script の巡回ではなく、フロントエンドから CDA/CMA の一覧 API を直接呼び出すほうがよいでしょう。

4. 単件取得後に guard して承認

{ "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 } } ] }

ResourceRead で単件を名前にバインドすると { /order/fields/... } で直接参照できます。存在しなければ取得でエラーになります(Try で囲めます)。

検索と 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 は最初のマッチを直接バインドし(なければ null)、{ "!!": "{ /found/sys/id }" } で存在有無を分岐します。

6. 動的フィールドキーと動的ロケール patch

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

フィールドの キー とロケールの バケットキー の両方が { /ptr } 参照です。翻訳を特定のロケールバケットに差し込むときに使います。

外部 API

7. クレジット guard、先行差し引き(CAS)、LLM 呼び出し、失敗時の返金 (代表例)

{ "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, 再試行" }, "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 } ] } ] }

guard で残高が十分かを確認したうえで、外部呼び出しより先に差し引きます。差し引きは wallet の sys.version で楽観ロック(CAS)をかけます。読み取った残高と差し引きの間に別の実行が wallet を変更していた場合、バージョン不一致で abort し、catch409 を返します。外部呼び出しは行わないため、同時リクエストが二重に差し引かれることはありません。差し引きが確定した後にのみ LLM を呼び出し、その呼び出しが失敗した場合は catch で差し引き分(cost)を戻して加算し 返金(補償)したうえで 502 を返します。取り消せない外部呼び出しの前に課金を確定し、失敗したときだけ補償する順序です。秘密鍵は secret:true ヘッダーに置きます。補償の限界は 実行セマンティクスのトランザクションなしと補償 を参照してください。

8. 画像(URL)を Media にして 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 }" } } } ] }

Medianame で作成し、ResourceCreate(Content) が { /img/sys/id } を参照フィールドに差し込みます。MediaContent と同じ fields モデルを使います。file はインジェスト指示 { source, encoding } です。ファイルのインジェストは宣言する時間がないため 30 秒の基本予算から出ていき、1 定義あたりの外部呼び出しの上限にも含まれません(静的制約)。

9. base64 画像を 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. モデレーション後に条件付きで publish または削除

{ "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

{ "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": "生成に失敗しました" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ] }

12. 記事に AI の要約とタグを入れる

{ "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 } ] } ] }

記事が登録されたら、要約とタグをモデルが埋める流れです(Webhook の連携アクションとして Content.Create に掛けておきます)。ここではレスポンスが 2 層になっています。HttpresponseType は既定が Json なので API のレスポンス封筒はすでにオブジェクトですが、モデルが作った答え自体はその中の choices/0/message/content文字列として入っています。そのため ParseJson でもう 1 層ほどいて、はじめて { /ai/summary }{ /ai/tags } として値を取り出せます。タグは Array(要素は ShortText)フィールドに配列のまま書きます。

構造化出力(response_format)で契約を掛けても、レスポンスが長さ制限で切れたりモデルが要求を拒否したりすると、JSON ではないものが返ってきます。そこで Try で囲み、パース失敗を 502 に変えます。失敗メッセージにはパースしようとしたテキストが載るので、何を受け取ったのか確認できます。レスポンス封筒からして JSON ではない API なら、HttpresponseType: "Text" を与えて { /resp/body } をそのまま渡します(HttpParseJson 参照)。

並列

13. 並列の外部呼び出し 2 件を合わせて 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. 加入審査: 並列スコア後に 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 } ] } ] }

URL パスにも { /ptr } を挿入できます。ブランチの結果はジョイン後に参照します。外部呼び出しは 2 件です。1 つの定義に含められる外部呼び出しの数はプランごとの上限なので(料金プラン 参照)、その上限の内かを確認してください。

反復と集計

LoopResourceForEach は、時間予算では body(onEach)が宣言した時間に繰り返しの上限を掛けた値として計上されます。この節の例は body に外部呼び出しがないため宣言時間が 0 で、30 秒の基本予算が実質的な上限です。繰り返しが実際に引っかかる地点もここです(時間予算Loop)。

15. 配列入力で N 件の Content (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): スロットシード

{ "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" } } } ] } ] }

forfrom から to まで 含む 範囲です(整数リテラル、step はデフォルト 1)。name が現在のカウンターを { /i } にバインドします。

17. cascade 削除 (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 }" } } } ] }

ResourceForEach がマッチを内部的にページングして項目ごとに削除するため、手動のページネーションなしで、条件にマッチするコメントをすべて(プラットフォームの上限まで)削除してから投稿自身を削除します。onEach が宣言した時間はないため、この定義の実行の時間予算は 30 秒です。

18. ループ累積: 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. 条件にマッチする全項目を一括処理

{ "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 }" } } } ] } ] }

ResourceForEach がマッチを内部的にページングするため、cursor ループ(Loop while + SetVar 累積)は不要です。条件にマッチする draft をすべて見つけて項目ごとに公開します。件数が非常に多く完走が難しい場合は、limit で一度に処理する上限を定め、where を「未処理」条件にして再実行で続けて処理します。

20. メールアドレス一覧で id をバッチ収集 (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 }" } } } ] }

読み取り(ResourceFind) は外部呼び出しではないため Loop の body で許可されます。存在と不在をそれぞれ merge で累積します。

サガと並行性

21. 決済サガ (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 }" } } } ] } ] }

予約(draft)後、決済成功時は確定と publish と 201、失敗時は catch が予約削除(補償)と 402finally は常にログです。削除補償は新しい sys.id になるため参照が壊れる限界の範囲です(実行セマンティクスのトランザクションなしと補償 を参照)。

22. 楽観ロック CAS

{ "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, 再試行" }, "isError": true, "statusCode": 409 } ] } ] }

在庫を読み取って新鮮な sys.version を確保したうえで、そのバージョンで差し引きます(version)。読み取りと書き込みの間に別の実行が値を変更していた場合、バージョン不一致で abort され、catch が 409 を返します。在庫不足の guard は Try の外です(正常な早期終了)。

メール

23. 各注文の購入者に通知メール (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": "配送を開始しました",
          "body": "<p>ご注文の商品の配送を開始しました。</p>" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "notified": { "en-US": true } } } ] } ] }

まだ通知を送っていない注文(fields.notifiedtrue でないもの)を巡回し、各注文の購入者にメールを送って、すぐに notified を記録します。EmailSend は 1 通につき受信者 1 名なので、多件送信はこのように ResourceForEach で項目ごとに送ります(onEach には外部呼び出しを含められます)。toServiceUser で渡すと、メンバーのアドレスが Script の変数空間に入らず、送信の直前に resolve されます。where を「未処理」にして onEach の最後で完了を記録しているため、途中で止まっても再実行すれば残った注文から続きます(副作用の成功直後の記録が失敗すると、その件は次の実行で重複することがあります。at-least-once)。

署名検証

決済代行会社(PG・MoR)は Webhook を送るとき、本文に署名を付けます。受け取る側は何かをする前に、その署名が自分の持つ秘密鍵で再現できるかを確認しなければなりません。以下の 2 つの例は、実際に分かれる 2 つの方式です。1 つは秘密鍵でコードを作る(keyed)方式で、もう 1 つはフィールドと秘密鍵をつなげてダイジェストを計算する方式です。

24. Webhook の署名検証 (包まれたヘッダーを解く、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 } } ] }

プロバイダーがタイムスタンプとコードを 1 つのヘッダーにまとめて 送ってくるため(t=1492774577,v1=<64 文字の hex>)、ヘッダーを解く前は署名対象のメッセージを作れません。そのため順序がこう決まります。

  1. RegexCapture がヘッダーを解いて { /sig/1 }(タイムスタンプ)と { /sig/2 }(コード)に分けます。インデックス 0 はマッチ全体で、1 からがキャプチャグループです。形式が合わなければ { /sig }null なので、その場で 400 を返します。
  2. Signature"<タイムスタンプ>.<原文の本文>" をメッセージとしてコードを作り、{ /sig/2 } と比較します。肝心なのは、メッセージを パース前の原文({ /rawPayload })で取ることです。パースされた /payload を再び文字列に戻すと、空白と数値表記が正規化され、相手が署名したバイトには戻りません。2 つのポインターを文字列に並べて置くとそのままつながるため、演算子は必要ありません。
  3. { /verified }false なら 401 です。署名が違うこととヘッダーがないことは、どちらも false の 1 つにまとめられます(どちらが違っているのかを送信元に知らせません)。
  4. 署名が合っていても、古いリクエストは拒否します。{ /now/seconds } はこの実行が始まった時刻なので、署名に載ったタイムスタンプとの差が replay window(ここでは 300 秒)を超えていないかを見ます。タイムスタンプはヘッダーから文字列で来ますが、算術演算が数値に変換してくれます。
  5. ここまで通ってから初めて、注文を探して状態を変えます。

外部呼び出しがなく宣言する時間もないため、30 秒の基本予算の内に終わり、プロバイダーはレスポンスをその場で受け取ります。検証に使う secretHttp ヘッダーの secret: true と違って 暗号化して保存されない ため、この Script を読める役割を狭くしておいてください(セキュリティモデルの secret ヘッダー)。

プロバイダーにこの窓口を呼ばせる方法は 2 つあり、そのプロバイダーがカスタムヘッダーを送れるかどうかが分かれ目です。

  • 送れる場合は、その ScriptExecute 権限だけを持つトークンを発行して Authorization ヘッダーに入れさせ、/execute を呼ばせます。特定の Script 1 つに絞る役割は SpaceRole の script 権限 で、トークンは Space Access Token で扱います。こちらが基本です。
  • 送れない場合(コールバック URL だけを登録でき、ヘッダーを付ける設定がないプロバイダー)は、その ScriptanonymousCallEnabled を有効にして /execute/anonymous のアドレスをコールバックとして登録します。このとき実行は 作成者のアイデンティティになるため、この Script が直す注文の updatedBy も作成者になり、認証がないので 上の署名検証がこの窓口の唯一の認証になります。条件と保存の規則は 匿名呼び出し で扱います。

上の定義は、そのまま匿名 Script の条件を満たしています。createdBy: ":self" フィルターを使わず、署名と replay window を通らなかったリクエストは何にも触れる前に Return で打ち切ります。

25. 鍵なしのハッシュ署名検証

{ "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 } } ] }

HMAC ではなく「決められたフィールドと秘密鍵をつなげて SHA256 を計算する」方式です。Hash には secret フィールドがなく、秘密鍵(9f2c1b7ae4)をそのスキームが置く位置に、value の中へ直接書きます。スキームごとに鍵が前・後・中間と分かれるため、この方がすべての位置を表現できます。

encoding は相手の表記に合わせます(HexHexUpperBase64Base64Url)。Signature と違って結果が文字列なので比較を自分で行う必要があり、その比較は通常の等価比較です。value の上限が 128 文字なので、本文全体を対象に計算するスキームには Signature を使います。

会員の検索

26. メールアドレスで会員を探してクーポンと通知メール

{ "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": "クーポンを発行しました",
      "body": "<p>{ /member/nickname }様にクーポン { /coupon/fields/code/en-US } を差し上げました。</p>" },
    { "type": "Return", "value": { "ok": true, "memberId": "{ /member/sys/id }" } } ] }

メールアドレス 1 つで会員を探し、その sys.id をクーポンの所有者とメールの受信者に使います。会員ディレクトリを読み取るときの規則はこうです。

  • sys.email は暗号化して保存されるため、完全一致系の演算子のみを受け取ります(eqneinnin)。prefix のような他の演算子を渡すと、黙って 0 件になるのではなく実行が失敗します。
  • マッチがなければ ResourceFindnull をバインドするので、Content を探すときと同じ形で存在の有無を分岐します。
  • 会員のフィールドは ContentMedia と違ってロケールマップではありません。{ /member/nickname } のようにそのまま参照します。
  • メールを送るときはアドレスを取り出さず、toServiceUsersys.id を渡します。エンジンが送信の直前にアドレスを resolve するため、会員のアドレスが Script の変数空間に入りません。
  • この定義を 保存するには、作成者の SpaceRole settingsSETTING_SERVICE_LOGIN が必要です。会員を作る・直す・消す文は、どの役割でも保存されません(会員ディレクトリの読み取り)。

EmailSendtimeoutMs(宣言がなければ 10 秒)を実行の時間予算に加算し、1 定義あたりの外部呼び出しの上限に 1 つとして数えられます。