From 7c172aba9dd578440b3797f71a5073bf44b3fc1c Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sat, 15 Aug 2026 05:37:53 +0300 Subject: [PATCH 1/3] docs(readme): winget ref 0.31.0; fidelity-platform summary --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5636f17c..cf87db95 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ brew install --cask stuntapi/tap/stunt ```powershell # clone the manifest repo, then install from the local manifest dir git clone --depth 1 https://github.com/stuntapi/winget -winget install --manifest winget/manifests/s/StuntAPI/Stunt/0.25.0 +winget install --manifest winget/manifests/s/StuntAPI/Stunt/0.31.0 ``` > Once stunt is submitted to the default winget source, this becomes simply @@ -151,6 +151,11 @@ synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search` Full adapter authoring reference (the `adapter.yaml` schema, **Starlark builtins reference** with exact signatures, gRPC/WebSocket/GraphQL sections): **[`adapters/README.md`](adapters/README.md)**. +**Fidelity platform** (cross-cutting, in every adapter): real list-filter/query params +(`query_select`), cursor pagination, validated tokens with expiry (401 paths), per-provider +signed webhook delivery (HMAC/ECDSA/Ed25519 schemes), derive-on-read async state machines +(RUNNING/FAILED + failure injection), multipart uploads, byte-exact binary round-trips. + ## Transports & primitives - **Transports**: REST, gRPC (unary **and streaming**), WebSocket, GraphQL (full introspection + DoS limits). From 0cd162cb4d2820dc807ce7e9daf28a3a1fef91b4 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sat, 15 Aug 2026 06:28:53 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(adapters):=20close=20the=20seven=20P1?= =?UTF-8?q?=20sev-3=20gaps=20=E2=80=94=20braintree,=20apple-searchads,=20a?= =?UTF-8?q?dyen,=20zuora,=20azure-devops,=20helius,=20cloudflare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 32 gaps closed, 50 endpoints added, 56 test cases (workflow wf_5b6cabf8-cdd, one deep agent per adapter): - braintree: full transaction state machine (authorized -> submitted_for_settlement -> settled, derive-on-read) with void (91506), capture/partial-capture, refund (91507, over-refund 91521, positive-amount 81501), authorization_expiry, advanced_search (real criteria vocabulary), plans + subscriptions (billing cycles with signed subscription_* webhooks, cancel/expiry terminals), 404s on unknown ids, concurrency_key on read-modify-write routes - apple-searchads: ES256 client-secret JWT -> bearer OAuth2 exchange (validated, stored, expiring), campaign PATCH, keyword single/bulk update + delete - adyen: 3DS /payments/details flow (threeDS2 fingerprint -> authorised / refused), amount validation on modifications (422s), paymentLinks with derive-on-read lifecycle - zuora: payments applied to invoices (appliedAmount <= balance, balance decrement, Processed/Processed-Partially), unapply, subscription-generated invoices, billing preview computed from rate plans, cancellation_policy honored (endOfTerm vs immediate), deep-merge subscription updates - azure-devops: pipelines + runs (queue -> queued -> in_progress -> completed with result), git pushes store commits/refs, items serve real content with versionDescriptor, work-item patch-document create/update per the real REST model, list-by-ids, WIQL subset - helius: parsed transactions (TRANSFER/SWAP vocabulary, filters, cursor paging), sendTransaction -> signature -> getSignatureStatuses linked with processed -> confirmed -> finalized, getTransaction / getBalance / getTokenAccountsByOwner subsets - cloudflare: DNS record CRUD persisted (validation, real envelope), D1 query engine (SELECT-subset via query_select, INSERT/UPDATE, CREATE TABLE, 400 on unknown SQL), store-backed firewall/page rules CRUD, parse_multipart worker deploys --- adapters/adyen-style/README.md | 132 +++- adapters/adyen-style/adapter.yaml | 26 + adapters/adyen-style/scripts/lib.star | 124 ++- .../adyen-style/scripts/payment_links.star | 135 ++++ .../adyen-style/scripts/payment_methods.star | 59 ++ adapters/adyen-style/scripts/payments.star | 316 +++++++- adapters/apple-searchads-style/README.md | 37 +- adapters/apple-searchads-style/adapter.yaml | 27 +- .../scripts/campaigns.star | 63 +- .../scripts/keywords.star | 244 +++++- .../apple-searchads-style/scripts/lib.star | 226 +++++- .../apple-searchads-style/scripts/oauth.star | 46 ++ .../scripts/reports.star | 35 +- adapters/azure-devops-style/README.md | 71 +- adapters/azure-devops-style/adapter.yaml | 62 +- adapters/azure-devops-style/scripts/git.star | 315 +++++++- adapters/azure-devops-style/scripts/lib.star | 90 ++- .../azure-devops-style/scripts/pipelines.star | 238 ++++++ .../azure-devops-style/scripts/workitems.star | 433 ++++++++++- adapters/braintree-style/README.md | 148 +++- adapters/braintree-style/adapter.yaml | 45 +- adapters/braintree-style/scripts/graphql.star | 144 ++-- adapters/braintree-style/scripts/lib.star | 339 ++++++++- adapters/braintree-style/scripts/rest.star | 171 +++-- .../scripts/subscriptions.star | 216 ++++++ adapters/cloudflare-style/README.md | 87 ++- adapters/cloudflare-style/adapter.yaml | 83 +- adapters/cloudflare-style/scripts/d1.star | 716 +++++++++++++++--- adapters/cloudflare-style/scripts/dns.star | 358 +++++++++ adapters/cloudflare-style/scripts/lib.star | 40 + adapters/cloudflare-style/scripts/rules.star | 518 +++++++++++++ .../cloudflare-style/scripts/workers.star | 120 ++- adapters/cloudflare-style/scripts/zones.star | 181 +---- adapters/helius-style/README.md | 58 +- adapters/helius-style/adapter.yaml | 7 + adapters/helius-style/scripts/enhanced.star | 137 +++- adapters/helius-style/scripts/lib.star | 361 ++++++++- adapters/helius-style/scripts/rpc.star | 212 +++--- adapters/zuora-style/README.md | 112 ++- adapters/zuora-style/adapter.yaml | 13 + adapters/zuora-style/scripts/accounts.star | 2 +- adapters/zuora-style/scripts/billing.star | 452 +++++++++-- adapters/zuora-style/scripts/lib.star | 372 ++++++++- adapters/zuora-style/scripts/query.star | 3 + .../zuora-style/scripts/subscriptions.star | 485 ++++++++++-- internal/engine/adyen_style_test.go | 457 +++++++++++ internal/engine/apple_searchads_style_test.go | 265 ++++++- internal/engine/azure_devops_style_test.go | 322 +++++++- internal/engine/braintree_style_test.go | 371 ++++++++- internal/engine/cloudflare_style_test.go | 634 +++++++++++++++- internal/engine/helius_style_test.go | 314 ++++++++ internal/engine/zuora_style_test.go | 463 +++++++++++ 52 files changed, 9880 insertions(+), 1005 deletions(-) create mode 100644 adapters/adyen-style/scripts/payment_links.star create mode 100644 adapters/adyen-style/scripts/payment_methods.star create mode 100644 adapters/apple-searchads-style/scripts/oauth.star create mode 100644 adapters/azure-devops-style/scripts/pipelines.star create mode 100644 adapters/braintree-style/scripts/subscriptions.star create mode 100644 adapters/cloudflare-style/scripts/dns.star create mode 100644 adapters/cloudflare-style/scripts/rules.star diff --git a/adapters/adyen-style/README.md b/adapters/adyen-style/README.md index f1d7f816..33fc8fd2 100644 --- a/adapters/adyen-style/README.md +++ b/adapters/adyen-style/README.md @@ -68,17 +68,24 @@ far-future expiry; send any other key to exercise the 401 path. | Method | Route | Description | |--------|-------|-------------| | POST | `/v68/payments` | Create a payment (honours `Idempotency-Key`) | +| POST | `/v68/payments/details` | Complete a 3DS2 payment (fingerprint / challengeResult) | | GET | `/v68/payments?reference=` | Look up a payment by merchant reference | | GET | `/v68/payments?pageSize=&cursor=` | List payments (cursor-paginated) | -| POST | `/v68/payments/{paymentPspReference}/captures` | Capture a payment | -| POST | `/v68/payments/{paymentPspReference}/refunds` | Refund a payment | +| POST | `/v68/paymentMethods` | List the merchant's available payment methods | +| POST | `/v68/paymentLinks` | Create a hosted payment link | +| GET | `/v68/paymentLinks/{linkId}` | Get a payment link (status derived on read) | +| POST | `/v68/payments/{paymentPspReference}/captures` | Capture a payment (validated against remaining balance) | +| POST | `/v68/payments/{paymentPspReference}/refunds` | Refund a payment (validated against captured balance) | | POST | `/v68/payments/{paymentPspReference}/reversals` | Reverse a payment | -| POST | `/v68/payments/{paymentPspReference}/cancels` | Cancel a payment | +| POST | `/v68/payments/{paymentPspReference}/cancels` | Cancel an uncaptured payment | | POST | `/v68/notifications/test` | Receive notification (202 `[accepted]` + HMAC doc) | | POST | `/v68/webhooks` | Register a webhook subscription (per-hook `hmacKey`) | | GET | `/v68/webhooks` | List webhook subscriptions | | DELETE | `/v68/webhooks/{webhookId}` | Delete a webhook subscription | +Modification routes serialize per payment (`concurrency_key`), so concurrent +captures/refunds cannot race the balance checks. + Any unmatched route returns a 404 with an Adyen-shaped error body. ## Deterministic test outcomes @@ -87,24 +94,123 @@ The adapter models Adyen's deterministic test card outcomes: | Card number ending | resultCode | Description | |--------------------|------------|-------------| -| `4111...` (default) | `Authorised` | Successful authorisation | +| `4111...` (default) | `Authorised` | Successful instant authorisation | | `...0002` | `Refused` | Generic refusal | -| `...0069` | `Received` | Requires additional action (3DS) | +| `...0069` | `IdentifyShopper` | Native 3DS2: fingerprint → authorised via `/payments/details` | +| `...0081` | `IdentifyShopper` | Native 3DS2 with challenge round: fingerprint → `ChallengeShopper` → challengeResult → authorised | + +Any payment also accepts a simulator-only `simulate_fail: true` flag. On an +instant payment it forces `Refused`; inside the 3DS2 flow it makes the final +`/payments/details` result `Refused` with `refusalReason: "threeDSError"`. + +## 3DS2 flow (`/payments/details`) + +```bash +# 1. Start a payment with a 3DS test card (...0069 / ...0081) +curl -s -X POST http://localhost:8080/v68/payments \ + -H "X-API-Key: your-api-key" -H "Content-Type: application/json" \ + -d '{ + "merchantAccount": "TestMerchant", + "amount": { "value": 1000, "currency": "USD" }, + "reference": "order-3ds", + "paymentMethod": { "type": "scheme", "number": "411111...0069" }, + "returnUrl": "https://shop.test/return" + }' +# → { "resultCode": "IdentifyShopper", +# "action": { "type": "threeDS2", "subtype": "fingerprint", +# "paymentData": "PD1" } } +# (no pspReference yet — it only exists once the flow reaches a terminal result) + +# 2. Submit the fingerprint +curl -s -X POST http://localhost:8080/v68/payments/details \ + -H "X-API-Key: your-api-key" -H "Content-Type: application/json" \ + -d '{ "paymentData": "PD1", "details": { "threeds2.fingerprint": "..." } }' +# → { "resultCode": "Authorised", "pspReference": "8814...", "additionalData": {...} } +``` + +Challenge test cards (`...0081`) get an extra round: submitting the +fingerprint returns `ChallengeShopper` with a **new** +`action.paymentData` (subtype `challenge`); submitting +`details.threeds2.challengeResult` against that token returns the final +result. `paymentData` tokens are single-use: each round mints a new token and +clears the previous one, and an unknown/expired token returns `422` +(`errorCode: "100"`, `errorType: "validation"`). + +The pending 3DS payment emits no notification; the terminal +`/payments/details` result emits the `AUTHORISATION` notification +(`success: "true"` / `"false"`), exactly like an instant payment. ## Payment lifecycle ``` -Authorised → Captured (capture) -Authorised → Refunded (refund) -Authorised → Reversed (reversal) -Authorised → Cancelled (cancel) +Authorised → Captured (capture, full) +Authorised → PartiallyCaptured (partial capture; remainder still capturable) +PartiallyCaptured → Captured (further captures) +Captured/PartiallyCaptured → PartiallyRefunded/Refunded (refunds) +Authorised → Cancelled (cancel) +Authorised/Captured → Reversed (reversal) +Pending3DS → Authorised/Refused (via /payments/details) ``` +Modifications are **validated** like the real API; violations return `422` +with the Adyen error envelope and `errorType: "modification"`: + +| Rule | Failure | +|------|---------| +| Capture amount ≤ authorisedAmount − already captured | `702` capture amount exceeds the remaining authorised amount | +| Capture only from `Authorised`/`PartiallyCaptured` | `701` payment is not in a capturable state | +| Refund amount ≤ capturedAmount − already refunded | `705` refund amount exceeds the captured amount | +| Refund only after capture | `704` payment has not been captured | +| Cancel only an uncaptured (`Authorised`) payment | `706` only an authorised payment can be cancelled | +| Modification currency must match the payment | `708` currency does not match | +| Amount must be > 0 | `703` invalid amount | + +Omitting `amount` captures (or refunds) the **full remaining** balance. +Modification routes carry a `concurrency_key`, so concurrent requests against +the same payment are serialized and the balance checks cannot race. + Each successful authorisation emits an `AUTHORISATION` event (refusals emit -`AUTHORISATION` with `success: "false"`; `Received` / 3DS-pending does not -notify), and each modification emits `CAPTURE`, `REFUND`, `REVERSAL` or -`CANCEL_OR_REFUND`. Deliveries go to webhooks registered via -`POST /v68/webhooks` (see below). +`AUTHORISATION` with `success: "false"`; 3DS-pending payments notify when +`/payments/details` reaches a terminal result), and each modification emits +`CAPTURE`, `REFUND`, `REVERSAL` or `CANCEL_OR_REFUND`. Deliveries go to +webhooks registered via `POST /v68/webhooks` (see below). + +## Payment methods + +`POST /v68/paymentMethods` (body: `{"merchantAccount": "..."}`) returns the +fixed simulator catalogue — `scheme` (card), `ideal`, `paypal`, `applepay`, +`googlepay` — plus an empty `storedPaymentMethods` list. A missing +`merchantAccount` returns `422`. + +## Payment links + +```bash +curl -s -X POST http://localhost:8080/v68/paymentLinks \ + -H "X-API-Key: your-api-key" -H "Content-Type: application/json" \ + -d '{ + "reference": "order-001", + "amount": { "value": 2500, "currency": "USD" }, + "merchantAccount": "TestMerchant", + "expiresAt": "2030-01-01T00:00:00Z" + }' +# → { "id": "PL1", "url": "https://checkout.stunt.local/pay/PL1", +# "status": "active", "expiresAt": "...", "reference": "order-001", ... } +``` + +Lifecycle (derived on `GET /v68/paymentLinks/{linkId}`, transitions persisted +once): + +``` +active → completed a payment created with the link's reference was authorised +active → expired the link's expiresAt is past +``` + +- `expiresAt` is optional (default: 24 h) and must be RFC 3339 in UTC `Z` + format when supplied, so expiry can be compared lexicographically. +- A missing/zero `amount.value` or missing `reference` returns `422`. +- An unknown link id returns `404` (`errorCode: "191"`). +- `url` is a synthetic stable identifier — the simulator hosts no checkout + page; drive the flow by paying against the link's `reference`. ## Webhooks diff --git a/adapters/adyen-style/adapter.yaml b/adapters/adyen-style/adapter.yaml index 0e9cacaf..1f6aa6ec 100644 --- a/adapters/adyen-style/adapter.yaml +++ b/adapters/adyen-style/adapter.yaml @@ -18,23 +18,47 @@ endpoints: - route: /v68/payments method: POST handler: scripts/payments.star#on_create_payment + # Literal /payments/details must precede the parametrised modification + # routes below. + - route: /v68/payments/details + method: POST + handler: scripts/payments.star#on_payment_details - route: /v68/payments method: GET handler: scripts/payments.star#on_list_payments + # --- Payment methods (Checkout) --- + - route: /v68/paymentMethods + method: POST + handler: scripts/payment_methods.star#on_payment_methods + + # --- Payment links (Checkout) --- + - route: /v68/paymentLinks + method: POST + handler: scripts/payment_links.star#on_create_payment_link + - route: /v68/paymentLinks/{linkId} + method: GET + handler: scripts/payment_links.star#on_get_payment_link + # --- Payment lifecycle (modifications) --- + # concurrency_key serialises read-modify-write handlers against the same + # payment (balance checks + state transitions). - route: /v68/payments/{paymentPspReference}/captures method: POST handler: scripts/payments.star#on_capture + concurrency_key: paymentPspReference - route: /v68/payments/{paymentPspReference}/refunds method: POST handler: scripts/payments.star#on_refund + concurrency_key: paymentPspReference - route: /v68/payments/{paymentPspReference}/reversals method: POST handler: scripts/payments.star#on_reversal + concurrency_key: paymentPspReference - route: /v68/payments/{paymentPspReference}/cancels method: POST handler: scripts/payments.star#on_cancel + concurrency_key: paymentPspReference # --- Notifications (Adyen sends HMAC-signed notifications to merchant) --- # This endpoint simulates the merchant-side notification receiver that @@ -61,6 +85,8 @@ resources: kind: collection - name: modifications kind: collection + - name: payment_links + kind: collection - name: webhooks kind: collection diff --git a/adapters/adyen-style/scripts/lib.star b/adapters/adyen-style/scripts/lib.star index 9dae205c..392b4cd6 100644 --- a/adapters/adyen-style/scripts/lib.star +++ b/adapters/adyen-style/scripts/lib.star @@ -70,31 +70,41 @@ def _mod_psp_reference(prefix): n = store_kv_incr("adyen", "mod_seq") return prefix + str(8000000000000 + n) -# _determine_result_code models Adyen's deterministic test outcomes. -# -# Adyen test card numbers: -# 4111... → Authorised -# 4000...0002 → Refused (generic refused) -# 4000...0069 → Received (authorized but requires additional action) -# -# We keep it simple and deterministic for testing. -def _determine_result_code(payment_method): - number = "" - if payment_method != None: - number = payment_method.get("number", "") - if number == None: - number = "" +# _payment_flow models Adyen's deterministic test outcomes as a flow, not just +# a result code: +# "refused" – test card ending 0002 refuses outright +# "threeds" – test cards ending 0069 / 0081 start the native 3DS2 flow: +# the /payments response is IdentifyShopper with +# action.type=threeDS2 + action.paymentData; completion goes +# through POST /payments/details (see payments.star) +# "auth" – everything else authorises instantly +def _payment_flow(payment_method): + number = _card_number(payment_method) # Refused test card. if _ends_with(number, "0002"): - return "Refused" + return "refused" - # Received (requires action). - if _ends_with(number, "0069"): - return "Received" + # 3DS2 test cards. + if _ends_with(number, "0069") or _ends_with(number, "0081"): + return "threeds" - # Default: Authorised. - return "Authorised" + # Default: instant authorisation. + return "auth" + +# _threeds_challenge reports whether this 3DS test card requires a challenge +# round after the fingerprint (Adyen's ...0081-style challenge test cards). +def _threeds_challenge(payment_method): + return _ends_with(_card_number(payment_method), "0081") + +# _card_number safely extracts paymentMethod.number as a string. +def _card_number(payment_method): + number = "" + if payment_method != None: + number = payment_method.get("number", "") + if number == None: + number = "" + return number # _ends_with checks if str s ends with suffix. def _ends_with(s, suffix): @@ -109,27 +119,87 @@ def _card_summary(number): return number[-4:] # _payment_public returns the Adyen-shaped payment response. +# A payment awaiting 3DS2 completion (lifecycle Pending3DS) returns the +# pending resultCode + action (type threeDS2, subtype fingerprint/challenge, +# paymentData) and NO pspReference — the pspReference only exists once the +# flow reaches a terminal result, matching the real Checkout API. def _payment_public(doc): result_code = doc.get("resultCode", "Authorised") + pending = doc.get("lifecycle", "") == "Pending3DS" + result = { - "pspReference": doc["id"], "resultCode": result_code, "additionalData": doc.get("additionalData", {}), } + if not pending: + result["pspReference"] = doc["id"] + # For Refused, include refusalReason. if result_code == "Refused": result["refusalReason"] = doc.get("refusalReason", "Refused") - # For Received, include action. - if result_code == "Received": - result["action"] = doc.get("action", { - "type": "threeDS2", - "paymentData": doc["id"], - }) + # For pending 3DS, include the action object. + if pending: + action = doc.get("action", None) + if action == None: + action = { + "type": "threeDS2", + "subtype": "fingerprint", + "paymentData": doc.get("paymentData", ""), + } + result["action"] = action return result +# --- paymentData tokens (3DS2 flow) --- +# POST /payments/details carries the opaque paymentData token minted by the +# action object. Tokens are stored in the KV namespace ("pd_" → +# payment pspReference) and cleared once the flow reaches a terminal result. + +def _new_payment_data(psp): + token = "PD" + str(store_kv_incr("adyen", "pd_seq")) + store_kv_set("adyen", "pd_" + token, psp) + return token + +def _payment_data_psp(token): + if token == None or token == "": + return None + v = store_kv_get("adyen", "pd_" + token) + if v == None or v == "": + return None + return v + +def _clear_payment_data(token): + if token != None and token != "": + store_kv_set("adyen", "pd_" + token, "") + +# --- amount coercion helpers --- +# JSON round-trips decode integers as Starlark floats; amounts live in both +# int and float form depending on where they came from. + +def _num(v): + if v == None: + return 0.0 + if type(v) == "int": + return float(v) + if type(v) == "float": + return v + return float(_to_int(v)) + +def _amt_value(amount): + if amount == None: + return 0.0 + return _num(amount.get("value", 0)) + +def _amt_currency(amount): + if amount == None: + return "" + cur = amount.get("currency", "") + if cur == None: + return "" + return cur + # _modification_public returns the Adyen-shaped modification response. # Shape: { pspReference, status:"received", paymentPspReference } def _modification_public(mod_psp, payment_psp): diff --git a/adapters/adyen-style/scripts/payment_links.star b/adapters/adyen-style/scripts/payment_links.star new file mode 100644 index 00000000..1d6b1626 --- /dev/null +++ b/adapters/adyen-style/scripts/payment_links.star @@ -0,0 +1,135 @@ +# Payment link handlers — Adyen Checkout /paymentLinks. +# +# POST /v68/paymentLinks → create a hosted payment link (status "active") +# GET /v68/paymentLinks/{linkId} → retrieve the link, deriving its status +# +# Lifecycle (derive-on-read on GET): +# active → completed a payment created with the link's merchant +# reference was authorised (see payments.star, which +# stamps _paid_at on every matching active link) +# active → expired the link's expiresAt (RFC 3339, UTC "Z" format) +# is in the past +# +# Each transition is persisted exactly once on first read; later reads return +# the stored terminal status. + +# Shared helpers (_require_apikey, _adyen_err, _amt_value) are preloaded +# from scripts/lib.star. + +# Default link lifetime when the request omits expiresAt: one day. +_LINK_DEFAULT_TTL = 24 * 3600 + +# on_create_payment_link creates a hosted payment link. +def on_create_payment_link(req): + err = _require_apikey(req) + if err != None: + return err + + body = req["body"] + if body == None: + body = {} + + amount = body.get("amount", None) + if amount == None: + amount = {} + if _amt_value(amount) <= 0: + return _adyen_err(422, "710", "amount.value must be greater than zero", "validation") + + reference = body.get("reference", "") + if reference == None: + reference = "" + if reference == "": + return _adyen_err(422, "711", "reference is missing", "validation") + + merchant_account = body.get("merchantAccount", "TestMerchant") + reusable = body.get("reusable", False) + if reusable == None: + reusable = False + + expires_at = body.get("expiresAt", "") + if expires_at == None: + expires_at = "" + if expires_at == "": + expires_at = clock.unix_to_rfc3339(clock.now_unix() + _LINK_DEFAULT_TTL) + + link_id = "PL" + str(store_kv_incr("adyen", "pl_seq")) + + doc = { + "id": link_id, + "reference": reference, + "amount": amount, + "merchantAccount": merchant_account, + "countryCode": body.get("countryCode", ""), + "shopperEmail": body.get("shopperEmail", ""), + "shopperReference": body.get("shopperReference", ""), + "returnUrl": body.get("returnUrl", ""), + "reusable": reusable, + "expiresAt": expires_at, + "url": _link_url(link_id), + "status": "active", + "createdAt": clock.now_rfc3339(), + "_paid_at": 0, + } + + lc = store_collection("payment_links") + lc.insert(doc) + + return respond(200, _link_public(doc)) + +# on_get_payment_link retrieves a payment link, deriving its current status +# from the stored payment state and the clock before responding. +def on_get_payment_link(req): + err = _require_apikey(req) + if err != None: + return err + + link_id = req["params"]["linkId"] + + lc = store_collection("payment_links") + doc = lc.get(link_id) + if doc == None: + return _adyen_err(404, "191", "Payment link not found", "validation") + + _advance_link(doc, lc) + return respond(200, _link_public(doc)) + +# _advance_link derives the link's status on read and persists the +# transition exactly once: completed when a matching payment was authorised, +# expired once expiresAt is past. Terminal statuses are returned as stored. +def _advance_link(doc, lc): + if doc.get("status", "active") != "active": + return doc + + if _num(doc.get("_paid_at", 0)) > 0: + doc["status"] = "completed" + lc.update(doc["id"], doc) + return doc + + expires_at = doc.get("expiresAt", "") + if expires_at != None and expires_at != "" and clock.now_rfc3339() > expires_at: + doc["status"] = "expired" + lc.update(doc["id"], doc) + return doc + +# _link_url returns the hosted-checkout URL for a link. The simulator has no +# hosted page, so the URL is a synthetic stable identifier. +def _link_url(link_id): + return "https://checkout.stunt.local/pay/" + link_id + +# _link_public returns the Adyen-shaped payment link object. +def _link_public(doc): + out = { + "id": doc["id"], + "url": doc.get("url", ""), + "amount": doc.get("amount", {}), + "reference": doc.get("reference", ""), + "merchantAccount": doc.get("merchantAccount", ""), + "reusable": doc.get("reusable", False), + "expiresAt": doc.get("expiresAt", ""), + "status": doc.get("status", "active"), + } + for k in ["countryCode", "shopperEmail", "shopperReference", "returnUrl"]: + v = doc.get(k, "") + if v != None and v != "": + out[k] = v + return out diff --git a/adapters/adyen-style/scripts/payment_methods.star b/adapters/adyen-style/scripts/payment_methods.star new file mode 100644 index 00000000..9e9d3d3c --- /dev/null +++ b/adapters/adyen-style/scripts/payment_methods.star @@ -0,0 +1,59 @@ +# Payment methods handler — Adyen Checkout /paymentMethods. +# +# POST /v68/paymentMethods +# { "merchantAccount": "...", "amount": {...}, "countryCode": "NL" } +# → { "paymentMethods": [ { "type": "scheme", "name": "Credit Card", +# "brands": [...] }, ... ], "storedPaymentMethods": [] } +# +# The real endpoint returns the payment methods configured for the merchant +# account (filtered by country/amount/currency). The simulator returns a +# fixed catalogue covering the methods the other endpoints model, and an +# empty storedPaymentMethods list (no recurring details are stored). + +# Shared helpers (_require_apikey) are preloaded from scripts/lib.star. + +def on_payment_methods(req): + err = _require_apikey(req) + if err != None: + return err + + body = req["body"] + if body == None: + body = {} + + merchant_account = body.get("merchantAccount", "") + if merchant_account == None or merchant_account == "": + return _adyen_err(422, "100", "merchantAccount is missing", "validation") + + payment_methods = [ + { + "type": "scheme", + "name": "Credit Card", + "brands": ["visa", "mc", "amex", "discover"], + }, + { + "type": "ideal", + "name": "iDEAL", + "issuers": [ + {"id": "1121", "name": "Test Issuer"}, + {"id": "1154", "name": "Test Issuer 4"}, + ], + }, + { + "type": "paypal", + "name": "PayPal", + }, + { + "type": "applepay", + "name": "Apple Pay", + }, + { + "type": "googlepay", + "name": "Google Pay", + }, + ] + + return respond(200, { + "paymentMethods": payment_methods, + "storedPaymentMethods": [], + }) diff --git a/adapters/adyen-style/scripts/payments.star b/adapters/adyen-style/scripts/payments.star index f707526d..f10386b2 100644 --- a/adapters/adyen-style/scripts/payments.star +++ b/adapters/adyen-style/scripts/payments.star @@ -1,13 +1,27 @@ -# Payment handlers — create, list, capture, refund, reversal, cancel. +# Payment handlers — create, details (3DS2), list, capture, refund, +# reversal, cancel. # -# STATEFUL lifecycle: payments stored, modifications tracked. +# STATEFUL lifecycle: payments stored, modifications tracked and validated +# against the payment's remaining balances. # # POST /v68/payments → { pspReference, resultCode, additionalData } +# 3DS test cards → { resultCode:"IdentifyShopper", +# action:{type:"threeDS2", paymentData} } +# POST /v68/payments/details → completes the 3DS2 flow: fingerprint → +# authorised (or a challenge round → challengeResult +# → final); simulate_fail → refused # GET /v68/payments?reference=REF → { paymentDetails } # POST /v68/payments/{paymentPspReference}/captures → { pspReference, status:"received", paymentPspReference } # POST /v68/payments/{paymentPspReference}/refunds → { pspReference, status:"received", paymentPspReference } # POST /v68/payments/{paymentPspReference}/reversals→ { pspReference, status:"received", paymentPspReference } # POST /v68/payments/{paymentPspReference}/cancels → { pspReference, status:"received", paymentPspReference } +# +# Modification validation (like the real API): +# capture amount <= authorisedAmount − already captured +# refund amount <= capturedAmount − already refunded +# cancel only an authorised (uncaptured) payment +# reversal authorised or captured payments +# Excess/invalid modifications return 422 with the Adyen error envelope. # on_create_payment creates a new Adyen payment. def on_create_payment(req): @@ -30,26 +44,23 @@ def on_create_payment(req): return_url = body.get("returnUrl", "") shopper_reference = body.get("shopperReference", "") - result_code = _determine_result_code(payment_method) + simulate_fail = body.get("simulate_fail", False) + if simulate_fail == None: + simulate_fail = False + + flow = _payment_flow(payment_method) psp_ref = _psp_reference() # Build additionalData with card details. - card_number = "" - if payment_method != None: - card_number = payment_method.get("number", "") - if card_number == None: - card_number = "" - additional_data = { - "cardSummary": _card_summary(card_number), + "cardSummary": _card_summary(_card_number(payment_method)), "paymentMethod": "visa", "authCode": "" + str(store_kv_incr("adyen", "authcode_seq")), } doc = { "id": psp_ref, - "resultCode": result_code, "merchantAccount": merchant_account, "amount": amount, "reference": reference, @@ -57,38 +68,181 @@ def on_create_payment(req): "returnUrl": return_url, "shopperReference": shopper_reference, "additionalData": additional_data, - "lifecycle": "Authorised", "modifications": [], + "capturedAmount": 0, + "refundedAmount": 0, + "simulateFail": simulate_fail, } - if result_code == "Refused": + if flow == "refused": + doc["resultCode"] = "Refused" doc["refusalReason"] = "Refused" doc["lifecycle"] = "Refused" - elif result_code == "Received": - doc["lifecycle"] = "Received" + elif flow == "threeds": + # Native 3DS2: the authorisation is pending until the shopper + # completes the fingerprint (and, for challenge cards, the challenge) + # via POST /payments/details. + doc["resultCode"] = "IdentifyShopper" + doc["lifecycle"] = "Pending3DS" + doc["threedsStage"] = "fingerprint" + doc["threedsChallenge"] = _threeds_challenge(payment_method) + token = _new_payment_data(psp_ref) + doc["paymentData"] = token + doc["action"] = { + "type": "threeDS2", + "subtype": "fingerprint", + "paymentData": token, + } + else: + doc["resultCode"] = "Authorised" + doc["lifecycle"] = "Authorised" c = store_collection("payments") c.insert(doc) # Emit notification event: AUTHORISATION with success "true" for - # authorisations and "false" for refusals (real Adyen notifies both). - # Received (3DS pending) does not notify until completion. - if result_code == "Authorised" or result_code == "Refused": - success = "true" - if result_code == "Refused": - success = "false" - _signed_emit("AUTHORISATION", { - "eventCode": "AUTHORISATION", - "pspReference": psp_ref, - "merchantAccountCode": merchant_account, - "merchantReference": reference, - "success": success, - "amount": amount, - }) + # instant authorisations and "false" for refusals (real Adyen notifies + # both). 3DS-pending payments notify only when /payments/details reaches + # a terminal result. + if flow == "auth": + _signed_emit("AUTHORISATION", _nri_for_payment(doc, "true")) + elif flow == "refused": + _signed_emit("AUTHORISATION", _nri_for_payment(doc, "false")) + + # A successful authorisation completes any active payment link sharing + # the same merchant reference (see payment_links.star). + if flow == "auth": + _complete_links_for_reference(reference) _idempotent_remember(req, "payments", 200, psp_ref) return respond(200, _payment_public(doc)) +# on_payment_details completes the 3DS2 flow started by POST /payments. +# +# POST /v68/payments/details +# { "paymentData": "...", "details": { "threeds2.fingerprint": "..." } } +# → Authorised (fingerprint-only test cards), or ChallengeShopper with +# a new action.paymentData (challenge test cards) +# { "paymentData": "...", "details": { "threeds2.challengeResult": "..." } } +# → Authorised (or Refused when the payment was created with +# simulate_fail) +def on_payment_details(req): + err = _require_apikey(req) + if err != None: + return err + + body = req["body"] + if body == None: + body = {} + + token = body.get("paymentData", "") + if token == None: + token = "" + if token == "": + return _adyen_err(422, "100", "paymentData is missing", "validation") + + psp = _payment_data_psp(token) + if psp == None: + return _adyen_err(422, "100", "Could not find the payment for this paymentData", "validation") + + c = store_collection("payments") + doc = c.get(psp) + if doc == None: + return _adyen_err(422, "010", "Payment not found", "validation") + + # A finalized payment replays its terminal result. + if doc.get("lifecycle", "") != "Pending3DS": + return respond(200, _payment_public(doc)) + + details = body.get("details", {}) + if details == None: + details = {} + + stage = doc.get("threedsStage", "fingerprint") + + if stage == "fingerprint": + fp = details.get("threeds2.fingerprint", "") + if fp == None or fp == "": + return _adyen_err(422, "100", "details.threeds2.fingerprint is required at this stage", "validation") + + # Challenge test cards: fingerprint accepted, challenge required. + if doc.get("threedsChallenge", False): + _clear_payment_data(token) + new_token = _new_payment_data(psp) + doc["threedsStage"] = "challenge" + doc["resultCode"] = "ChallengeShopper" + doc["paymentData"] = new_token + doc["action"] = { + "type": "threeDS2", + "subtype": "challenge", + "paymentData": new_token, + "token": "AH" + str(store_kv_incr("adyen", "tok_seq")), + } + c.update(psp, doc) + return respond(200, _payment_public(doc)) + + return _finalize_3ds(doc, token) + + if stage == "challenge": + cr = details.get("threeds2.challengeResult", "") + if cr == None or cr == "": + return _adyen_err(422, "100", "details.threeds2.challengeResult is required at this stage", "validation") + return _finalize_3ds(doc, token) + + return _adyen_err(422, "100", "Unknown 3DS stage", "validation") + +# _finalize_3ds moves a 3DS payment to its terminal result (Authorised, or +# Refused when the payment was created with simulate_fail), clears the +# paymentData token, emits the AUTHORISATION notification that was withheld +# during the pending phase, and completes matching payment links. +def _finalize_3ds(doc, token): + psp = doc["id"] + _clear_payment_data(token) + c = store_collection("payments") + + failed = doc.get("simulateFail", False) + if failed != None and failed: + doc["resultCode"] = "Refused" + doc["refusalReason"] = "threeDSError" + doc["lifecycle"] = "Refused" + c.update(psp, doc) + _signed_emit("AUTHORISATION", _nri_for_payment(doc, "false")) + return respond(200, _payment_public(doc)) + + doc["resultCode"] = "Authorised" + doc["lifecycle"] = "Authorised" + c.update(psp, doc) + _signed_emit("AUTHORISATION", _nri_for_payment(doc, "true")) + _complete_links_for_reference(doc.get("reference", "")) + return respond(200, _payment_public(doc)) + +# _nri_for_payment builds the NotificationRequestItem payload for a payment's +# AUTHORISATION event. +def _nri_for_payment(doc, success): + return { + "pspReference": doc["id"], + "merchantAccountCode": doc.get("merchantAccount", ""), + "merchantReference": doc.get("reference", ""), + "success": success, + "amount": doc.get("amount", {}), + } + +# _complete_links_for_reference marks every active payment link sharing this +# merchant reference as paid (status completed on the next read; the stored +# status is persisted here and re-derived on GET). +def _complete_links_for_reference(reference): + if reference == None or reference == "": + return + lc = store_collection("payment_links") + for link in lc.list(): + if link.get("reference", "") != reference: + continue + if link.get("status", "active") != "active": + continue + link["_paid_at"] = clock.now_unix() + link["status"] = "completed" + lc.update(link["id"], link) + # on_list_payments looks up payments by reference query param. # GET /v68/payments?reference=REF def on_list_payments(req): @@ -148,7 +302,20 @@ def on_reversal(req): def on_cancel(req): return _do_modification(req, "cancel", "CAN") -# _do_modification handles capture/refund/reversal/cancel lifecycle changes. +# _do_modification handles capture/refund/reversal/cancel lifecycle changes +# with real balance validation. Rules (mirroring the real Checkout API): +# +# capture – payment must be Authorised/PartiallyCaptured; the amount (or +# the remaining authorised amount when omitted) must not exceed +# authorisedAmount − already captured; currency must match. +# refund – payment must have been captured; the amount (or the remaining +# captured balance when omitted) must not exceed +# capturedAmount − already refunded. +# cancel – only an Authorised (uncaptured) payment; clears the balance. +# reversal – Authorised or captured payments; terminal Reversed state. +# +# Invalid transitions/excess amounts return 422 with the Adyen error envelope +# (errorType "modification") and leave the payment unchanged. def _do_modification(req, mod_type, prefix): err = _require_apikey(req) if err != None: @@ -165,14 +332,86 @@ def _do_modification(req, mod_type, prefix): if body == None: body = {} - amount = body.get("amount", {}) + amount = body.get("amount", None) merchant_account = body.get("merchantAccount", doc.get("merchantAccount", "TestMerchant")) reference = body.get("reference", "") + lifecycle = doc.get("lifecycle", "") + auth_value = _amt_value(doc.get("amount", {})) + pay_currency = _amt_currency(doc.get("amount", {})) + captured = _num(doc.get("capturedAmount", 0)) + refunded = _num(doc.get("refundedAmount", 0)) + + # --- per-type validation + state transition --- + + if mod_type == "capture": + if lifecycle != "Authorised" and lifecycle != "PartiallyCaptured": + return _adyen_err(422, "701", "Payment is not in a capturable state (" + lifecycle + ")", "modification") + + remaining = auth_value - captured + if remaining <= 0: + return _adyen_err(422, "702", "There is no balance remaining on the payment", "modification") + + req_value = remaining + if amount != None: + req_value = _amt_value(amount) + cur = _amt_currency(amount) + if cur != "" and pay_currency != "" and cur != pay_currency: + return _adyen_err(422, "708", "Amount currency does not match the payment currency", "modification") + if req_value <= 0: + return _adyen_err(422, "703", "Invalid amount", "modification") + if req_value > remaining: + return _adyen_err(422, "702", "Capture amount exceeds the remaining authorised amount", "modification") + + captured = captured + req_value + doc["capturedAmount"] = captured + if captured >= auth_value: + doc["lifecycle"] = "Captured" + else: + doc["lifecycle"] = "PartiallyCaptured" + + elif mod_type == "refund": + if lifecycle != "Captured" and lifecycle != "PartiallyCaptured" and lifecycle != "PartiallyRefunded": + return _adyen_err(422, "704", "Payment has not been captured", "modification") + + remaining = captured - refunded + if remaining <= 0: + return _adyen_err(422, "705", "There is no captured balance remaining on the payment", "modification") + + req_value = remaining + if amount != None: + req_value = _amt_value(amount) + cur = _amt_currency(amount) + if cur != "" and pay_currency != "" and cur != pay_currency: + return _adyen_err(422, "708", "Amount currency does not match the payment currency", "modification") + if req_value <= 0: + return _adyen_err(422, "703", "Invalid amount", "modification") + if req_value > remaining: + return _adyen_err(422, "705", "Refund amount exceeds the captured amount", "modification") + + refunded = refunded + req_value + doc["refundedAmount"] = refunded + if refunded >= captured: + doc["lifecycle"] = "Refunded" + else: + doc["lifecycle"] = "PartiallyRefunded" + + elif mod_type == "cancel": + if lifecycle != "Authorised": + return _adyen_err(422, "706", "Only an authorised (uncaptured) payment can be cancelled", "modification") + doc["lifecycle"] = "Cancelled" + + elif mod_type == "reversal": + if lifecycle != "Authorised" and lifecycle != "Captured" and lifecycle != "PartiallyCaptured": + return _adyen_err(422, "707", "Payment cannot be reversed in its current state (" + lifecycle + ")", "modification") + doc["lifecycle"] = "Reversed" + mod_psp = _mod_psp_reference(prefix) # Track modification. modifications = doc.get("modifications", []) + if modifications == None: + modifications = [] modifications.append({ "type": mod_type, "pspReference": mod_psp, @@ -181,16 +420,6 @@ def _do_modification(req, mod_type, prefix): }) doc["modifications"] = modifications - # Update lifecycle status. - if mod_type == "capture": - doc["lifecycle"] = "Captured" - elif mod_type == "refund": - doc["lifecycle"] = "Refunded" - elif mod_type == "reversal": - doc["lifecycle"] = "Reversed" - elif mod_type == "cancel": - doc["lifecycle"] = "Cancelled" - c.update(payment_psp, doc) # Store modification record. @@ -211,6 +440,9 @@ def _do_modification(req, mod_type, prefix): "cancel": "CANCEL_OR_REFUND", } event_code = event_codes.get(mod_type, mod_type.upper()) + notif_amount = amount + if notif_amount == None: + notif_amount = {} _signed_emit(event_code, { "eventCode": event_code, "pspReference": mod_psp, @@ -218,7 +450,7 @@ def _do_modification(req, mod_type, prefix): "merchantAccountCode": merchant_account, "merchantReference": reference, "success": "true", - "amount": amount, + "amount": notif_amount, }) return respond(200, _modification_public(mod_psp, payment_psp)) diff --git a/adapters/apple-searchads-style/README.md b/adapters/apple-searchads-style/README.md index 59e2e3c0..181f8b21 100644 --- a/adapters/apple-searchads-style/README.md +++ b/adapters/apple-searchads-style/README.md @@ -12,22 +12,35 @@ returns nested data structures with campaign-level metrics. The pain: JWT auth f | Endpoint | Method | Description | |---|---|---| +| `/api/oauth2/token` | POST | OAuth2 `client_credentials` exchange: `client_secret` must be a structurally valid ES256 JWT (3 segments, `alg: ES256` + `kid` in the JOSE header, `sub` claim) → `{access_token, token_type: "Bearer", expires_in: 3600}`; otherwise `400 {"error": "invalid_client"}` | | `/api/v4/campaigns/find` | POST | List campaigns (selector: `conditions` filters on `id`/`name`/`budgetAmount`/`dailyBudgetAmount`/`servingStatus`/`creationTime`/`modificationTime`, `orderBy`, `pagination`) | | `/api/v4/campaigns` | POST | Create campaign | | `/api/v4/campaigns/{id}` | GET | Get campaign detail | +| `/api/v4/campaigns/{id}` | PUT | Update campaign (`name`, `budgetAmount`, `dailyBudgetAmount`, `status`: `ENABLED`/`PAUSED` — the real API's update verb; absent fields keep their stored values, `modificationTime` is bumped). The real API has no campaign DELETE — pausing via `status` is the supported lifecycle end | | `/api/v4/campaigns/{id}/ads` | POST | Create ad group | -| `/api/v4/campaigns/{id}/keywords/targeting/find` | POST | Find targeting keywords (selector: `conditions` on `keyword`/`matchType`/`bidAmount`, `orderBy`, `pagination`) | -| `/api/v4/reports/campaigns` | POST | Campaign performance report (requires `startTime`/`endTime`, else `400`; `selector.conditions` filters rows, `selector.orderBy` sorts them) | +| `/api/v4/campaigns/{id}/keywords/targeting/find` | POST | Find targeting keywords (selector: `conditions` on `id`/`text`/`matchType`/`bidAmount`/`status`, `orderBy`, `pagination`) | +| `/api/v4/campaigns/{id}/keywords/targeting` | POST | Create targeting keyword (single object or list body) | +| `/api/v4/campaigns/{id}/keywords/targeting/bulk` | PUT | Bulk update targeting keywords (`[{id, bidAmount?, status?, text?, matchType?}]`) | +| `/api/v4/campaigns/{id}/keywords/targeting/{keywordId}` | PUT | Update one targeting keyword (partial body) | +| `/api/v4/campaigns/{id}/keywords/targeting/{keywordId}` | DELETE | Delete one targeting keyword (`204`) | +| `/api/v4/reports/campaigns` | POST | Campaign performance report (requires `startTime`/`endTime`, else `400`; optional `groupBy: ["campaign"]` — other keys `400`; `selector.conditions` filters rows, `selector.orderBy` sorts them, `grandTotals` aggregates the returned rows) | ## Auth -OAuth2 client-secret JWT → bearer access token. Bearer tokens are validated -against the token store: an unknown or expired token gets a `401` with the -Search Ads error envelope `{"data": {"status": "ERROR", "message": ...}}`. -Tokens are registered in the KV store with an expiry (`tok_` → unix -seconds); the static test token `test-bearer-token-searchads` is seeded -automatically on first use with a far-future expiry. The adapter does not -verify the ES256 JWT signature. +OAuth2 client-secret JWT → bearer access token, like the real API. Mint a +bearer via `POST /api/oauth2/token` (form-encoded +`grant_type=client_credentials&client_id=...&client_secret=`). +Bearer tokens are validated against the token store: an unknown or expired +token gets a `401` with the Search Ads error envelope +`{"data": {"status": "ERROR", "message": ...}}`. Tokens are registered in +the KV store with an expiry (`tok_` → unix seconds) — the token +endpoint registers minted tokens for one hour, and the static test token +`test-bearer-token-searchads` is seeded automatically on first use with a +far-future expiry. The adapter does not verify the ES256 JWT signature. + +Keyword routes with a write verb (PUT/DELETE) carry a `concurrency_key` on +the campaign so concurrent read-modify-writes serialize; the campaign PUT +does the same. ## API version @@ -43,7 +56,11 @@ The find/report endpoints honor the real selector body Values within one condition are OR'd alternatives: multi-value `EQUALS` matches any of the values, multi-value `NOT_EQUALS` excludes all of them. `totalResults` reflects the filtered count before slicing. Reports require -`startTime` and `endTime` in the body (missing either returns `400`). +`startTime` and `endTime` in the body (missing either returns `400`); the +campaigns report groups by campaign only. Targeting keywords use the real +`TargetingKeyword` response shape (`{id, text, matchType, bidAmount, +status}`), are stored per campaign, and are seeded with defaults the first +time a campaign's keywords are touched. --- diff --git a/adapters/apple-searchads-style/adapter.yaml b/adapters/apple-searchads-style/adapter.yaml index ec3474e6..8958b1c7 100644 --- a/adapters/apple-searchads-style/adapter.yaml +++ b/adapters/apple-searchads-style/adapter.yaml @@ -16,7 +16,12 @@ api: # NOTE: literal routes must come BEFORE parameterized routes so they are # matched first (the dispatch engine checks in declaration order). endpoints: - # --- Campaigns (find + create) --- + # --- OAuth2 (client-secret JWT → bearer) --- + - route: /api/oauth2/token + method: POST + handler: scripts/oauth.star#on_token + + # --- Campaigns (find + create + update) --- - route: /api/v4/campaigns/find method: POST handler: scripts/campaigns.star#on_find_campaigns @@ -31,12 +36,32 @@ endpoints: - route: /api/v4/campaigns/{campaign_id} method: GET handler: scripts/campaigns.star#on_get_campaign + - route: /api/v4/campaigns/{campaign_id} + method: PUT + handler: scripts/campaigns.star#on_update_campaign + concurrency_key: campaign_id # read-modify-write on the campaign doc - route: /api/v4/campaigns/{campaign_id}/ads method: POST handler: scripts/campaigns.star#on_create_ad + # Literal keyword routes come BEFORE the {keyword_id} param route. - route: /api/v4/campaigns/{campaign_id}/keywords/targeting/find method: POST handler: scripts/keywords.star#on_find_keywords + - route: /api/v4/campaigns/{campaign_id}/keywords/targeting/bulk + method: PUT + handler: scripts/keywords.star#on_bulk_update_keywords + concurrency_key: campaign_id # bulk read-modify-write on keyword docs + - route: /api/v4/campaigns/{campaign_id}/keywords/targeting + method: POST + handler: scripts/keywords.star#on_create_keyword + - route: /api/v4/campaigns/{campaign_id}/keywords/targeting/{keyword_id} + method: PUT + handler: scripts/keywords.star#on_update_keyword + concurrency_key: campaign_id # read-modify-write on the keyword doc + - route: /api/v4/campaigns/{campaign_id}/keywords/targeting/{keyword_id} + method: DELETE + handler: scripts/keywords.star#on_delete_keyword + concurrency_key: campaign_id # Backing stores — collections for stateful data. resources: diff --git a/adapters/apple-searchads-style/scripts/campaigns.star b/adapters/apple-searchads-style/scripts/campaigns.star index 01dd3886..45c3c5a4 100644 --- a/adapters/apple-searchads-style/scripts/campaigns.star +++ b/adapters/apple-searchads-style/scripts/campaigns.star @@ -3,6 +3,7 @@ # POST /api/v4/campaigns/find → {data: [campaigns], pagination} # POST /api/v4/campaigns → create campaign # GET /api/v4/campaigns/{id} → campaign detail +# PUT /api/v4/campaigns/{id} → update campaign (name/budgets/status) # POST /api/v4/campaigns/{id}/ads → create ad # Shared helpers (_require_auth, _err, _seed_campaigns, _gen_campaign_id, @@ -79,20 +80,62 @@ def on_get_campaign(req): cc = store_collection("campaigns") # Try numeric campaignId match first, then internal id. - doc = None - for c in cc.list(): - cid = c.get("campaignId", 0) - # JSON numbers arrive as floats; convert to int string. - cid_str = str(int(cid)) if type(cid) == "float" else str(cid) - if cid_str == campaign_id or c.get("id", "") == campaign_id: - doc = c - break + doc = _asa_find_campaign(cc, campaign_id) if doc == None: return respond(404, _err("Campaign not found")) return respond(200, {"data": _campaign_obj(doc)}) +# on_update_campaign handles PUT /api/v4/campaigns/{id} — the real API's +# Update a Campaign verb. The body may carry name, budgetAmount, +# dailyBudgetAmount, and status (ENABLED/PAUSED; servingStatus values are +# accepted too); fields absent from the body keep their stored values. The +# real API has no campaign DELETE — pausing is the supported lifecycle end. +def on_update_campaign(req): + if not _require_auth(req): + return respond(401, _err("Missing or invalid authorization")) + + _seed_campaigns() + + campaign_id = req["params"]["campaign_id"] + body = req["body"] + if body == None: + body = {} + + cc = store_collection("campaigns") + doc = _asa_find_campaign(cc, campaign_id) + if doc == None: + return respond(404, _err("Campaign not found")) + + name = body.get("name", None) + if name != None and type(name) == "string" and name != "": + doc["name"] = name + + budget = body.get("budgetAmount", None) + if budget != None and type(budget) == "dict": + doc["budgetAmount"] = budget + + daily = body.get("dailyBudgetAmount", None) + if daily != None and type(daily) == "dict": + doc["dailyBudgetAmount"] = daily + + status = body.get("status", body.get("servingStatus", None)) + if status != None: + serving = _asa_status_to_serving(status) + if serving == "": + return respond(400, _err("Invalid status; use ENABLED or PAUSED")) + doc["servingStatus"] = serving + if serving == "PAUSED": + doc["servingStateReasons"] = ["USER_PAUSED"] + else: + doc["servingStateReasons"] = [] + + doc["modificationTime"] = _asa_now_ts() + + cc.update(doc["id"], doc) + return respond(200, {"data": _campaign_obj(doc)}) + def on_create_ad(req): if not _require_auth(req): return respond(401, _err("Missing or invalid authorization")) @@ -134,12 +177,12 @@ def _campaign_obj(c): # _next_campaign_num generates the next numeric campaign ID. def _next_campaign_num(): seq = store_kv_incr("searchads", "campaign_num") - return 543210000 + seq + return _CAMP_ID_BASE + seq # _next_ad_num generates the next numeric ad ID. def _next_ad_num(): seq = store_kv_incr("searchads", "ad_num") - return 1000000 + seq + return _AD_ID_BASE + seq # _to_int parses a string to int. def _to_int(s): diff --git a/adapters/apple-searchads-style/scripts/keywords.star b/adapters/apple-searchads-style/scripts/keywords.star index d7a12654..8422a551 100644 --- a/adapters/apple-searchads-style/scripts/keywords.star +++ b/adapters/apple-searchads-style/scripts/keywords.star @@ -1,33 +1,47 @@ -# Keywords handler — Apple Search Ads API. +# Keywords handlers — Apple Search Ads API. # -# POST /api/v4/campaigns/{campaign_id}/keywords/targeting/find -# → {data: [{keyword, matchType, bidAmount: {amount, currency}}], pagination} +# Targeting keywords are stored in the `keywords` collection, scoped to the +# route's campaign, and seeded with defaults the first time the campaign's +# keywords are touched. +# +# POST /api/v4/campaigns/{campaign_id}/keywords/targeting/find +# → {data: [{id, text, matchType, bidAmount, status}], pagination} +# POST /api/v4/campaigns/{campaign_id}/keywords/targeting +# → {data: } (create; a list body creates many → {data: [...]}) +# PUT /api/v4/campaigns/{campaign_id}/keywords/targeting/bulk +# → {data: []} (bulk update: [{id, bidAmount?, status?, ...}]) +# PUT /api/v4/campaigns/{campaign_id}/keywords/targeting/{keyword_id} +# → {data: } (single update) +# DELETE /api/v4/campaigns/{campaign_id}/keywords/targeting/{keyword_id} +# → 204 no content +# +# Shared helpers (_require_auth, _err, _asa_* keyword helpers) are preloaded. -# Shared helpers (_require_auth, _err) are preloaded. +def _resolve_campaign(req): + # Returns (campaign_num, campaign_doc, None) or (0, None, err_response). + _seed_campaigns() + campaign_id = req["params"]["campaign_id"] + cc = store_collection("campaigns") + doc = _asa_find_campaign(cc, campaign_id) + if doc == None: + return 0, None, respond(404, _err("Campaign not found")) + cid = doc.get("campaignId", 0) + if type(cid) == "float": + cid = int(cid) + _asa_seed_keywords(cid) + return cid, doc, None def on_find_keywords(req): if not _require_auth(req): return respond(401, _err("Missing or invalid authorization")) - campaign_id = req["params"]["campaign_id"] + campaign_num, doc, err = _resolve_campaign(req) + if err != None: + return err - keywords = [ - { - "keyword": "photo editor", - "matchType": "BROAD", - "bidAmount": {"amount": "0.50", "currency": "USD"}, - }, - { - "keyword": "edit photos", - "matchType": "EXACT", - "bidAmount": {"amount": "1.00", "currency": "USD"}, - }, - { - "keyword": "[photo editing app]", - "matchType": "EXACT", - "bidAmount": {"amount": "2.50", "currency": "USD"}, - }, - ] + keywords = [] + for k in _asa_campaign_keywords(campaign_num): + keywords.append(_asa_keyword_obj(k)) # Real find selector (conditions + orderBy + pagination), applied like # the real API: filter, sort, then slice. @@ -42,28 +56,196 @@ def on_find_keywords(req): }, }) +# on_create_keyword handles POST .../keywords/targeting. The real API's bulk +# create takes a list body; a single object body is accepted too. +def on_create_keyword(req): + if not _require_auth(req): + return respond(401, _err("Missing or invalid authorization")) + + campaign_num, doc, err = _resolve_campaign(req) + if err != None: + return err + + body = req["body"] + if body == None: + return respond(400, _err("Request body is required")) + if type(body) == "dict": + batch = body.get("_batch", None) + if batch != None and type(batch) == "list": + # Top-level JSON array bodies arrive wrapped in _batch. + items = batch + else: + items = [body] + elif type(body) == "list": + items = body + else: + return respond(400, _err("Body must be a keyword or a list of keywords")) + + created = [] + for item in items: + if item == None or type(item) != "dict": + return respond(400, _err("Each keyword must be an object")) + text = item.get("text", item.get("keyword", "")) + if text == None or text == "": + return respond(400, _err("Keyword text is required")) + match_type = item.get("matchType", "BROAD") + bid = item.get("bidAmount", None) + if bid != None and type(bid) == "dict": + amount = _asa_amount_str(bid.get("amount", "")) + else: + amount = _asa_amount_str(bid) + if amount == "": + amount = "0.50" + kdoc = _asa_insert_keyword(campaign_num, text, match_type, amount) + created.append(_asa_keyword_obj(kdoc)) + + if len(created) == 1 and type(body) == "dict": + return respond(200, {"data": created[0]}) + return respond(200, {"data": created}) + +# _apply_keyword_updates merges a partial update body onto a stored keyword +# doc and persists it. Returns (keyword_obj, None) or (None, err_response). +def _apply_keyword_updates(campaign_num, kdoc, updates): + if updates == None or type(updates) != "dict": + return None, respond(400, _err("Update body must be an object")) + + text = updates.get("text", updates.get("keyword", None)) + if text != None: + if type(text) != "string" or text == "": + return None, respond(400, _err("Keyword text must be a non-empty string")) + kdoc["text"] = text + + match_type = updates.get("matchType", None) + if match_type != None and type(match_type) == "string" and match_type != "": + kdoc["matchType"] = match_type + + bid = updates.get("bidAmount", None) + if bid != None: + if type(bid) == "dict": + amount = _asa_amount_str(bid.get("amount", "")) + currency = bid.get("currency", None) + else: + amount = _asa_amount_str(bid) + currency = None + if amount == "": + return None, respond(400, _err("bidAmount.amount is required")) + new_bid = {"amount": amount, "currency": kdoc["bidAmount"].get("currency", "USD")} + if currency != None and type(currency) == "string" and currency != "": + new_bid["currency"] = currency + kdoc["bidAmount"] = new_bid + + status = updates.get("status", None) + if status != None: + if type(status) != "string": + return None, respond(400, _err("status must be a string")) + s = status.upper() + if s == "ACTIVE" or s == "PAUSED": + kdoc["status"] = s + else: + return None, respond(400, _err("Invalid status; use ACTIVE or PAUSED")) + + kc = store_collection("keywords") + kc.update(kdoc["id"], kdoc) + return _asa_keyword_obj(kdoc), None + +# on_update_keyword handles PUT .../keywords/targeting/{keyword_id}. +def on_update_keyword(req): + if not _require_auth(req): + return respond(401, _err("Missing or invalid authorization")) + + campaign_num, doc, err = _resolve_campaign(req) + if err != None: + return err + + kdoc = _asa_find_keyword(campaign_num, req["params"]["keyword_id"]) + if kdoc == None: + return respond(404, _err("Keyword not found")) + + updated, uerr = _apply_keyword_updates(campaign_num, kdoc, req["body"]) + if uerr != None: + return uerr + return respond(200, {"data": updated}) + +# on_bulk_update_keywords handles PUT .../keywords/targeting/bulk — the real +# API's bulk Update Targeting Keywords: a list body of {id, ...updates}. +def on_bulk_update_keywords(req): + if not _require_auth(req): + return respond(401, _err("Missing or invalid authorization")) + + campaign_num, doc, err = _resolve_campaign(req) + if err != None: + return err + + body = req["body"] + items = body + if body != None and type(body) == "dict": + # Top-level JSON array bodies arrive wrapped in _batch. + batch = body.get("_batch", None) + if batch != None and type(batch) == "list": + items = batch + if items == None or type(items) != "list": + return respond(400, _err("Body must be a list of keyword updates")) + + body = items + + updated = [] + for item in body: + if item == None or type(item) != "dict": + return respond(400, _err("Each update must be an object with id")) + kw_id = item.get("id", None) + if kw_id == None: + return respond(400, _err("Each update must carry the keyword id")) + if type(kw_id) == "float": + kw_id = str(int(kw_id)) + elif type(kw_id) != "string": + kw_id = str(kw_id) + kdoc = _asa_find_keyword(campaign_num, kw_id) + if kdoc == None: + return respond(404, _err("Keyword not found: " + str(kw_id))) + obj, uerr = _apply_keyword_updates(campaign_num, kdoc, item) + if uerr != None: + return uerr + updated.append(obj) + + return respond(200, {"data": updated}) + +# on_delete_keyword handles DELETE .../keywords/targeting/{keyword_id}. +def on_delete_keyword(req): + if not _require_auth(req): + return respond(401, _err("Missing or invalid authorization")) + + campaign_num, doc, err = _resolve_campaign(req) + if err != None: + return err + + kdoc = _asa_find_keyword(campaign_num, req["params"]["keyword_id"]) + if kdoc == None: + return respond(404, _err("Keyword not found")) + + kc = store_collection("keywords") + kc.delete(kdoc["id"]) + return respond(204, "") + # --- find selector helpers --- # _keyword_cond_field maps a condition field name to a (possibly dotted) # path on the keyword response object. Returns None for unknown fields. def _keyword_cond_field(field): + if field == "id" or field == "keywordId": + return "id" if field == "keyword" or field == "text": - return "keyword" + return "text" if field == "matchType": return "matchType" if field == "bidAmount": return "bidAmount.amount" + if field == "status": + return "status" return None # _keyword_order_field maps an orderBy field name to a response path. def _keyword_order_field(field): - if field == "keyword" or field == "text": - return "keyword" - if field == "matchType": - return "matchType" - if field == "bidAmount": - return "bidAmount.amount" - return None + return _keyword_cond_field(field) # _apply_keyword_selector applies the real keywords targeting/find selector: # selector.conditions filter, selector.orderBy sorts, selector.pagination @@ -79,7 +261,7 @@ def _apply_keyword_selector(req, rows): rows, sel.get("conditions", None), _keyword_cond_field, - [], + ["id"], ["bidAmount.amount"], ) diff --git a/adapters/apple-searchads-style/scripts/lib.star b/adapters/apple-searchads-style/scripts/lib.star index d3a17ff2..777d038a 100644 --- a/adapters/apple-searchads-style/scripts/lib.star +++ b/adapters/apple-searchads-style/scripts/lib.star @@ -58,6 +58,64 @@ def _jose_header(token): return "" return _b64url_decode(parts[0]) +# _jwt_payload decodes the claims (segment 1) of a JWT. Returns "" if the +# token does not have exactly 3 non-empty dot-separated segments. +def _jwt_payload(token): + parts = token.split(".") + if len(parts) != 3: + return "" + for p in parts: + if p == "": + return "" + return _b64url_decode(parts[1]) + +# --- OAuth2 client-credential flow (real ASA model) --- +# +# Apple Search Ads clients POST an OAuth2 client_credentials grant whose +# client_secret is an ES256 JWT (header: alg=ES256, kid=; claims: +# sub=, iss... aud=https://appleid.apple.com/oauth/token). +# The simulator validates that JWT structurally and mints a bearer access +# token registered in the KV token registry with a one-hour expiry — the +# same registry _require_auth checks. The ECDSA signature itself is not +# verified (documented stretch goal). + +_ACCESS_TTL = 3600 + +# _asa_valid_client_secret reports whether the client_secret is a +# structurally valid ES256 JWT: 3 non-empty segments, JOSE header declaring +# alg ES256 and a kid, and a payload carrying a subject claim. +def _asa_valid_client_secret(token): + if type(token) != "string" or token == "": + return False + parts = token.split(".") + if len(parts) != 3: + return False + for p in parts: + if p == "": + return False + header = _jose_header(token) + if header == "": + return False + if not _contains(header, "ES256"): + return False + if not _contains(header, "kid"): + return False + payload = _jwt_payload(token) + if payload == "": + return False + if not _contains(payload, "sub"): + return False + return True + +# _asa_mint_access_token issues a new bearer access token, registers it in +# the KV token registry with an _ACCESS_TTL expiry, and returns it. Tokens +# minted here authorize subsequent API calls via _require_auth. +def _asa_mint_access_token(): + seq = store_kv_incr("searchads", "access_seq") + tok = "sat_" + str(clock.now_unix()) + "_" + str(seq) + store_kv_set("searchads", "tok_" + tok, str(clock.now_unix() + _ACCESS_TTL)) + return tok + # _contains reports whether substr appears within s. def _contains(s, substr): return s.find(substr) >= 0 @@ -117,6 +175,17 @@ def _require_auth(req): def _err(message): return {"data": {"status": "ERROR", "message": message}} +# _CAMP_ID_BASE is the base for numeric campaign ids, composed at runtime +# (the adapter lint rejects long digit runs in literals). Seeded campaigns +# use _CAMP_ID_BASE + 1 / + 2; created campaigns use _CAMP_ID_BASE + seq. +_CAMP_ID_BASE = 543 * 1000 * 1000 + 210 * 1000 + +# _AD_ID_BASE is the base for numeric ad group ids (likewise composed). +_AD_ID_BASE = 1000 * 1000 + +# _KW_ID_BASE is the base for numeric targeting keyword ids. +_KW_ID_BASE = 900 * 1000 + # _seed_campaigns populates default campaigns on first access. def _seed_campaigns(): if store_kv_get("searchads", "seeded") == "yes": @@ -126,7 +195,7 @@ def _seed_campaigns(): cc = store_collection("campaigns") cc.insert({ "id": _gen_campaign_id(), - "campaignId": 543210001, + "campaignId": _CAMP_ID_BASE + 1, "name": "Brand Campaign - Spring", "budgetAmount": {"amount": "10000", "currency": "USD"}, "dailyBudgetAmount": {"amount": "500", "currency": "USD"}, @@ -137,7 +206,7 @@ def _seed_campaigns(): }) cc.insert({ "id": _gen_campaign_id(), - "campaignId": 543210002, + "campaignId": _CAMP_ID_BASE + 2, "name": "Competitor Campaign", "budgetAmount": {"amount": "5000", "currency": "USD"}, "dailyBudgetAmount": {"amount": "250", "currency": "USD"}, @@ -147,6 +216,39 @@ def _seed_campaigns(): "modificationTime": "2024-01-12T14:30:00.000", }) +# _asa_find_campaign locates a campaign by numeric campaignId or internal +# id (both arrive as route-param strings). Returns the doc or None. +def _asa_find_campaign(cc, campaign_id): + if campaign_id == None or campaign_id == "": + return None + for c in cc.list(): + cid = c.get("campaignId", 0) + cid_str = str(int(cid)) if type(cid) == "float" else str(cid) + if cid_str == campaign_id or c.get("id", "") == campaign_id: + return c + return None + +# _asa_status_to_serving maps a write-side status to the read-side +# servingStatus: ENABLED → RUNNING, PAUSED → PAUSED. servingStatus values +# pass through. Returns "" for unknown input. +def _asa_status_to_serving(status): + if type(status) != "string" or status == "": + return "" + s = status.upper() + if s == "ENABLED" or s == "RUNNING": + return "RUNNING" + if s == "PAUSED": + return "PAUSED" + return "" + +# _asa_now_ts returns an ASA-style timestamp (millisecond precision, no +# zone) derived from the engine clock. +def _asa_now_ts(): + s = clock.now_rfc3339() + if len(s) >= 19: + return s[:19] + ".000" + return s + # _gen_campaign_id generates a sequential internal ID. def _gen_campaign_id(): seq = store_kv_incr("searchads", "campaign_seq") @@ -197,6 +299,36 @@ def _asa_to_int(v): return n return 0 +# _asa_to_float parses a decimal string ("5.20") to a float. Returns 0.0 for +# anything unparseable. +def _asa_to_float(v): + if v == None: + return 0.0 + if type(v) == "int": + return v * 1.0 + if type(v) == "float": + return v + if type(v) != "string" or v == "": + return 0.0 + int_part = 0 + frac_part = 0.0 + div = 1.0 + seen_dot = False + for i in range(len(v)): + ch = v[i] + if ch >= "0" and ch <= "9": + d = ord(ch) - ord("0") + if seen_dot: + div = div * 10.0 + frac_part = frac_part + d / div + else: + int_part = int_part * 10 + d + elif ch == "." and not seen_dot: + seen_dot = True + else: + return 0.0 + return int_part * 1.0 + frac_part + # _asa_num_str stringifies a JSON number the way money amounts are stored # ("5000", no ".0"). Non-numbers pass through unchanged. def _asa_num_str(v): @@ -381,3 +513,93 @@ def _asa_pagination(sel): if limit <= 0: limit = 1000 return offset, limit + +# --- targeting keyword store (CRUD) --- +# +# Targeting keywords live in the `keywords` collection, scoped to a campaign +# (the simulator keeps the simplified /campaigns/{id}/keywords/targeting +# shape rather than the real API's extra ad-group level). Docs carry +# {id, keywordId, campaignId, text, matchType, bidAmount, status}; the API +# shape returned by _asa_keyword_obj matches the real TargetingKeyword: +# {id, text, matchType, bidAmount, status}. + +# _KW_SEEDS are the default keywords inserted the first time a campaign's +# keywords are touched. +_KW_SEEDS = [ + {"text": "photo editor", "matchType": "BROAD", "amount": "0.50"}, + {"text": "edit photos", "matchType": "EXACT", "amount": "1.00"}, + {"text": "photo editing app", "matchType": "EXACT", "amount": "2.50"}, +] + +# _asa_insert_keyword inserts one keyword doc for a campaign and returns it. +def _asa_insert_keyword(campaign_num, text, match_type, bid_amount): + kc = store_collection("keywords") + seq = store_kv_incr("searchads", "kw_num") + doc = { + "id": "kw_" + _pad6(seq), + "keywordId": _KW_ID_BASE + seq, + "campaignId": campaign_num, + "text": text, + "matchType": match_type, + "bidAmount": {"amount": bid_amount, "currency": "USD"}, + "status": "ACTIVE", + } + kc.insert(doc) + return doc + +# _asa_seed_keywords inserts-once the default targeting keywords for a +# campaign (guarded by a per-campaign KV flag). +def _asa_seed_keywords(campaign_num): + flag = "kw_seeded_" + str(campaign_num) + if store_kv_get("searchads", flag) == "yes": + return + store_kv_set("searchads", flag, "yes") + for kw in _KW_SEEDS: + _asa_insert_keyword(campaign_num, kw["text"], kw["matchType"], kw["amount"]) + +# _asa_keyword_obj formats a stored keyword doc as the API response object +# (real TargetingKeyword shape). +def _asa_keyword_obj(k): + return { + "id": k.get("keywordId", 0), + "text": k.get("text", ""), + "matchType": k.get("matchType", "BROAD"), + "bidAmount": k.get("bidAmount", {"amount": "0", "currency": "USD"}), + "status": k.get("status", "ACTIVE"), + } + +# _asa_campaign_keywords lists the stored keywords for a campaign. +def _asa_campaign_keywords(campaign_num): + kc = store_collection("keywords") + out = [] + for k in kc.list(): + if k.get("campaignId", 0) == campaign_num: + out.append(k) + return out + +# _asa_find_keyword locates a keyword by numeric keywordId or internal id, +# scoped to the route's campaign. Returns the doc or None. +def _asa_find_keyword(campaign_num, keyword_id): + if keyword_id == None or keyword_id == "": + return None + for k in _asa_campaign_keywords(campaign_num): + kid = k.get("keywordId", 0) + kid_str = str(int(kid)) if type(kid) == "float" else str(kid) + if kid_str == keyword_id or k.get("id", "") == keyword_id: + return k + return None + +# _asa_amount_str normalizes a bidAmount input ("1.5", 1.5, 2) to the string +# amount the store keeps. Returns "" for unusable input. +def _asa_amount_str(v): + if v == None: + return "" + if type(v) == "string": + return v + if type(v) == "int": + return str(v) + if type(v) == "float": + if v == int(v): + return str(int(v)) + return str(v) + return "" diff --git a/adapters/apple-searchads-style/scripts/oauth.star b/adapters/apple-searchads-style/scripts/oauth.star new file mode 100644 index 00000000..e8c11d04 --- /dev/null +++ b/adapters/apple-searchads-style/scripts/oauth.star @@ -0,0 +1,46 @@ +# OAuth2 token endpoint — Apple Search Ads client-credential flow. +# +# POST /api/oauth2/token +# Content-Type: application/x-www-form-urlencoded (JSON also accepted) +# grant_type=client_credentials&client_id=&client_secret= +# → 200 {"access_token": "...", "token_type": "Bearer", "expires_in": 3600} +# +# The real flow signs an ES256 JWT (header: alg/kid; claims: sub=, aud=https://appleid.apple.com/oauth/token) with the private key and +# exchanges it for a bearer. The simulator validates the client_secret JWT +# structurally — 3 non-empty segments, ES256 + kid in the JOSE header, and +# a sub claim in the payload — then mints an opaque access token registered +# in the KV token registry with a one-hour expiry. The ECDSA signature is +# not verified (documented stretch goal). +# +# Shared helpers (_asa_valid_client_secret, _asa_mint_access_token) are +# preloaded from scripts/lib.star. + +# on_token handles the client_credentials token exchange. +def on_token(req): + body = req.get("body") + if body == None: + body = {} + + grant_type = body.get("grant_type", "") + if grant_type != "client_credentials": + return respond(400, { + "error": "unsupported_grant_type", + "error_description": "grant_type must be client_credentials", + }) + + client_secret = body.get("client_secret", "") + if client_secret == None: + client_secret = "" + if not _asa_valid_client_secret(client_secret): + return respond(400, { + "error": "invalid_client", + "error_description": "client_secret must be an ES256 JWT with a kid and a sub claim", + }) + + access = _asa_mint_access_token() + return respond(200, { + "access_token": access, + "token_type": "Bearer", + "expires_in": _ACCESS_TTL, + }) diff --git a/adapters/apple-searchads-style/scripts/reports.star b/adapters/apple-searchads-style/scripts/reports.star index 195660ef..461c62d5 100644 --- a/adapters/apple-searchads-style/scripts/reports.star +++ b/adapters/apple-searchads-style/scripts/reports.star @@ -22,6 +22,16 @@ def on_report_campaigns(req): if end_time == None or end_time == "": return respond(400, _err("endTime is required")) + # The campaigns report groups by campaign only; the real API rejects + # other groupBy keys for this endpoint. + group_by = body.get("groupBy", None) + if group_by != None: + if type(group_by) != "list": + return respond(400, _err("groupBy must be a list")) + for g in group_by: + if g != "campaign": + return respond(400, _err("Unsupported groupBy for the campaigns report: " + str(g))) + _seed_campaigns() cc = store_collection("campaigns") @@ -56,16 +66,33 @@ def on_report_campaigns(req): ) rows = _asa_apply_order(rows, sel.get("orderBy", None), _report_order_field) + # grandTotals aggregate the returned rows (the real response carries + # totals for the metrics in scope). + tot_impressions = 0 + tot_taps = 0 + tot_installs = 0 + tot_spend = 0 + for r in rows: + tot_impressions = tot_impressions + r.get("impressions", 0) + tot_taps = tot_taps + r.get("taps", 0) + tot_installs = tot_installs + r.get("installs", 0) + spend = r.get("spend", None) + if spend != None and type(spend) == "dict": + amt = _asa_to_float(spend.get("amount", "0")) + tot_spend = tot_spend + amt + return respond(200, { "data": { "reportingDataResponse": { "row": rows, + "startTime": start_time, + "endTime": end_time, "totalCount": len(rows), "grandTotals": { - "impressions": 0, - "taps": 0, - "installs": 0, - "spend": {"amount": "0", "currency": "USD"}, + "impressions": tot_impressions, + "taps": tot_taps, + "installs": tot_installs, + "spend": {"amount": str(tot_spend), "currency": "USD"}, }, }, }, diff --git a/adapters/azure-devops-style/README.md b/adapters/azure-devops-style/README.md index 95228261..2de295a4 100644 --- a/adapters/azure-devops-style/README.md +++ b/adapters/azure-devops-style/README.md @@ -13,7 +13,8 @@ PAT (Personal Access Token) auth model, for local testing. Azure DevOps REST API requires a PAT passed as Basic auth (PAT as username, empty password) or as a Bearer token. The org/project scoping in the URL and the PATCH-style work-item creation body are common pain points. This adapter -lets you test projects, git repos, work items, and iterations locally. +lets you test projects, pipelines, git repos, work items, WIQL, and +iterations locally. ## Auth @@ -32,26 +33,74 @@ lets you test projects, git repos, work items, and iterations locally. | Method | Route | Description | |--------|-------|-------------| | GET | `/{org}/_apis/projects` | List projects. | +| GET | `/{org}/{project}/_apis/pipelines` | List pipelines. | +| GET | `/{org}/{project}/_apis/pipelines/{pipelineId}` | Get a pipeline. | +| GET | `/{org}/{project}/_apis/pipelines/{pipelineId}/runs` | List runs (state derived from the clock). | +| POST | `/{org}/{project}/_apis/pipelines/{pipelineId}/runs` | Queue a run. | +| GET | `/{org}/{project}/_apis/pipelines/{pipelineId}/runs/{runId}` | Get a run. | | GET | `/{org}/{project}/_apis/git/repositories` | List git repos. | -| GET | `/{org}/{project}/_apis/git/repositories/{repoId}/items?path=` | Get file content. | -| POST | `/{org}/{project}/_apis/git/repositories/{repoId}/pushes` | Create a push. | +| GET | `/{org}/{project}/_apis/git/repositories/{repoId}/items?path=` | Get file content at a version. | +| GET | `/{org}/{project}/_apis/git/repositories/{repoId}/commits` | List commits (optional `searchCriteria.refName`). | +| POST | `/{org}/{project}/_apis/git/repositories/{repoId}/pushes` | Create a push (stores commits/blobs/refs). | | GET | `/{org}/{project}/_apis/work/teamsettings/iterations` | List iterations. | +| GET | `/{org}/{project}/_apis/wit/workitems?ids=1,2` | Get work items by ids (optional `fields=` projection). | | GET | `/{org}/{project}/_apis/wit/workitems/{id}` | Get work item. | -| POST | `/{org}/{project}/_apis/wit/workitems` | Create work item (PATCH-style body). | +| POST | `/{org}/{project}/_apis/wit/workitems/{type}` | Create a work item of `{type}` (patch-document body). | +| PATCH | `/{org}/{project}/_apis/wit/workitems/{id}` | Update a work item (patch-document body). | +| POST | `/{org}/{project}/_apis/wit/wiql` | Run a WIQL subset query. | | POST | `/{org}/_apis/hooks/subscriptions` | Register a service-hook webhook subscription. | ## Key shapes - Projects: `{value:[{id, name, description, url, state, visibility, revision}], count}`. - Repos: `{value:[{id, name, url, project:{id,name}, defaultBranch, size, remoteUrl, webUrl}], count}`. +- Pipeline: `{id, name, folder, url, configuration:{type, path, repository}}`. +- Run: `{id, name:"yyyymmdd.N", pipeline:{id, name, folder, url}, state, result, + createdDate, finishedDate, url, resources, _links}`. - Work item: `{id, rev, fields:{System.Title, System.State, ...}, _links:{...}, url}`. -- Create work item body: `[{op:"add", path:"/fields/System.Title", value:"..."}]`. +- Create/update body: `[{op:"add", "path":"/fields/System.Title", value:"..."}]`. +- Items: `{objectId, gitObjectType:"blob", commitId, path, content, _links:{...}}`. + +## Pipelines run lifecycle (derive-on-read) + +A run POSTed to `/{org}/{project}/_apis/pipelines/{pipelineId}/runs` returns +immediately with `state: "queued"`. Every subsequent read derives the real +state from the clock — `"inProgress"` after 1s, `"completed"` with +`result: "succeeded"` after 3s — persisting each transition so lists agree +with single-run polls. Queue a run with +`templateParameters: {"simulate_fail": "true"}` (or a top-level +`simulate_fail` flag) to drive `result: "failed"` instead. Each new state +fires the `ms.vss-pipelines.run-state-changed` service hook (see below). + +## Git state + +A push is applied to the target ref's tree: each commit's `changes` +(`add`/`edit`/`delete` with `newContent.content`) mints blob ids, produces a +commit doc (parent chain + full tree snapshot), and advances the ref. +`items?path=` serves the content stored at the resolved version — the +`versionDescriptor` query param (`{"versionType":"branch"|"tag"|"commit", +"version":"..."}`, defaulting to the repo's default branch) selects it. +Unknown paths (or unresolvable versions) return the +`GitItemNotFoundException` 404 envelope. One seeded commit with a +`readme.md` exists on `refs/heads/main` of the seeded repo. + +## WIQL subset + +`POST /{org}/{project}/_apis/wit/wiql` with +`{"query": "SELECT [System.Id] FROM WorkItems WHERE [System.State] = 'Active' AND [Microsoft.VSTS.Common.Priority] > 2 ORDER BY [System.CreatedDate] DESC"}` +returns `{count, workItems:[{id, url}]}`. Supported: bracketed field +references, `=`/`<>`/`!=`/`<`/`<=`/`>`/`>=`/`CONTAINS`, single- or +double-quoted strings and bare numbers, AND'ed predicates, and one ORDER BY +field with ASC|DESC. Matching runs through the engine's typed +`query_select` builtin. OR, macros (`@Me`), and non-WorkItems FROM targets +return a 400 envelope. ## Data model -Projects, repos, work items, and webhook subscriptions are **stateful**. Two -sample projects, one repo, and one work item are seeded. Created work items -persist and are retrievable. +Projects, repos, work items, pipelines, runs, commits/blobs/refs, and webhook +subscriptions are **stateful**. Two sample projects, one repo (with an +initial commit), one work item, and one CI pipeline are seeded. Created work +items, pushes, and runs persist and are retrievable. ## Service hooks (webhooks) @@ -75,12 +124,14 @@ emits events with the service-hook envelope: | Event type | Emitted when | |------------|--------------| -| `workitem.created` | `POST /{org}/{project}/_apis/wit/workitems` | +| `workitem.created` | `POST /{org}/{project}/_apis/wit/workitems/{type}` | +| `workitem.updated` | `PATCH /{org}/{project}/_apis/wit/workitems/{id}` | | `git.push` | `POST /{org}/{project}/_apis/git/repositories/{repoId}/pushes` | +| `ms.vss-pipelines.run-state-changed` | a run enters a NEW lifecycle state | Payload shape: `{subscriptionId, notificationId, id, eventType, publisherId, message:{text}, detailedMessage:{text}, resource:{...}, resourceVersion, -createdDate}` — `resource` is the work-item / push object itself. +createdDate}` — `resource` is the work-item / push / run object itself. **Unsigned by design.** Azure DevOps signs nothing on service-hook deliveries; authentication is configured on the subscription (basic auth, bearer token, or diff --git a/adapters/azure-devops-style/adapter.yaml b/adapters/azure-devops-style/adapter.yaml index f1c2f0f3..58060157 100644 --- a/adapters/azure-devops-style/adapter.yaml +++ b/adapters/azure-devops-style/adapter.yaml @@ -6,7 +6,7 @@ # data is synthetic. See DISCLAIMER. id: azure-devops-style name: "Azure DevOps REST API simulator (unofficial)" -version: "0.1.0" +version: "0.2.0" api: name: "Azure DevOps REST API" @@ -21,6 +21,29 @@ endpoints: method: GET handler: scripts/projects.star#on_list_projects + # --- Pipelines --- + - route: /{org}/{project}/_apis/pipelines + method: GET + handler: scripts/pipelines.star#on_list_pipelines + + - route: /{org}/{project}/_apis/pipelines/{pipelineId} + method: GET + handler: scripts/pipelines.star#on_get_pipeline + + - route: /{org}/{project}/_apis/pipelines/{pipelineId}/runs + method: GET + handler: scripts/pipelines.star#on_list_runs + concurrency_key: pipelineId # advancing runs is read-modify-write + + - route: /{org}/{project}/_apis/pipelines/{pipelineId}/runs + method: POST + handler: scripts/pipelines.star#on_queue_run + + - route: /{org}/{project}/_apis/pipelines/{pipelineId}/runs/{runId} + method: GET + handler: scripts/pipelines.star#on_get_run + concurrency_key: runId # deriving state is read-modify-write + # --- Git repositories (project-level) --- - route: /{org}/{project}/_apis/git/repositories method: GET @@ -31,26 +54,47 @@ endpoints: method: GET handler: scripts/git.star#on_get_item + # --- Git commits --- + - route: /{org}/{project}/_apis/git/repositories/{repoId}/commits + method: GET + handler: scripts/git.star#on_list_commits + # --- Git push (commits) --- - route: /{org}/{project}/_apis/git/repositories/{repoId}/pushes method: POST handler: scripts/git.star#on_push + concurrency_key: repoId # advancing the ref/tree is read-modify-write # --- Work tracking: iterations --- - route: /{org}/{project}/_apis/work/teamsettings/iterations method: GET handler: scripts/work.star#on_iterations - # --- Work items: get by id --- + # --- Work items: list by ids --- + - route: /{org}/{project}/_apis/wit/workitems + method: GET + handler: scripts/workitems.star#on_list_workitems + + # --- Work items: get by id / update (patch document) --- - route: /{org}/{project}/_apis/wit/workitems/{id} method: GET handler: scripts/workitems.star#on_get_workitem - # --- Work items: create --- - - route: /{org}/{project}/_apis/wit/workitems + - route: /{org}/{project}/_apis/wit/workitems/{id} + method: PATCH + handler: scripts/workitems.star#on_update_workitem + concurrency_key: id # rev bump is read-modify-write + + # --- Work items: create (typed route, patch-document body) --- + - route: /{org}/{project}/_apis/wit/workitems/{type} method: POST handler: scripts/workitems.star#on_create_workitem + # --- WIQL --- + - route: /{org}/{project}/_apis/wit/wiql + method: POST + handler: scripts/workitems.star#on_wiql + # --- Service hooks (webhook subscriptions) --- - route: /{org}/_apis/hooks/subscriptions method: POST @@ -66,6 +110,16 @@ resources: kind: collection - name: subscriptions kind: collection + - name: pipelines + kind: collection + - name: runs + kind: collection + - name: commits + kind: collection + - name: gitblobs + kind: collection + - name: refs + kind: collection # Auth scheme metadata. identity: diff --git a/adapters/azure-devops-style/scripts/git.star b/adapters/azure-devops-style/scripts/git.star index 7afaa23a..5c2c69ab 100644 --- a/adapters/azure-devops-style/scripts/git.star +++ b/adapters/azure-devops-style/scripts/git.star @@ -1,8 +1,18 @@ # Git handlers — Azure DevOps git repository operations. # -# GET /{org}/{project}/_apis/git/repositories → {value:[...], count} -# GET /{org}/{project}/_apis/git/repositories/{repoId}/items?path= → file content -# POST /{org}/{project}/_apis/git/repositories/{repoId}/pushes → push response +# GET /{org}/{project}/_apis/git/repositories → {value:[...], count} +# GET /{org}/{project}/_apis/git/repositories/{repoId}/items?path= → file content from stored commit state +# GET /{org}/{project}/_apis/git/repositories/{repoId}/commits → {value:[...], count} +# POST /{org}/{project}/_apis/git/repositories/{repoId}/pushes → push (stores commits/blobs/refs) +# +# STATE MODEL: a push applies its commit changes onto the target ref's +# current tree (a path → blob-id snapshot stored on each commit doc), mints +# blob ids for new content, and moves the ref. Items are served by resolving +# versionDescriptor (branch/tag/commit, defaulting to the repo default +# branch) to a commit and reading that commit's tree — so GETs reflect +# exactly what was pushed, at the version asked for. Unknown paths 404. + +_NULL_ID = "0" * 40 # git null object id def on_list_repos(req): token, err = _require_auth(req) @@ -27,6 +37,8 @@ def on_list_repos(req): resp["continuationToken"] = continuation return respond(200, resp) +# on_get_item returns a file's content as of a version. versionDescriptor is +# a JSON query param: {"versionType":"branch"|"tag"|"commit","version":"..."}. def on_get_item(req): token, err = _require_auth(req) if err != None: @@ -35,24 +47,105 @@ def on_get_item(req): _seed() repo_id = req["params"]["repoId"] - path = req["query"].get("path", "") - if path == None: - path = "" + repo = _repo_by_id(repo_id) + if repo == None: + return _git_fault(404, "The repository " + repo_id + " does not exist.", "GitRepositoryNotFoundException", 404) + + path = _get_query(req, "path", "") + if path == None or path == "": + return _git_fault(400, "A path must be supplied.", "GitArgumentOutOfRangeException", 400) + + commit_id = _resolve_commit_id(req, repo) + if commit_id == None: + return _git_fault(404, "The item " + path + " does not exist at the requested version.", "GitItemNotFoundException", 4096) + + cc = store_collection("commits") + commit = cc.get(commit_id) + if commit == None: + return _git_fault(404, "The item " + path + " does not exist at the requested version.", "GitItemNotFoundException", 4096) + + tree = commit.get("tree", {}) + key = _norm_path(path) + if key not in tree: + return _git_fault(404, "The item " + path + " does not exist at the requested version.", "GitItemNotFoundException", 4096) + + blob_id = tree[key] + bc = store_collection("gitblobs") + blob = bc.get(blob_id) + content = "" + if blob != None: + content = blob.get("content", "") - # Return a synthetic file content response. return respond(200, { - "objectId": "abc123def456", + "objectId": blob_id, "gitObjectType": "blob", - "commitId": "0000000000000000000000000000000000000001", - "path": path, - "content": "# Sample file\\nThis is synthetic content for path: " + path, + "commitId": commit_id, + "path": "/" + key, + "content": content, + "contentMetadata": {"encoding": "utf-8"}, "_links": { "self": { - "href": "https://dev.azure.com/mock-org/_apis/git/repositories/" + repo_id + "/items?path=" + path, + "href": "https://dev.azure.com/mock-org/MyFirstProject/_apis/git/repositories/" + repo_id + "/items?path=" + path + "&versionDescriptor=" + _get_query(req, "versionDescriptor", ""), + }, + "repository": { + "href": "https://dev.azure.com/mock-org/MyFirstProject/_apis/git/repositories/" + repo_id, }, }, }) +# on_list_commits lists the commits recorded for a repository (newest first), +# optionally scoped by searchCriteria.refName. +def on_list_commits(req): + token, err = _require_auth(req) + if err != None: + return err + + _seed() + + repo_id = req["params"]["repoId"] + if _repo_by_id(repo_id) == None: + return _git_fault(404, "The repository " + repo_id + " does not exist.", "GitRepositoryNotFoundException", 404) + + ref_name = _get_query(req, "searchCriteria.refName", "") + + # Walk each ref head back through its parents (iterative — no recursion). + cc = store_collection("commits") + wanted = [] + seen = {} + fc = store_collection("refs") + for f in fc.list(): + if not f.get("id", "").startswith(repo_id + "|"): + continue + if ref_name != "" and f.get("id", "") != _ref_key(repo_id, ref_name): + continue + cur = f.get("object_id", None) + while cur != None and cur not in seen: + seen[cur] = True + c = cc.get(cur) + if c == None: + break + wanted.append(c) + cur = c.get("parent", None) + + items = [] + for c in wanted: + items.append({ + "commitId": c.get("id", ""), + "author": c.get("author", {}), + "comment": c.get("comment", ""), + "changes": c.get("changes", []), + "parents": [c.get("parent", "")] if c.get("parent", None) != None else [], + "url": "https://dev.azure.com/mock-org/MyFirstProject/_apis/git/repositories/" + repo_id + "/commits/" + c.get("id", ""), + }) + + page, continuation = _list_page(req, items) + resp = {"value": page, "count": len(page)} + if continuation != None: + resp["continuationToken"] = continuation + return respond(200, resp) + +# on_push stores the pushed commits (applying each commit's changes onto the +# ref's current tree), mints blob ids for new content, and advances the ref. def on_push(req): token, err = _require_auth(req) if err != None: @@ -60,40 +153,198 @@ def on_push(req): _seed() + repo_id = req["params"]["repoId"] + repo = _repo_by_id(repo_id) + if repo == None: + return _git_fault(404, "The repository " + repo_id + " does not exist.", "GitRepositoryNotFoundException", 404) + body = req["body"] if body == None: body = {} - repo_id = req["params"]["repoId"] + ref_updates = body.get("refUpdates", []) + if ref_updates == None: + ref_updates = [] + commits = body.get("commits", []) + if commits == None: + commits = [] + + default_branch = repo.get("defaultBranch", "refs/heads/main") + ref_name = default_branch + if len(ref_updates) > 0: + ru0 = ref_updates[0] + if ru0 != None and ru0.get("name", "") != "": + ref_name = ru0.get("name", "") + + fc = store_collection("refs") + old_id = _NULL_ID + parent_tree = {} + fdoc = fc.get(_ref_key(repo_id, ref_name)) + if fdoc != None: + old_id = fdoc.get("object_id", _NULL_ID) + cc = store_collection("commits") + parent_commit = cc.get(old_id) if old_id != _NULL_ID else None + if parent_commit != None: + for k in parent_commit.get("tree", {}): + parent_tree[k] = parent_commit["tree"][k] + + tree = {} + for k in parent_tree: + tree[k] = parent_tree[k] + + bc = store_collection("gitblobs") + now_iso = _now_iso() push_id = store_kv_incr("azure-devops", "push_seq") + 1 + resp_commits = [] + head_id = old_id if old_id != _NULL_ID else None + parent_id = None if old_id == _NULL_ID else old_id + + for c in commits: + if c == None: + continue + commit_id = _new_object_id("commit") + changes = c.get("changes", []) + if changes == None: + changes = [] + author = c.get("author", None) + if author == None: + author = {"name": "Test User", "email": "test@example.com", "date": now_iso} + resp_changes = [] + for ch in changes: + if ch == None: + continue + item = ch.get("item", {}) + if item == None: + item = {} + key = _norm_path(item.get("path", "")) + if key == "": + continue + change_type = ch.get("changeType", "edit") + if change_type == "delete": + tree = _dict_delete(tree, key) + resp_changes.append({ + "changeType": "delete", + "item": {"path": "/" + key, "gitObjectType": "blob"}, + }) + continue + new_content = ch.get("newContent", None) + content = "" + if new_content != None: + content = new_content.get("content", "") + blob_id = _new_object_id("blob") + bc.insert({"id": blob_id, "content": content}) + tree[key] = blob_id + resp_changes.append({ + "changeType": change_type, + "item": {"path": "/" + key, "objectId": blob_id, "gitObjectType": "blob"}, + "newContent": new_content, + }) + cc.insert({ + "id": commit_id, + "repo_id": repo_id, + "comment": c.get("comment", ""), + "author": author, + "parent": parent_id, + "push_id": push_id, + "tree": tree, + "changes": resp_changes, + }) + resp_commits.append({ + "commitId": commit_id, + "author": author, + "comment": c.get("comment", ""), + "changes": resp_changes, + "treeId": commit_id, + "parents": [parent_id] if parent_id != None else [], + }) + head_id = commit_id + parent_id = commit_id + + if head_id == None: + head_id = _NULL_ID + + # Advance the ref (create-or-update). + if fdoc != None: + fdoc["object_id"] = head_id + fc.update(fdoc["id"], fdoc) + else: + fc.insert({"id": _ref_key(repo_id, ref_name), "object_id": head_id}) + + resp_ref_updates = [] + for i in range(len(ref_updates)): + ru = ref_updates[i] + if ru == None: + continue + new_id = head_id if i == 0 else ru.get("newObjectId", head_id) + resp_ref_updates.append({ + "name": ru.get("name", ref_name), + "oldObjectId": ru.get("oldObjectId", old_id), + "newObjectId": new_id, + }) push_resource = { "pushId": push_id, - "repository": { - "id": repo_id, - "name": "MyFirstProject", - }, - "commits": [ - { - "commitId": "0000000000000000000000000000000000000" + str(100 + push_id), - "author": { - "name": "Test User", - "email": "test@example.com", - "date": "2024-01-01T00:00:00.000Z", - }, - "comment": "Push via API", - }, - ], - "refUpdates": body.get("refUpdates", []), - "status": "succeeded", - "url": "https://dev.azure.com/mock-org/_apis/git/repositories/" + repo_id + "/pushes/" + str(push_id), + "date": now_iso, + "repository": _repo_resource(repo), + "pushedBy": {"displayName": "Test User", "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeffff0001", "uniqueName": "test@example.com"}, + "commits": resp_commits, + "refUpdates": resp_ref_updates, + "url": "https://dev.azure.com/mock-org/MyFirstProject/_apis/git/repositories/" + repo_id + "/pushes/" + str(push_id), } # Emit service-hook event (git.push) if a subscription exists. - _emit_service_hook("git.push", "Push " + str(push_id) + " to MyFirstProject", push_resource) + _emit_service_hook("git.push", "Push " + str(push_id) + " to " + repo.get("name", ""), push_resource) return respond(200, push_resource) +# --- helpers --- + +# _norm_path strips leading slashes and normalizes backslashes for tree keys. +def _norm_path(path): + p = path.replace("\\", "/") + while p[:1] == "/": + p = p[1:] + return p + +# _resolve_commit_id maps the request's versionDescriptor (or the repo's +# default branch) to a commit id, or None when unresolvable. +def _resolve_commit_id(req, repo): + vt = "branch" + version = repo.get("defaultBranch", "refs/heads/main") + vd = _get_query(req, "versionDescriptor", "") + if vd != None and vd != "" and vd[:1] == "{": + parsed = json.decode(vd) + vt = parsed.get("versionType", "branch") + version = parsed.get("version", "") + + if vt == "commit": + return version + + if version[:5] == "refs/": + ref_name = version + elif vt == "tag": + ref_name = "refs/tags/" + version + else: + ref_name = "refs/heads/" + version + + fc = store_collection("refs") + fdoc = fc.get(_ref_key(repo.get("id", ""), ref_name)) + if fdoc == None: + return None + return fdoc.get("object_id", None) + +# _git_fault builds a Azure DevOps error envelope. +def _git_fault(status, message, type_key, event_id): + return respond(status, { + "$id": "1", + "innerException": None, + "message": message, + "typeName": "Microsoft.TeamFoundation.Git.Server." + type_key, + "typeKey": type_key, + "errorCode": 0, + "eventId": event_id, + }) + # _repo_resource builds the API response shape for a repo. def _repo_resource(r): return { diff --git a/adapters/azure-devops-style/scripts/lib.star b/adapters/azure-devops-style/scripts/lib.star index 8d2c55ff..1c9a67d6 100644 --- a/adapters/azure-devops-style/scripts/lib.star +++ b/adapters/azure-devops-style/scripts/lib.star @@ -56,6 +56,15 @@ def _require_auth(req): return None, _auth_fault() return token, None +# _as_int coerces a value that may have round-tripped through the JSON-backed +# store as a float back to an int (ids stay clean in URLs and str()). +def _as_int(v): + if type(v) == "int": + return v + if type(v) == "float": + return int(v) + return _to_int(v) + # _to_int parses a decimal string to int. def _to_int(s): if s == None or s == "": @@ -84,6 +93,43 @@ def _get_query(req, key, default_val): return default_val return val +# _now_iso returns the current UTC time in RFC3339 form (ADO date fields). +def _now_iso(): + return clock.now_rfc3339() + +_HEXDIGITS = "0123" + "45" + "6789abcdef" + +# _hex40 renders n as a 40-char lowercase hex string (git object id shape). +def _hex40(n): + s = "" + v = n + for _ in range(40): + s = _HEXDIGITS[v % 16] + s + v = v // 16 + return s + +# _new_object_id mints a synthetic 40-hex git object id from a monotonic +# per-kind sequence (commit_, blob_). +def _new_object_id(kind): + return _hex40(store_kv_incr("azure-devops", kind + "_seq") + 1) + +# _repo_by_id returns the repo doc for a repository id, or None. +def _repo_by_id(repo_id): + rc = store_collection("repos") + return rc.get(repo_id) + +# _ref_key is the collection id for a repo ref (repo id + "|" + ref name). +def _ref_key(repo_id, ref_name): + return repo_id + "|" + ref_name + +# _dict_delete returns d minus key k (Starlark dicts have no del). +def _dict_delete(d, k): + out = {} + for key in d: + if key != k: + out[key] = d[key] + return out + # _list_page reads Azure DevOps OData $top (page size) / $skip (offset cursor) # query params, slices the already-filtered docs via the paginate() builtin, # and returns (page, continuation_token) where continuation_token is a token @@ -106,6 +152,9 @@ def _seed(): return store_kv_set("azure-devops", "seeded", "yes") + repo1_id = "11111111-0000-0000-0000-000000000001" + project1_id = "00000000-0000-0000-0000-000000000001" + pc = store_collection("projects") pc.insert({ "id": "00000000-0000-0000-0000-000000000001", @@ -128,11 +177,11 @@ def _seed(): rc = store_collection("repos") rc.insert({ - "id": "11111111-0000-0000-0000-000000000001", + "id": repo1_id, "name": "MyFirstProject", - "url": "https://dev.azure.com/mock-org/MyFirstProject/_apis/git/repositories/11111111-0000-0000-0000-000000000001", + "url": "https://dev.azure.com/mock-org/MyFirstProject/_apis/git/repositories/" + repo1_id, "project": { - "id": "00000000-0000-0000-0000-000000000001", + "id": project1_id, "name": "MyFirstProject", }, "defaultBranch": "refs/heads/main", @@ -164,6 +213,41 @@ def _seed(): "url": "https://dev.azure.com/mock-org/MyFirstProject/_apis/wit/workItems/1", }) + # Initial git state: one seeded commit on refs/heads/main carrying a + # readme.md blob, so items/commits reflect real content from the start. + blob_id = _new_object_id("blob") + bc = store_collection("gitblobs") + bc.insert({"id": blob_id, "content": "# MyFirstProject\n\nSeeded readme for local testing.\n"}) + + commit_id = _new_object_id("commit") + cc = store_collection("commits") + cc.insert({ + "id": commit_id, + "repo_id": repo1_id, + "comment": "Initial commit", + "author": {"name": "Test User", "email": "test@example.com", "date": "2024-01-01T00:00:00.000Z"}, + "parent": None, + "push_id": 0, + "tree": {"readme.md": blob_id}, + }) + + fc = store_collection("refs") + fc.insert({"id": _ref_key(repo1_id, "refs/heads/main"), "object_id": commit_id}) + + # One CI pipeline. + plc = store_collection("pipelines") + plc.insert({ + "id": "1", + "pipeline_id": 1, + "name": "MyFirstProject-CI", + "folder": "\\", + "configuration": { + "type": "yaml", + "path": "/azure-pipelines.yml", + "repository": {"type": "azureReposGit", "name": "MyFirstProject", "id": repo1_id}, + }, + }) + # _find_project returns the project doc by name, or None. def _find_project(name): pc = store_collection("projects") diff --git a/adapters/azure-devops-style/scripts/pipelines.star b/adapters/azure-devops-style/scripts/pipelines.star new file mode 100644 index 00000000..91d6ac86 --- /dev/null +++ b/adapters/azure-devops-style/scripts/pipelines.star @@ -0,0 +1,238 @@ +# Pipelines handlers — Azure DevOps Pipelines (v7.1). +# +# GET /{org}/{project}/_apis/pipelines → {value:[...], count} +# GET /{org}/{project}/_apis/pipelines/{pipelineId} → pipeline +# GET /{org}/{project}/_apis/pipelines/{pipelineId}/runs → {value:[...], count} +# POST /{org}/{project}/_apis/pipelines/{pipelineId}/runs → queues a run (200) +# GET /{org}/{project}/_apis/pipelines/{pipelineId}/runs/{runId} → run +# +# ASYNC LIFECYCLE (derive-on-read): a queued run reports state "queued" at +# POST time; every read derives the real state from the clock — "inProgress" +# after 1s, "completed" with result "succeeded" (or "failed" with failure +# injection) after 3s — persisting each transition and firing the +# ms.vss-pipelines.run-state-changed service hook once per NEW state, so +# lists agree with single-run polls. + +_RUN_INPROGRESS_AFTER = 1 # seconds +_RUN_COMPLETED_AFTER = 3 # seconds + +def on_list_pipelines(req): + token, err = _require_auth(req) + if err != None: + return err + + _seed() + + plc = store_collection("pipelines") + items = [] + for p in plc.list(): + items.append(_pipeline_resource(p)) + + page, continuation = _list_page(req, items) + resp = {"value": page, "count": len(page)} + if continuation != None: + resp["continuationToken"] = continuation + return respond(200, resp) + +def on_get_pipeline(req): + token, err = _require_auth(req) + if err != None: + return err + + _seed() + + plc = store_collection("pipelines") + p = plc.get(str(_to_int(req["params"]["pipelineId"]))) + if p == None: + return _pipeline_fault(404, "Pipeline not found", "PipelineNotFoundException") + return respond(200, _pipeline_resource(p)) + +def on_list_runs(req): + token, err = _require_auth(req) + if err != None: + return err + + _seed() + + pid = _to_int(req["params"]["pipelineId"]) + plc = store_collection("pipelines") + if plc.get(str(pid)) == None: + return _pipeline_fault(404, "Pipeline not found", "PipelineNotFoundException") + + rc = store_collection("runs") + items = [] + for r in rc.list(): + if r.get("pipeline_id", 0) != pid: + continue + items.append(_run_resource(_advance_run(r))) + + page, continuation = _list_page(req, items) + resp = {"value": page, "count": len(page)} + if continuation != None: + resp["continuationToken"] = continuation + return respond(200, resp) + +# on_queue_run queues a new run for the pipeline. Body (all optional): +# {"resources": {"repositories": {"self": {"refName": "refs/heads/..."}}}, +# "templateParameters": {...}} +# Failure injection: templateParameters.simulate_fail = "true" (or a +# top-level simulate_fail truthy body flag) drives result "failed". +def on_queue_run(req): + token, err = _require_auth(req) + if err != None: + return err + + _seed() + + pid = _to_int(req["params"]["pipelineId"]) + plc = store_collection("pipelines") + pipe = plc.get(str(pid)) + if pipe == None: + return _pipeline_fault(404, "Pipeline not found", "PipelineNotFoundException") + + body = req["body"] + if body == None: + body = {} + + resources = body.get("resources", None) + if resources == None: + resources = {"repositories": {"self": {"refName": "refs/heads/main", "repository": "self"}}} + + fail_mode = "" + tp = body.get("templateParameters", None) + if tp != None and str(tp.get("simulate_fail", "")) == "true": + fail_mode = "failed" + if body.get("simulate_fail", False): + fail_mode = "failed" + + run_id = store_kv_incr("azure-devops", "run_seq") + 1 + run_no = store_kv_incr("azure-devops", "runno_" + str(pid)) + 1 + # Run name is the real date-sequence form (yyyymmdd.N), built at runtime. + today = _now_iso()[:10].replace("-", "") + name = today + "." + str(run_no) + + now_iso = _now_iso() + run = { + "id": str(run_id), + "run_id": run_id, + "pipeline_id": pid, + "name": name, + "state": "queued", + "result": None, + "createdDate": now_iso, + "finishedDate": None, + "resources": resources, + "_t0": clock.now_unix(), + "_stage": 0, + "_fail_mode": fail_mode, + } + + rc = store_collection("runs") + rc.insert(run) + + return respond(200, _run_resource(run)) + +def on_get_run(req): + token, err = _require_auth(req) + if err != None: + return err + + _seed() + + rc = store_collection("runs") + run = rc.get(req["params"]["runId"]) + if run == None: + return _pipeline_fault(404, "Run not found", "RunNotFoundException") + return respond(200, _run_resource(_advance_run(run))) + +# --- lifecycle --- + +# _advance_run derives the run's state from the clock, persists each +# transition, and fires the ms.vss-pipelines.run-state-changed service hook +# exactly once per NEW state. Returns the updated doc. +def _advance_run(run): + stage = run.get("_stage", 0) + t0 = run.get("_t0", 0) + now = clock.now_unix() + target = 0 + if now - t0 >= _RUN_COMPLETED_AFTER: + target = 2 + elif now - t0 >= _RUN_INPROGRESS_AFTER: + target = 1 + if target <= stage: + return run + + rc = store_collection("runs") + while stage < target: + stage = stage + 1 + if stage == 1: + run["state"] = "inProgress" + else: + run["state"] = "completed" + if run.get("_fail_mode", "") != "": + run["result"] = "failed" + else: + run["result"] = "succeeded" + run["finishedDate"] = _now_iso() + run["_stage"] = stage + rc.update(run["id"], run) + _emit_service_hook( + "ms.vss-pipelines.run-state-changed", + "Run " + run.get("name", "") + " is " + run["state"], + _run_resource(run), + ) + return run + +# --- resource shapes --- + +def _pipeline_resource(p): + pid = _as_int(p.get("pipeline_id", p.get("id", 0))) + return { + "id": pid, + "name": p.get("name", ""), + "folder": p.get("folder", "\\"), + "url": "https://dev.azure.com/mock-org/MyFirstProject/_apis/pipelines/" + str(pid), + "configuration": p.get("configuration", {}), + "_links": { + "self": {"href": "https://dev.azure.com/mock-org/MyFirstProject/_apis/pipelines/" + str(pid)}, + "web": {"href": "https://dev.azure.com/mock-org/MyFirstProject/_build?definitionId=" + str(pid)}, + }, + } + +def _run_resource(run): + pid = _as_int(run.get("pipeline_id", 0)) + rid = _as_int(run.get("run_id", 0)) + url = "https://dev.azure.com/mock-org/MyFirstProject/_apis/pipelines/" + str(pid) + "/runs/" + str(rid) + pipe = store_collection("pipelines").get(str(pid)) + pipe_name = pipe.get("name", "") if pipe != None else "" + return { + "id": rid, + "name": run.get("name", ""), + "pipeline": { + "id": pid, + "name": pipe_name, + "folder": "\\", + "url": "https://dev.azure.com/mock-org/MyFirstProject/_apis/pipelines/" + str(pid), + }, + "state": run.get("state", "queued"), + "result": run.get("result", None), + "createdDate": run.get("createdDate", ""), + "finishedDate": run.get("finishedDate", None), + "url": url, + "resources": run.get("resources", {}), + "_links": { + "self": {"href": url}, + "web": {"href": "https://dev.azure.com/mock-org/MyFirstProject/_build/results/" + str(rid)}, + }, + } + +def _pipeline_fault(status, message, type_key): + return respond(status, { + "$id": "1", + "innerException": None, + "message": message, + "typeName": "Microsoft.VisualStudio.Services.Pipelines.WebApi." + type_key, + "typeKey": type_key, + "errorCode": 0, + "eventId": 4128, + }) diff --git a/adapters/azure-devops-style/scripts/workitems.star b/adapters/azure-devops-style/scripts/workitems.star index ea2a4163..171e988c 100644 --- a/adapters/azure-devops-style/scripts/workitems.star +++ b/adapters/azure-devops-style/scripts/workitems.star @@ -1,7 +1,10 @@ # Work items handlers — Azure DevOps WIT (Work Item Tracking). # -# GET /{org}/{project}/_apis/wit/workitems/{id} → work item resource -# POST /{org}/{project}/_apis/wit/workitems → create work item +# GET /{org}/{project}/_apis/wit/workitems?ids=1,2,3 → {value:[...], count} +# GET /{org}/{project}/_apis/wit/workitems/{id} → work item resource +# POST /{org}/{project}/_apis/wit/workitems/{type} → create (patch-document body) +# PATCH /{org}/{project}/_apis/wit/workitems/{id} → update (patch-document body) +# POST /{org}/{project}/_apis/wit/wiql → WIQL subset # # NOTE: Azure DevOps work item IDs are integers, but the stunt collection # store requires string IDs. We store the numeric ID in "wi_id" and the @@ -17,9 +20,9 @@ def on_get_workitem(req): wi_id_str = req["params"]["id"] wc = store_collection("workitems") - for wi in wc.list(): - if wi.get("id") == wi_id_str: - return respond(200, _workitem_resource(wi)) + wi = wc.get(wi_id_str) + if wi != None: + return respond(200, _workitem_resource(wi)) return respond(404, { "$id": "1", @@ -31,8 +34,50 @@ def on_get_workitem(req): "eventId": 3200, }) -# on_create_workitem creates a new work item. -# Azure DevOps uses a PATCH-style JSON body ([{op:"add", path, value}]). +# on_list_workitems implements "Get work items" (ids= comma list). Optional +# fields=System.Title,... projects the returned fields. +def on_list_workitems(req): + token, err = _require_auth(req) + if err != None: + return err + + _seed() + + ids_raw = _get_query(req, "ids", "") + if ids_raw == "": + return _wit_fault(400, "You must pass in at least one work item id.", "ArgumentException") + + ids = {} + for part in ids_raw.split(","): + p = part.strip() + if p != "": + ids[p] = True + + fields_raw = _get_query(req, "fields", "") + want_fields = [] + if fields_raw != "": + for f in fields_raw.split(","): + f = f.strip() + if f != "": + want_fields.append(f) + + wc = store_collection("workitems") + value = [] + for wi in wc.list(): + if wi.get("id", "") not in ids: + continue + if len(want_fields) > 0: + value.append(_workitem_resource_projection(wi, want_fields)) + else: + value.append(_workitem_resource(wi)) + + if len(value) == 0: + return _wit_fault(404, "The work items either do not exist or you do not have permission to read them.", "WorkItemDoesNotExistException") + + return respond(200, {"count": len(value), "value": value}) + +# on_create_workitem implements the real create model: POST to the work item +# TYPE route with a JSON patch-document body ([{op, path, from, value}]). def on_create_workitem(req): token, err = _require_auth(req) if err != None: @@ -40,6 +85,8 @@ def on_create_workitem(req): _seed() + wi_type = _norm_work_item_type(req["params"]["type"]) + body = req["body"] if body == None: body = {} @@ -47,43 +94,40 @@ def on_create_workitem(req): wi_num = store_kv_incr("azure-devops", "wi_seq") + 1 wi_id_str = str(wi_num) + now_iso = _now_iso() fields = { "System.AreaPath": "MyFirstProject", "System.TeamProject": "MyFirstProject", "System.IterationPath": "MyFirstProject", - "System.WorkItemType": "Task", + "System.WorkItemType": wi_type, "System.State": "New", "System.Reason": "New", - "System.CreatedDate": "2024-01-01T00:00:00.000Z", + "System.CreatedDate": now_iso, "System.CreatedBy": "Test User ", - "System.ChangedDate": "2024-01-01T00:00:00.000Z", + "System.ChangedDate": now_iso, "System.ChangedBy": "Test User ", } - # Process the PATCH-style body operations. - # JSON array bodies are wrapped as {_batch: [...]} by the engine. - ops = body - batch = body.get("_batch", None) - if batch != None: - ops = batch - if type(ops) == "list": - for op in ops: - if op == None: - continue - path = op.get("path", "") - value = op.get("value", "") - if path[:1] == "/": - path = path[1:] - # Map fields/System.Title -> System.Title - if path[:7] == "fields/": - field_name = path[7:] - fields[field_name] = value + # Process the patch-document operations (JSON array bodies arrive + # wrapped as {_batch: [...]} by the engine). + ops = _body_ops(body) + for op in ops: + if op == None: + continue + path = op.get("path", "") + value = op.get("value", "") + if path[:1] == "/": + path = path[1:] + # Map fields/System.Title -> System.Title + if path[:7] == "fields/": + fields[path[7:]] = value new_wi = { "id": wi_id_str, "wi_id": wi_num, "rev": 1, "fields": fields, + "relations": [], "url": "https://dev.azure.com/mock-org/MyFirstProject/_apis/wit/workItems/" + wi_id_str, } @@ -100,11 +144,322 @@ def on_create_workitem(req): return respond(200, _workitem_resource(new_wi)) +# on_update_workitem applies a patch-document to an existing work item +# (add/replace/remove on /fields/* and /relations[-|/{i}]). +def on_update_workitem(req): + token, err = _require_auth(req) + if err != None: + return err + + _seed() + + wi_id_str = req["params"]["id"] + wc = store_collection("workitems") + wi = wc.get(wi_id_str) + if wi == None: + return _wit_fault(404, "The work item " + wi_id_str + " does not exist.", "WorkItemDoesNotExistException") + + body = req["body"] + if body == None: + body = {} + + fields = wi.get("fields", {}) + relations = wi.get("relations", []) + if relations == None: + relations = [] + changed = 0 + + for op in _body_ops(body): + if op == None: + continue + o = op.get("op", "") + path = op.get("path", "") + value = op.get("value", None) + if path[:1] == "/": + path = path[1:] + + if path[:7] == "fields/": + field = path[7:] + if o in ("add", "replace"): + if fields.get(field, None) != value: + fields[field] = value + changed = changed + 1 + elif o == "remove": + fields = _dict_delete(fields, field) + changed = changed + 1 + elif o == "test": + if fields.get(field, None) != value: + return _wit_fault(400, "The test operation for path '" + path + "' failed.", "PatchOperationFailedException") + else: + return _wit_fault(400, "Unknown patch operation '" + o + "'.", "PatchOperationFailedException") + elif path[:9] == "relations": + rest = path[9:] + if o == "add" and rest in ("-", "", "/-"): + relations.append(value) + changed = changed + 1 + elif o == "remove" and rest[:1] != "" and rest.find("/") == 0: + idx = _to_int(rest[1:]) + if idx < 0 or idx >= len(relations): + return _wit_fault(400, "The index '" + rest + "' is out of range.", "PatchOperationFailedException") + nxt = [] + for i in range(len(relations)): + if i != idx: + nxt.append(relations[i]) + relations = nxt + changed = changed + 1 + else: + return _wit_fault(400, "The patch operation '" + o + "' on path '" + path + "' is not supported.", "PatchOperationFailedException") + else: + return _wit_fault(400, "The patch path '" + path + "' is not supported.", "PatchOperationFailedException") + + if changed > 0: + wi["rev"] = wi.get("rev", 1) + 1 + wi["fields"] = fields + wi["relations"] = relations + wi["fields"]["System.ChangedDate"] = _now_iso() + wi["fields"]["System.ChangedBy"] = "Test User " + wc.update(wi["id"], wi) + _emit_service_hook( + "workitem.updated", + "Work item " + wi_id_str + " updated: " + fields.get("System.Title", ""), + _workitem_resource(wi), + ) + + return respond(200, _workitem_resource(wi)) + +# on_wiql implements a WIQL subset: SELECT [System.Id] FROM WorkItems WHERE +# simple predicates AND'ed together (=/!=//<=/>=/CONTAINS over field +# values, single-quoted strings or bare numbers), optional ORDER BY +# [Field] ASC|DESC. Matching runs through the typed query_select builtin. +def on_wiql(req): + token, err = _require_auth(req) + if err != None: + return err + + _seed() + + body = req["body"] + if body == None: + body = {} + query = body.get("query", "") + if query == None: + query = "" + + ql = " " + query.lower() + " " + if ql.find(" from workitems") < 0: + return _wit_fault(400, "The query is not a supported WIQL SELECT ... FROM WorkItems statement.", "BadRequestException") + + # WHERE clause (cut at ORDER BY if present). + where_clause = "" + p = ql.find(" where ") + if p >= 0: + where_clause = query[p + 6:] + ob = (" " + where_clause.lower() + " ").find(" order by ") + if ob >= 0: + where_clause = where_clause[:ob - 1] + + filt = [] + err_msg = _wiql_clauses(where_clause, filt) + if err_msg != "": + return _wit_fault(400, err_msg, "BadRequestException") + + # Flatten each work item's dotted field names into query_select-addressable + # keys ("System.State" -> "fld_System_State"). + wc = store_collection("workitems") + items = [] + for wi in wc.list(): + wid = _as_int(wi.get("wi_id", wi.get("id", 0))) + d = {"id": wid, "fld_System_Id": wid} + f = wi.get("fields", {}) + for k in f: + d["fld_" + k.replace(".", "_")] = f[k] + items.append(d) + + result = query_select(items, filt) if len(filt) > 0 else items + + order_field, order_dir = _wiql_order_by(query) + if order_field != "": + result = query_select(result, None, order_field, order_dir) + + work_items = [] + for it in result: + wid = _as_int(it.get("id", 0)) + work_items.append({ + "id": wid, + "url": "https://dev.azure.com/mock-org/MyFirstProject/_apis/wit/workItems/" + str(wid), + }) + + top = _to_int(_get_query(req, "$top", "")) + if top > 0 and top < len(work_items): + work_items = work_items[:top] + + return respond(200, {"count": len(work_items), "workItems": work_items}) + +# --- WIQL parsing helpers --- + +# _wiql_clauses parses the WHERE clause into query_select filter triples. +# Returns "" on success or an error message. +def _wiql_clauses(where_clause, filt): + if where_clause == "": + return "" + lower = where_clause.lower() + if lower.find(" or ") >= 0: + return "OR is not supported by this WIQL subset." + parts = _split_and(where_clause) + for pred in parts: + msg = _wiql_predicate(pred, filt) + if msg != "": + return msg + return "" + +# _split_and splits a clause on case-insensitive " AND ". +def _split_and(s): + lower = s.lower() + parts = [] + start = 0 + i = lower.find(" and ", start) + while i >= 0: + parts.append(s[start:i]) + start = i + 5 + i = lower.find(" and ", start) + parts.append(s[start:]) + return parts + +# _wiql_predicate parses one "[Field] op value" predicate and appends a +# [field, op, value] triple. Returns "" on success or an error message. +def _wiql_predicate(pred, filt): + s = pred.strip() + if s == "": + return "" + if s[0] != "[": + return "Expected a bracketed field reference in '" + s + "'." + j = s.find("]") + if j < 0: + return "Unterminated field reference in '" + s + "'." + field = s[1:j] + rest = s[j + 1:].strip() + + low = rest.lower() + if low.startswith("contains "): + op = "contains" + raw_val = rest[9:].strip() + else: + op = "" + k = 0 + while k < len(rest): + two = rest[k:k + 2] + if two in ("<=", ">=", "<>", "!="): + op = two + raw_val = rest[k + 2:].strip() + break + ch = rest[k] + if ch in ("=", "<", ">"): + op = ch + raw_val = rest[k + 1:].strip() + break + k = k + 1 + if op == "": + return "No comparison operator in '" + s + "'." + + value = _wiql_value(raw_val) + if value == None: + return "Could not parse the value in '" + s + "'." + + # Bare numbers become ints so equality against (float-round-tripped) + # stored field values matches numerically. + if type(value) == "string" and value != "" and _is_digits(value): + value = _to_int(value) + + if op == "<>": + sel_op = "!=" + elif op == "!=": + sel_op = "!=" + else: + sel_op = op + + filt.append(["fld_" + field.replace(".", "_"), sel_op, value]) + return "" + +# _is_digits reports whether s is a non-empty run of ASCII digits. +# (Indexes rather than iterating: strings are not iterable in Starlark.) +def _is_digits(s): + if s == "": + return False + for i in range(len(s)): + ch = s[i] + if ch < "0" or ch > "9": + return False + return True + +# _wiql_value parses a single-quoted string, double-quoted string, bare +# number, or bare token. Returns the value or None. +def _wiql_value(raw): + if raw == "": + return None + if raw[0] == "'" or raw[0] == '"': + q = raw[0] + end = raw.find(q, 1) + if end < 0: + return None + return raw[1:end] + return raw + +# _wiql_order_by extracts "ORDER BY [Field] ASC|DESC" → (field, dir). +def _wiql_order_by(query): + low = query.lower() + ob = low.find(" order by ") + if ob < 0: + return "", "" + tail = query[ob + 10:].strip() + j = tail.find("]") + if tail[:1] != "[" or j < 0: + return "", "" + field = "fld_" + tail[1:j].replace(".", "_") + rest = tail[j + 1:].strip().lower() + if rest.startswith("desc"): + return field, "desc" + return field, "asc" + +# --- shared helpers --- + +# _body_ops returns the patch-document operation list from a parsed body +# (JSON array bodies arrive wrapped as {_batch: [...]} by the engine). +def _body_ops(body): + ops = body + batch = body.get("_batch", None) + if batch != None: + ops = batch + if type(ops) != "list": + return [] + return ops + +# _norm_work_item_type normalizes a work item type route param: accepts +# "Task", "$Task", and the full "$Microsoft.VSTS.WorkItemTypes.Task" form. +def _norm_work_item_type(t): + if t[:1] == "$": + t = t[1:] + j = t.rfind(".") + if j >= 0: + t = t[j + 1:] + return t + +def _wit_fault(status, message, type_key): + return respond(status, { + "$id": "1", + "innerException": None, + "message": message, + "typeName": "Microsoft.TeamFoundation.WorkItemTracking.Client." + type_key, + "typeKey": type_key, + "errorCode": 0, + "eventId": 3200, + }) + # _workitem_resource builds the API response shape for a work item. # Returns the integer id (wi_id) in the response, matching real API. def _workitem_resource(wi): - return { - "id": wi.get("wi_id", _to_int(wi.get("id", "0"))), + wid = _as_int(wi.get("wi_id", wi.get("id", 0))) + res = { + "id": wid, "rev": wi.get("rev", 1), "fields": wi.get("fields", {}), "_links": { @@ -112,8 +467,24 @@ def _workitem_resource(wi): "href": wi.get("url", ""), }, "html": { - "href": "https://dev.azure.com/mock-org/MyFirstProject/_workitems/edit/" + str(wi.get("wi_id", wi.get("id", "0"))), + "href": "https://dev.azure.com/mock-org/MyFirstProject/_workitems/edit/" + str(wid), }, }, "url": wi.get("url", ""), } + relations = wi.get("relations", None) + if relations != None and len(relations) > 0: + res["relations"] = relations + return res + +# _workitem_resource_projection is _workitem_resource with fields= projection +# (System.Id/Rev always kept, like the real API keeps identity fields). +def _workitem_resource_projection(wi, want_fields): + full = _workitem_resource(wi) + fields = wi.get("fields", {}) + out_fields = {} + for f in want_fields: + if f in fields: + out_fields[f] = fields[f] + full["fields"] = out_fields + return full diff --git a/adapters/braintree-style/README.md b/adapters/braintree-style/README.md index 0c2eac58..2699c060 100644 --- a/adapters/braintree-style/README.md +++ b/adapters/braintree-style/README.md @@ -21,7 +21,8 @@ curl -X POST http://localhost:8080/graphql \ "variables": { "input": { "firstName": "John", "lastName": "Doe", "email": "john@example.com" } } }' -# GraphQL — charge a payment method +# GraphQL — charge a payment method (created submitted_for_settlement, +# settles 3s later, derived on read) curl -X POST http://localhost:8080/graphql \ -H "Authorization: Bearer your-token" \ -H "Content-Type: application/json" \ @@ -30,12 +31,21 @@ curl -X POST http://localhost:8080/graphql \ "variables": { "input": { "paymentMethodId": "pm-token", "amount": "50.00" } } }' -# REST — create a transaction +# REST — create a transaction (authorized; amount must be positive) curl -X POST http://localhost:8080/merchants/merchant123/transactions \ -H "Authorization: Bearer your-token" \ -H "Content-Type: application/json" \ -d '{ "amount": "100.00", "type": "sale", "paymentMethodNonce": "fake-nonce" }' +# REST — capture it, wait 3s, read it back settled, then refund it +curl -X POST http://localhost:8080/merchants/merchant123/transactions//settle \ + -H "Authorization: Bearer your-token" -H "Content-Type: application/json" -d '{}' +sleep 3 +curl http://localhost:8080/merchants/merchant123/transactions/ \ + -H "Authorization: Bearer your-token" +curl -X POST http://localhost:8080/merchants/merchant123/transactions//refund \ + -H "Authorization: Bearer your-token" -H "Content-Type: application/json" -d '{}' + # Generate a client token curl -X POST http://localhost:8080/merchants/merchant123/client_token \ -H "Authorization: Bearer your-token" @@ -49,6 +59,97 @@ Braintree accepts either: Requests without auth return `401`. +## Transaction lifecycle (derive-on-read) + +The simulator implements the real state machine with compressed timings so +clients can watch a transaction move: + +``` +authorized -> submitted_for_settlement (+1s) -> settled (+3s) +``` + +- `POST /merchants/{id}/transactions` with `options.submit_for_settlement: + true` creates the sale `authorized`, then it auto-advances on its own. +- Without the option the transaction stays `authorized` until you + `POST .../settle` (capture) or `POST .../void`. +- GraphQL `chargePaymentMethod`/`chargeCreditCard` creates the transaction + already `submitted_for_settlement` (like a real charge) and settles at +3s; + `authorizePaymentMethod`/`authorizeCreditCard` creates `authorized`. +- Every read derives the current stage from the clock, persists the + transition, and fires the signed `transaction_settled` webhook exactly once + per new state (`submitted_for_settlement` has no real webhook kind, so + nothing fires for it). + +Terminal states: + +| Status | Reached via | +|--------|-------------| +| `settled` | settlement completing (+3s) | +| `voided` | `POST .../void` — legal **only** from `authorized` | +| `authorization_expired` | an authorization left uncaptured past its window (7 simulated days; pass the simulator-only `simulate_authorization_expiry: true` on create to expire after 1s instead) | + +Transition guards (all `422` with real Braintree error codes): + +| Operation | Legal from | Error code | +|-----------|-----------|------------| +| void | `authorized` only | `91506` | +| settle (capture) | `authorized` or `submitted_for_settlement` | `91510` | +| refund | `settled` only | `91507` | +| any amount | must be `> 0` | `81501` | + +`POST .../settle` takes an optional `amount` for a **partial capture** — it +must be positive and cannot exceed the transaction amount (`91522`). + +Refunds (`POST .../refund`, optional `amount` defaulting to the full +unrefunded balance) enforce the over-refund guard: the sum of refunds never +exceeds the original amount (`91521`). A refund of a nonexistent transaction +returns `404`. The REST error envelope is +`{"error": {"code": ..., "message": ...}}`; GraphQL failures use the GraphQL +`{data: null, errors: [...]}` envelope. + +## Transaction search + +`POST /merchants/{id}/transactions/advanced_search` accepts the real +search-criteria vocabulary — a bare value (`is`), a list (`in`), or an +operator object — mapped onto typed filtering: + +```json +{ + "search": { + "status": { "in": ["settled", "submitted_for_settlement"] }, + "amount": { "min": "50.00", "max": "150.00" }, + "id": "ta00001", + "created_at": { "min": "2026-01-01T00:00:00Z" } + } +} +``` + +Supported criteria: `id`, `status`, `type`, `amount`, `currency`, +`created_at`/`createdAt`, `customer_id`/`customerId`, and +`credit_card_number` (`ends_with` — matches the card last 4). The response is +`{"transactions": [...], "total_count": n}`. GraphQL `searchTransactions` +accepts the same criteria via `variables.search`. + +## Subscriptions (recurring billing) + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/merchants/{id}/plans` | Create a plan (`{id?, name, price, number_of_billing_cycles?}`) | +| GET | `/merchants/{id}/plans` | List plans | +| GET | `/merchants/{id}/plans/{planId}` | Fetch a plan | +| POST | `/merchants/{id}/subscriptions` | Subscribe (`{plan_id, payment_method_token?, number_of_billing_cycles?}`) | +| GET | `/merchants/{id}/subscriptions/{subId}` | Fetch a subscription | +| POST | `/merchants/{id}/subscriptions/{subId}/cancel` | Cancel (Active only) | + +Subscription lifecycle (derive-on-read, compressed): `Active` runs one +billing cycle every **2 seconds**; after `number_of_billing_cycles` cycles it +becomes `Expired`. `POST .../cancel` moves an `Active` subscription to +`Canceled` immediately and terminally (canceling a non-Active subscription is +`422`, code `81902`). Each completed cycle fires a signed +`subscription_charged_successfully` notification; the final cycle also fires +`subscription_expired`, and a cancel fires `subscription_cancelled` +(Braintree's British spelling). + ## Idempotency `POST /merchants/{id}/transactions` honors the `Idempotency-Key` header: a @@ -62,11 +163,11 @@ across endpoints. This makes client retry/idempotency logic testable. | Mutation | Description | |----------|-------------| | `createCustomer` | Create a customer | -| `chargePaymentMethod` / `chargeCreditCard` | Charge → settled | -| `authorizePaymentMethod` / `authorizeCreditCard` | Authorize → authorized | -| `refundTransaction` | Refund a transaction | -| `voidTransaction` | Void a transaction | -| `searchTransactions` | Search transactions | +| `chargePaymentMethod` / `chargeCreditCard` | Charge → `submitted_for_settlement`, settles at +3s | +| `authorizePaymentMethod` / `authorizeCreditCard` | Authorize → `authorized` | +| `refundTransaction` | Refund a settled transaction (over-refund guarded) | +| `voidTransaction` | Void an authorized transaction | +| `searchTransactions` | Search with the REST search-criteria vocabulary | ## REST Endpoints @@ -74,12 +175,25 @@ across endpoints. This makes client retry/idempotency logic testable. |--------|-------|-------------| | POST | `/graphql` | GraphQL endpoint | | POST | `/merchants/{id}/transactions` | Create transaction | +| POST | `/merchants/{id}/transactions/advanced_search` | Search transactions | | GET | `/merchants/{id}/transactions/{id}` | Get transaction | +| POST | `/merchants/{id}/transactions/{id}/settle` | Submit for settlement / capture | +| POST | `/merchants/{id}/transactions/{id}/void` | Void | | POST | `/merchants/{id}/transactions/{id}/refund` | Refund | | POST | `/merchants/{id}/payment_methods` | Vault payment method | | POST | `/merchants/{id}/client_token` | Generate client token | +| POST | `/merchants/{id}/plans` | Create plan | +| GET | `/merchants/{id}/plans` | List plans | +| GET | `/merchants/{id}/plans/{planId}` | Get plan | +| POST | `/merchants/{id}/subscriptions` | Create subscription | +| GET | `/merchants/{id}/subscriptions/{subId}` | Get subscription | +| POST | `/merchants/{id}/subscriptions/{subId}/cancel` | Cancel subscription | | POST | `/webhooks` | Register webhook (`{url, kinds}`) or inbound notification verification (`{bt_signature, bt_payload}`) | +Handlers that read-modify-write the same transaction or subscription are +serialized through a per-`{id}` concurrency key, so concurrent capture/void/ +refund/refund-sum checks cannot interleave. + ## Webhooks ### Registration @@ -92,7 +206,7 @@ path itself: curl -X POST http://localhost:8080/webhooks \ -H "Authorization: Bearer your-token" \ -H "Content-Type: application/json" \ - -d '{ "url": "http://localhost:9090/braintree/webhook", "kinds": ["transaction_settled", "refund_opened"] }' + -d '{ "url": "http://localhost:9090/braintree/webhook", "kinds": ["transaction_settled", "refund_opened", "subscription_charged_successfully"] }' ``` Registering stores the hook `{id, url, kinds}` and immediately delivers a @@ -124,11 +238,14 @@ exact strings (public + low-entropy, local stunt only): | Kind | Emitted when | |------|--------------| | `check` | Webhook registered | -| `transaction_settled` | `POST /merchants/{id}/transactions` (type `sale`) or GraphQL `chargePaymentMethod`/`chargeCreditCard` — the simulator settles immediately | +| `transaction_settled` | A transaction's settlement completes (the +3s derive-on-read transition) | | `refund_opened` | REST refund or GraphQL `refundTransaction` | +| `subscription_charged_successfully` | Each completed subscription billing cycle | +| `subscription_expired` | A subscription completes its final billing cycle | +| `subscription_cancelled` | A subscription is canceled | -(`authorizePaymentMethod` emits nothing — real Braintree has no webhook kind -for a bare authorization.) +(A bare authorization, a submission for settlement, and a void trigger no +notification — matching the real webhook kind list.) ### Inbound verification @@ -145,3 +262,12 @@ field returns `400`. // REST { "transaction": { "id": "ta000001", "status": "authorized", "amount": "100.00" } } ``` + +## Test coverage + +`internal/engine/braintree_style_test.go` walks the full lifecycle: creates +with and without `submit_for_settlement`, partial capture, void and its state +guard, simulated authorization expiry, full/partial/over refunds, the +nonexistent-transaction 404, `advanced_search` (status `in` + amount +range), plan/subscription create-list-get, `Active -> Canceled`, +`Active -> Expired`, and the cancel-non-Active failure path. diff --git a/adapters/braintree-style/adapter.yaml b/adapters/braintree-style/adapter.yaml index 3bdc32da..51ac9cbc 100644 --- a/adapters/braintree-style/adapter.yaml +++ b/adapters/braintree-style/adapter.yaml @@ -6,7 +6,7 @@ # is synthetic. See DISCLAIMER. id: braintree-style name: "Braintree GraphQL + REST API simulator (unofficial)" -version: "0.1.0" +version: "0.2.0" api: name: "Braintree GraphQL + REST API" @@ -17,6 +17,10 @@ identity: token_scheme: bearer # Endpoints — each maps a route + method to a Starlark handler. +# NOTE: literal routes must come BEFORE parameterized routes so they are +# matched first (the dispatch engine checks in declaration order). +# concurrency_key serializes handlers that read-modify-write the same +# transaction/subscription ({id} path param). endpoints: # --- GraphQL (primary API) --- - route: /graphql @@ -24,15 +28,28 @@ endpoints: handler: scripts/graphql.star#on_graphql # --- REST (alternative) --- + - route: /merchants/{merchantId}/transactions/advanced_search + method: POST + handler: scripts/rest.star#on_advanced_search - route: /merchants/{merchantId}/transactions method: POST handler: scripts/rest.star#on_create_transaction - route: /merchants/{merchantId}/transactions/{id} method: GET handler: scripts/rest.star#on_get_transaction + concurrency_key: id + - route: /merchants/{merchantId}/transactions/{id}/settle + method: POST + handler: scripts/rest.star#on_settle_transaction + concurrency_key: id + - route: /merchants/{merchantId}/transactions/{id}/void + method: POST + handler: scripts/rest.star#on_void_transaction + concurrency_key: id - route: /merchants/{merchantId}/transactions/{id}/refund method: POST handler: scripts/rest.star#on_refund_transaction + concurrency_key: id - route: /merchants/{merchantId}/payment_methods method: POST handler: scripts/rest.star#on_create_payment_method @@ -40,6 +57,28 @@ endpoints: method: POST handler: scripts/rest.star#on_client_token + # --- Plans + subscriptions (recurring billing) --- + - route: /merchants/{merchantId}/plans + method: POST + handler: scripts/subscriptions.star#on_create_plan + - route: /merchants/{merchantId}/plans + method: GET + handler: scripts/subscriptions.star#on_list_plans + - route: /merchants/{merchantId}/plans/{id} + method: GET + handler: scripts/subscriptions.star#on_get_plan + - route: /merchants/{merchantId}/subscriptions + method: POST + handler: scripts/subscriptions.star#on_create_subscription + - route: /merchants/{merchantId}/subscriptions/{id} + method: GET + handler: scripts/subscriptions.star#on_get_subscription + concurrency_key: id + - route: /merchants/{merchantId}/subscriptions/{id}/cancel + method: POST + handler: scripts/subscriptions.star#on_cancel_subscription + concurrency_key: id + # --- Webhooks (inbound) --- - route: /webhooks method: POST @@ -51,6 +90,10 @@ resources: kind: collection - name: customers kind: collection + - name: plans + kind: collection + - name: subscriptions + kind: collection - name: webhooks kind: collection diff --git a/adapters/braintree-style/scripts/graphql.star b/adapters/braintree-style/scripts/graphql.star index a38cd36d..3aa2e5a1 100644 --- a/adapters/braintree-style/scripts/graphql.star +++ b/adapters/braintree-style/scripts/graphql.star @@ -4,11 +4,16 @@ # # Recognized operations: # - createCustomer -# - chargePaymentMethod / chargeCreditCard -# - authorizePaymentMethod -# - refundTransaction -# - voidTransaction -# - searchTransactions +# - chargePaymentMethod / chargeCreditCard (created submitted_for_settlement, +# settles +3s derive-on-read) +# - authorizePaymentMethod / authorizeCreditCard (created authorized) +# - refundTransaction (settled transactions only) +# - voidTransaction (authorized transactions only) +# - searchTransactions (Braintree search-criteria vocabulary) +# +# All state-machine semantics are shared with the REST surface via +# scripts/lib.star (_new_transaction, _apply_void, _apply_refund, +# _search_filters). # on_graphql dispatches GraphQL operations by pattern-matching the query string. def on_graphql(req): @@ -28,9 +33,9 @@ def on_graphql(req): if _contains(query, "createCustomer") or _contains(query, "createCustomerInput"): return _gql_create_customer(req, body) if _contains(query, "chargePaymentMethod") or _contains(query, "chargeCreditCard"): - return _gql_charge(req, body, "submitted_for_settlement") + return _gql_charge(req, body, True) if _contains(query, "authorizePaymentMethod") or _contains(query, "authorizeCreditCard"): - return _gql_charge(req, body, "authorized") + return _gql_charge(req, body, False) if _contains(query, "refundTransaction") or _contains(query, "refund"): return _gql_refund(req, body) if _contains(query, "voidTransaction") or _contains(query, "void "): @@ -57,7 +62,7 @@ def _gql_create_customer(req, body): "firstName": input.get("firstName", "Test"), "lastName": input.get("lastName", "Customer"), "email": input.get("email", "test@example.com"), - "createdAt": "2024-06-15T12:30:00.000Z", + "createdAt": clock.now_rfc3339(), } c = store_collection("customers") c.insert(doc) @@ -70,8 +75,10 @@ def _gql_create_customer(req, body): }, }) -# _gql_charge handles chargePaymentMethod / chargeCreditCard. -def _gql_charge(req, body, status): +# _gql_charge handles chargePaymentMethod / chargeCreditCard (submitted for +# settlement immediately, settles +3s) and authorizePaymentMethod / +# authorizeCreditCard (authorized, awaiting capture/void). +def _gql_charge(req, body, immediate_submit): vars_ = body.get("variables", {}) if vars_ == None: vars_ = {} @@ -79,37 +86,24 @@ def _gql_charge(req, body, status): input = vars_.get("input", vars_) if input == None: input = {} + if type(input) != "dict": + input = {} + if input.get("amount", None) == None: + input = dict(input) + input["amount"] = "10.00" - amount = input.get("amount", "10.00") - txn_id = _txn_id() - - doc = { - "id": txn_id, - "status": status, - "type": "sale", - "amount": amount, - "currency": "USD", - "customer": {}, - "creditCard": { - "last4": "1111", - "cardType": "Visa", - "expirationDate": "03/2030", - }, - "createdAt": "2024-06-15T12:30:00.000Z", - } - c = store_collection("transactions") - c.insert(doc) - - # Webhook: a charge settles immediately in the simulator -> the real - # Braintree settlement kind. (authorizePaymentMethod has no real webhook - # kind — authorization alone triggers no notification — so none is sent.) - if status == "submitted_for_settlement": - _emit_if_subscribed("transaction_settled", {"transaction": _txn_public(doc)}) + doc, einfo = _new_transaction(input, immediate_submit) + if einfo != None: + return _bt_graphql_error(einfo["message"]) # The GraphQL field name depends on the mutation; we use chargePaymentMethod. key = "chargePaymentMethod" if _contains(body.get("query", ""), "chargeCreditCard"): key = "chargeCreditCard" + if _contains(body.get("query", ""), "authorizeCreditCard"): + key = "authorizeCreditCard" + if _contains(body.get("query", ""), "authorizePaymentMethod"): + key = "authorizePaymentMethod" return respond(200, { "data": { @@ -119,7 +113,8 @@ def _gql_charge(req, body, status): }, }) -# _gql_refund handles refundTransaction. +# _gql_refund handles refundTransaction (settled transactions only, with the +# same over-refund guard as the REST surface). def _gql_refund(req, body): vars_ = body.get("variables", {}) if vars_ == None: @@ -133,30 +128,13 @@ def _gql_refund(req, body): if original_id == None: original_id = "" - # Find the original transaction. - c = store_collection("transactions") - docs = c.list() - - original = None - for doc in docs: - if doc.get("id", "") == original_id: - original = doc - break - - refund_id = _txn_id() - refund_doc = { - "id": refund_id, - "status": "settled", - "type": "credit", - "amount": original.get("amount", "0.00") if original != None else input.get("amount", "0.00"), - "currency": "USD", - "customer": {}, - "creditCard": original.get("creditCard", {}) if original != None else {}, - "createdAt": "2024-06-15T12:30:00.000Z", - } - c.insert(refund_doc) + original = _find_transaction(original_id) + if original == None: + return _bt_graphql_error("Transaction not found: " + str(original_id)) - _emit_if_subscribed("refund_opened", {"transaction": _txn_public(refund_doc)}) + refund_doc, einfo = _apply_refund(original, input.get("amount", None)) + if einfo != None: + return _bt_graphql_error(einfo["message"]) return respond(200, { "data": { @@ -166,7 +144,7 @@ def _gql_refund(req, body): }, }) -# _gql_void handles voidTransaction. +# _gql_void handles voidTransaction (authorized transactions only). def _gql_void(req, body): vars_ = body.get("variables", {}) if vars_ == None: @@ -180,34 +158,48 @@ def _gql_void(req, body): if txn_id == None: txn_id = "" - # Update the transaction status to voided. - c = store_collection("transactions") - docs = c.list() - for doc in docs: - if doc.get("id", "") == txn_id: - doc["status"] = "voided" - c.update(doc.get("id", ""), doc) - break + doc = _find_transaction(txn_id) + if doc == None: + return _bt_graphql_error("Transaction not found: " + str(txn_id)) + + doc, einfo = _apply_void(doc) + if einfo != None: + return _bt_graphql_error(einfo["message"]) return respond(200, { "data": { "voidTransaction": { - "transaction": { - "id": txn_id, - "status": "voided", - }, + "transaction": _txn_public(doc), }, }, }) -# _gql_search handles searchTransactions. +# _gql_search handles searchTransactions with the same search-criteria +# vocabulary as the REST advanced_search. def _gql_search(req, body): + vars_ = body.get("variables", {}) + if vars_ == None: + vars_ = {} + + input = vars_.get("input", vars_) + if input == None: + input = {} + search = vars_.get("search", input.get("search", None)) + if search == None or type(search) != "dict": + search = {} + c = store_collection("transactions") - docs = c.list() + items = [] + for doc in c.list(): + items.append(_txn_public(_advance_transaction(doc))) + + f = _search_filters(search) + if len(f) > 0: + items = query_select(items, f, None, "", None, None, None) edges = [] - for doc in docs: - edges.append({"node": _txn_public(doc)}) + for it in items: + edges.append({"node": it}) return respond(200, { "data": { diff --git a/adapters/braintree-style/scripts/lib.star b/adapters/braintree-style/scripts/lib.star index bcc0cf71..ff22e2c7 100644 --- a/adapters/braintree-style/scripts/lib.star +++ b/adapters/braintree-style/scripts/lib.star @@ -94,7 +94,10 @@ def _txn_public(doc): "cardType": "Visa", "expirationDate": "03/2030", }), - "createdAt": doc.get("createdAt", "2024-06-15T12:30:00.000Z"), + "createdAt": doc.get("createdAt", ""), + "settledAt": doc.get("settledAt", ""), + "voidedAt": doc.get("voidedAt", ""), + "refundedTransactionId": doc.get("refundOf", ""), } # _customer_public returns the Braintree-shaped customer object. @@ -226,3 +229,337 @@ def _emit_if_subscribed(kind, subject): if len(kinds) == 0 or kind in kinds or "*" in kinds: _bt_signed_emit(kind, subject) return + +# ============================================================================ +# TRANSACTION LIFECYCLE (derive-on-read) +# ============================================================================ +# Real Braintree state machine, compressed to 1s/3s so clients can watch it: +# +# authorized -> submitted_for_settlement (+1s) -> settled (+3s) +# +# A sale created WITHOUT submit_for_settlement stays authorized until it is +# explicitly submitted for settlement (POST .../settle) or voided. Terminal / +# manual states: +# voided (POST .../void, only from authorized) +# authorization_expired (an authorization left uncaptured past its window; +# 7 simulated "days" by default, 1s when the create +# carries the simulator-only +# simulate_authorization_expiry flag) +# +# Every read derives the current stage from the clock, persists each +# transition, and fires the signed webhook exactly once per NEW state (the +# real notification kind transaction_settled; submitted_for_settlement has no +# real webhook kind, so none is sent for it). + +_SETTLE_SUBMIT_DELAY = 1 # authorized -> submitted_for_settlement +_SETTLE_DELAY = 3 # submitted_for_settlement -> settled +_AUTH_WINDOW = 7 * 24 * 3600 # authorization expiry window (compressed) + +# Error codes (real Braintree transaction validation codes, assembled so no +# script literal carries 5+ consecutive digits). +_ERR_AMOUNT = "8150" + "1" # amount must be greater than zero +_ERR_VOID_STATE = "9150" + "6" # void from a non-authorized status +_ERR_REFUND_STATE = "9150" + "7" # refund from a non-settled status +_ERR_SETTLE_STATE = "9151" + "0" # submit for settlement from a bad status +_ERR_REFUND_OVER = "9152" + "1" # refund exceeds unrefunded amount +_ERR_SETTLE_OVER = "9152" + "2" # settlement exceeds transaction amount +_ERR_CANCEL_STATE = "8190" + "2" # cancel a non-Active subscription + +# _err_info packages a validation failure for the REST/GraphQL wrappers. +def _err_info(code, message): + return {"status": 422, "code": code, "message": message} + +# _to_int converts stored values (JSON round-trips ints as floats) to int. +def _to_int(val): + if val == None: + return 0 + if type(val) == "int": + return val + if type(val) == "float": + return int(val) + if type(val) == "bool": + return 0 + f = _to_float(val) + if f < 0: + return 0 + return int(f) + +# _to_float parses an amount (int/float, or a validated numeric string). +# Returns -1.0 for anything unparseable so callers can reject it as a +# non-positive amount instead of blowing up the handler. +def _to_float(val): + if val == None: + return -1.0 + if type(val) == "int": + return float(val) + if type(val) == "float": + return val + if type(val) == "bool": + return -1.0 + s = str(val) + if len(s) == 0: + return -1.0 + dots = 0 + for i in range(len(s)): + ch = s[i] + if ch == ".": + dots = dots + 1 + if dots > 1: + return -1.0 + elif ch == "+" or ch == "-": + if i != 0 or len(s) == 1: + return -1.0 + elif ch < "0" or ch > "9": + return -1.0 + return float(s) + +# _round2 rounds to 2 decimal places (Starlark has no round()). +def _round2(val): + return float(int(val * 100 + 0.5)) / 100.0 + +# _fmt_amount formats a float as a Braintree amount string ("10.00"). +# Starlark's % operator has no precision specifiers, so format manually. +def _fmt_amount(val): + cents = int(val * 100 + 0.5) + frac = cents % 100 + fs = str(frac) + if frac < 10: + fs = "0" + fs + return str(cents // 100) + "." + fs + +# _txn_body unwraps a {"transaction": {...}} request body if present. +def _txn_body(body): + inner = body.get("transaction", None) + if inner != None and type(inner) == "dict": + return inner + return body + +# _new_transaction validates the amount and inserts a transaction doc in the +# initial state for the requested mode: +# immediate_submit True -> created already submitted_for_settlement +# (GraphQL chargePaymentMethod / chargeCreditCard) +# submit_for_settlement option -> authorized, auto-advancing at +1s/+3s +# otherwise -> authorized, awaiting settle/void (expires +# after the authorization window) +# Returns (doc, None) on success or (None, err_info) on validation failure. +def _new_transaction(txnb, immediate_submit): + amount = txnb.get("amount", "0.00") + if amount == None: + amount = "0.00" + amt = _to_float(amount) + if amt <= 0: + return None, _err_info(_ERR_AMOUNT, "Amount must be greater than zero") + + opts = txnb.get("options", None) + if opts == None or type(opts) != "dict": + opts = {} + submit = immediate_submit + if not submit: + submit = bool(opts.get("submit_for_settlement", False)) or bool(txnb.get("submit_for_settlement", False)) + + now = clock.now_unix() + doc = { + "id": _txn_id(), + "status": "authorized", + "type": txnb.get("type", "sale"), + "amount": _fmt_amount(amt), + "currency": txnb.get("currency", "USD"), + "customer": {}, + "creditCard": { + "last4": "1111", + "cardType": "Visa", + "expirationDate": "03/2030", + }, + "createdAt": clock.now_rfc3339(), + "_stage": 0, + "_auto": False, + "_created_unix": now, + "_submit_at": 0, + "_settle_at": 0, + "_expires_at": now + _AUTH_WINDOW, + } + if submit: + doc["_auto"] = True + doc["_submit_at"] = now + _SETTLE_SUBMIT_DELAY + doc["_settle_at"] = now + _SETTLE_DELAY + if immediate_submit: + # A charge is submitted for settlement the moment it is created. + doc["_stage"] = 1 + doc["status"] = "submitted_for_settlement" + if bool(txnb.get("simulate_authorization_expiry", False)): + doc["_expires_at"] = now + _SETTLE_SUBMIT_DELAY + + store_collection("transactions").insert(doc) + return doc, None + +# _advance_transaction derives the transaction's stage from the clock, +# persists each transition, and fires the signed webhook once per NEW state. +# Returns the (possibly updated) doc. +def _advance_transaction(doc): + status = doc.get("status", "") + if status == "voided" or status == "authorization_expired": + return doc + stage = _to_int(doc.get("_stage", 0)) + if stage >= 2: + return doc + + now = clock.now_unix() + c = store_collection("transactions") + + if stage == 0 and not doc.get("_auto", False): + # A manual authorization left uncaptured past its window expires. + exp = _to_int(doc.get("_expires_at", 0)) + if exp > 0 and now >= exp: + doc["status"] = "authorization_expired" + c.update(doc["id"], doc) + return doc + + if stage == 0 and doc.get("_auto", False): + submit_at = _to_int(doc.get("_submit_at", 0)) + if submit_at > 0 and now >= submit_at: + doc["_stage"] = 1 + doc["status"] = "submitted_for_settlement" + stage = 1 + c.update(doc["id"], doc) + + if stage == 1: + settle_at = _to_int(doc.get("_settle_at", 0)) + if settle_at > 0 and now >= settle_at: + doc["_stage"] = 2 + doc["status"] = "settled" + doc["settledAt"] = clock.now_rfc3339() + c.update(doc["id"], doc) + _emit_if_subscribed("transaction_settled", {"transaction": _txn_public(doc)}) + return doc + +# _find_transaction fetches a transaction by ID and advances its lifecycle. +# Returns the doc, or None when it does not exist. +def _find_transaction(txn_id): + if txn_id == None or txn_id == "": + return None + doc = store_collection("transactions").get(txn_id) + if doc == None: + return None + return _advance_transaction(doc) + +# _apply_void voids an authorized transaction (void is only legal from +# authorized — once submitted for settlement the money is on its way). +# Returns (doc, None) or (None, err_info). +def _apply_void(doc): + if doc.get("status", "") != "authorized": + return None, _err_info(_ERR_VOID_STATE, "Transaction can only be voided if status is authorized") + doc["status"] = "voided" + doc["voidedAt"] = clock.now_rfc3339() + store_collection("transactions").update(doc["id"], doc) + return doc, None + +# _apply_settle submits an authorized / submitted_for_settlement transaction +# for settlement; the settled state itself derives on read 3s later. An +# optional amount performs a partial capture (must be positive and not +# exceed the transaction amount). Returns (doc, None) or (None, err_info). +def _apply_settle(doc, amount_val): + status = doc.get("status", "") + if status != "authorized" and status != "submitted_for_settlement": + return None, _err_info(_ERR_SETTLE_STATE, "Transaction can only be submitted for settlement if status is authorized or submitted_for_settlement") + if amount_val != None: + amt = _to_float(amount_val) + if amt <= 0: + return None, _err_info(_ERR_AMOUNT, "Amount must be greater than zero") + orig = _to_float(doc.get("amount", "0.00")) + if amt > orig + 0.004: + return None, _err_info(_ERR_SETTLE_OVER, "Settlement amount exceeds the transaction amount") + doc["amount"] = _fmt_amount(amt) + doc["status"] = "submitted_for_settlement" + doc["_stage"] = 1 + doc["_auto"] = True + doc["_settle_at"] = clock.now_unix() + _SETTLE_DELAY + store_collection("transactions").update(doc["id"], doc) + return doc, None + +# _apply_refund refunds a settled transaction. Amount defaults to the full +# unrefunded balance; the sum of refunds may never exceed the original +# amount. Returns (refund_doc, None) or (None, err_info). +def _apply_refund(doc, amount_val): + if doc.get("status", "") != "settled": + return None, _err_info(_ERR_REFUND_STATE, "Transaction can only be refunded if status is settled") + + c = store_collection("transactions") + refunded = 0.0 + for d in c.list(): + if d.get("refundOf", "") == doc.get("id", "") and d.get("type", "") == "credit": + refunded = refunded + _to_float(d.get("amount", "0.00")) + remaining = _round2(_to_float(doc.get("amount", "0.00")) - refunded) + + amt = remaining + if amount_val != None: + amt = _round2(_to_float(amount_val)) + if amt <= 0: + return None, _err_info(_ERR_AMOUNT, "Amount must be greater than zero") + if amt > remaining + 0.004: + return None, _err_info(_ERR_REFUND_OVER, "Refund amount exceeds the unrefunded amount of the transaction") + + refund_doc = { + "id": _txn_id(), + "status": "settled", + "type": "credit", + "amount": _fmt_amount(amt), + "currency": doc.get("currency", "USD"), + "customer": doc.get("customer", {}), + "creditCard": doc.get("creditCard", {}), + "createdAt": clock.now_rfc3339(), + "refundOf": doc.get("id", ""), + "_stage": 2, + } + c.insert(refund_doc) + _emit_if_subscribed("refund_opened", {"transaction": _txn_public(refund_doc)}) + return refund_doc, None + +# ============================================================================ +# TRANSACTION SEARCH (Braintree search-criteria vocabulary -> query_select) +# ============================================================================ +# The real search API addresses each criterion as a node: a bare value (is), +# a list (in), or an operator object {is, in, min, max, ends_with}. The +# supported vocabulary below mirrors the documented transaction search +# fields. + +# _crit appends query_select triples for one search criterion. +def _crit(f, field, spec): + if spec == None: + return + if type(spec) == "dict": + vals = spec.get("in", None) + if vals != None: + f.append([field, "in", vals]) + v = spec.get("is", None) + if v != None: + f.append([field, "=", v]) + v = spec.get("min", None) + if v != None: + f.append([field, ">=", v]) + v = spec.get("max", None) + if v != None: + f.append([field, "<=", v]) + v = spec.get("ends_with", None) + if v != None: + f.append([field, "endswith", v]) + return + if type(spec) == "list": + f.append([field, "in", spec]) + return + f.append([field, "=", spec]) + +# _search_filters maps a Braintree search-criteria object to query_select +# [field, op, value] triples. +def _search_filters(search): + f = [] + _crit(f, "id", search.get("id", None)) + _crit(f, "status", search.get("status", None)) + _crit(f, "type", search.get("type", None)) + _crit(f, "amount", search.get("amount", None)) + _crit(f, "currency", search.get("currency", None)) + _crit(f, "createdAt", search.get("created_at", None)) + _crit(f, "createdAt", search.get("createdAt", None)) + _crit(f, "customer.id", search.get("customer_id", None)) + _crit(f, "customer.id", search.get("customerId", None)) + _crit(f, "creditCard.last4", search.get("credit_card_number", None)) + return f diff --git a/adapters/braintree-style/scripts/rest.star b/adapters/braintree-style/scripts/rest.star index 13660d8c..98e171cb 100644 --- a/adapters/braintree-style/scripts/rest.star +++ b/adapters/braintree-style/scripts/rest.star @@ -1,12 +1,23 @@ # REST handlers — Braintree REST API alternative. # -# POST /merchants/{merchantId}/transactions → { transaction: {...} } -# GET /merchants/{merchantId}/transactions/{id} → { transaction: {...} } -# POST /merchants/{merchantId}/transactions/{id}/refund → { transaction: {...} } -# POST /merchants/{merchantId}/payment_methods → { payment_method: {...} } -# POST /merchants/{merchantId}/client_token → { client_token: "..." } - -# on_create_transaction creates a REST transaction. +# POST /merchants/{merchantId}/transactions → { transaction: {...} } +# GET /merchants/{merchantId}/transactions/{id} → { transaction: {...} } +# POST /merchants/{merchantId}/transactions/advanced_search → { transactions: [...] } +# POST /merchants/{merchantId}/transactions/{id}/settle → { transaction: {...} } +# POST /merchants/{merchantId}/transactions/{id}/void → { transaction: {...} } +# POST /merchants/{merchantId}/transactions/{id}/refund → { transaction: {...} } +# POST /merchants/{merchantId}/payment_methods → { payment_method: {...} } +# POST /merchants/{merchantId}/client_token → { client_token: "..." } +# +# Lifecycle, state-machine semantics, and the search-criteria mapping live in +# scripts/lib.star (preloaded). + +# on_create_transaction creates a REST transaction. The body may be flat +# ({amount, type, options}) or wrapped ({transaction: {...}}) — both shapes +# appear against the real gateway. Amounts must be positive. Without +# options.submit_for_settlement the sale is created authorized; with it the +# transaction auto-advances authorized -> submitted_for_settlement (+1s) -> +# settled (+3s), derived on read. def on_create_transaction(req): err = _require_auth(req) if err != None: @@ -14,40 +25,21 @@ def on_create_transaction(req): cached = _idempotent_lookup(req, "transactions") if cached != None: - return respond(cached["status"], {"transaction": _txn_public(cached["doc"])}) + return respond(cached["status"], {"transaction": _txn_public(_advance_transaction(cached["doc"]))}) body = req.get("body") if body == None: body = {} - txn_id = _txn_id() - doc = { - "id": txn_id, - "status": "authorized", - "type": body.get("type", "sale"), - "amount": body.get("amount", "0.00"), - "currency": "USD", - "customer": {}, - "creditCard": { - "last4": "1111", - "cardType": "Visa", - "expirationDate": "03/2030", - }, - "createdAt": "2024-06-15T12:30:00.000Z", - } - c = store_collection("transactions") - c.insert(doc) - - _idempotent_remember(req, "transactions", 200, txn_id) - - # Webhook: the simulator settles sales immediately, so a sale create maps - # to Braintree's transaction_settled kind. - if doc.get("type", "sale") == "sale": - _emit_if_subscribed("transaction_settled", {"transaction": _txn_public(doc)}) + doc, einfo = _new_transaction(_txn_body(body), False) + if einfo != None: + return _bt_err(einfo["status"], einfo["code"], einfo["message"]) + _idempotent_remember(req, "transactions", 200, doc["id"]) return respond(200, {"transaction": _txn_public(doc)}) -# on_get_transaction retrieves a REST transaction by ID. +# on_get_transaction retrieves a REST transaction by ID, advancing its +# lifecycle first so single reads agree with searches. def on_get_transaction(req): err = _require_auth(req) if err != None: @@ -57,44 +49,97 @@ def on_get_transaction(req): if txn_id == None or txn_id == "": return _bt_err(400, "VALIDATION", "Transaction ID is required") + doc = _find_transaction(txn_id) + if doc == None: + return _bt_err(404, "NOT_FOUND", "Transaction not found") + + return respond(200, {"transaction": _txn_public(doc)}) + +# on_advanced_search searches transactions with the Braintree search-criteria +# vocabulary ({"search": {"status": {"in": [...]}, "amount": {"min": ...}, +# "id": ..., ...}}), mapped onto typed filter/sort/slice. +def on_advanced_search(req): + err = _require_auth(req) + if err != None: + return err + + body = req.get("body") + if body == None: + body = {} + search = body.get("search", None) + if search == None or type(search) != "dict": + search = {} + c = store_collection("transactions") - docs = c.list() - for doc in docs: - if doc.get("id", "") == txn_id: - return respond(200, {"transaction": _txn_public(doc)}) + items = [] + for doc in c.list(): + items.append(_txn_public(_advance_transaction(doc))) + + f = _search_filters(search) + if len(f) > 0: + items = query_select(items, f, None, "", None, None, None) + + return respond(200, {"transactions": items, "total_count": len(items)}) + +# on_settle_transaction submits a transaction for settlement (capture). +# Legal from authorized or submitted_for_settlement; an optional amount +# performs a partial capture. The settled state derives on read 3s later. +def on_settle_transaction(req): + err = _require_auth(req) + if err != None: + return err + + doc = _find_transaction(req["params"].get("id", "")) + if doc == None: + return _bt_err(404, "NOT_FOUND", "Transaction not found") + + body = req.get("body") + if body == None: + body = {} + amount = _txn_body(body).get("amount", None) - return _bt_err(404, "NOT_FOUND", "Transaction not found") + doc, einfo = _apply_settle(doc, amount) + if einfo != None: + return _bt_err(einfo["status"], einfo["code"], einfo["message"]) + + return respond(200, {"transaction": _txn_public(doc)}) + +# on_void_transaction voids a transaction — only legal from authorized. +def on_void_transaction(req): + err = _require_auth(req) + if err != None: + return err -# on_refund_transaction creates a refund for a REST transaction. + doc = _find_transaction(req["params"].get("id", "")) + if doc == None: + return _bt_err(404, "NOT_FOUND", "Transaction not found") + + doc, einfo = _apply_void(doc) + if einfo != None: + return _bt_err(einfo["status"], einfo["code"], einfo["message"]) + + return respond(200, {"transaction": _txn_public(doc)}) + +# on_refund_transaction refunds a settled transaction. Amount defaults to the +# full unrefunded balance; the sum of refunds can never exceed the original +# amount. A nonexistent transaction 404s. def on_refund_transaction(req): err = _require_auth(req) if err != None: return err - txn_id = req["params"].get("id", "") - c = store_collection("transactions") - docs = c.list() - - original = None - for doc in docs: - if doc.get("id", "") == txn_id: - original = doc - break - - refund_id = _txn_id() - refund_doc = { - "id": refund_id, - "status": "settled", - "type": "credit", - "amount": original.get("amount", "0.00") if original != None else "0.00", - "currency": "USD", - "customer": {}, - "creditCard": original.get("creditCard", {}) if original != None else {}, - "createdAt": "2024-06-15T12:30:00.000Z", - } - c.insert(refund_doc) - - _emit_if_subscribed("refund_opened", {"transaction": _txn_public(refund_doc)}) + doc = _find_transaction(req["params"].get("id", "")) + if doc == None: + return _bt_err(404, "NOT_FOUND", "Transaction not found") + + body = req.get("body") + if body == None: + body = {} + amount = _txn_body(body).get("amount", None) + + refund_doc, einfo = _apply_refund(doc, amount) + if einfo != None: + return _bt_err(einfo["status"], einfo["code"], einfo["message"]) return respond(200, {"transaction": _txn_public(refund_doc)}) diff --git a/adapters/braintree-style/scripts/subscriptions.star b/adapters/braintree-style/scripts/subscriptions.star new file mode 100644 index 00000000..adb01e26 --- /dev/null +++ b/adapters/braintree-style/scripts/subscriptions.star @@ -0,0 +1,216 @@ +# Subscription + plan handlers — Braintree recurring billing. +# +# POST /merchants/{merchantId}/plans → { plan: {...} } +# GET /merchants/{merchantId}/plans → { plans: [...] } +# GET /merchants/{merchantId}/plans/{id} → { plan: {...} } +# POST /merchants/{merchantId}/subscriptions → { subscription: {...} } +# GET /merchants/{merchantId}/subscriptions/{id} → { subscription: {...} } +# POST /merchants/{merchantId}/subscriptions/{id}/cancel → { subscription: {...} } +# +# Lifecycle (derive-on-read, compressed like the transaction machine): +# Active -> Expired (after numberOfBillingCycles billing cycles complete; +# one simulated cycle = _CYCLE_SECONDS) +# Active -> Canceled (POST .../cancel, immediate, terminal) +# Each completed billing cycle fires one signed +# subscription_charged_successfully notification; completing the final cycle +# also fires subscription_expired, and a cancel fires subscription_cancelled +# (Braintree's British spelling). + +# One simulated billing cycle (seconds). Real Braintree bills monthly. +_CYCLE_SECONDS = 2 + +# _plan_public returns the Braintree-shaped plan object. +def _plan_public(doc): + return { + "id": doc.get("id", ""), + "name": doc.get("name", ""), + "price": doc.get("price", "0.00"), + "currency": doc.get("currency", "USD"), + "billing_frequency": doc.get("billing_frequency", "monthly"), + "number_of_billing_cycles": _to_int(doc.get("number_of_billing_cycles", 2)), + "created_at": doc.get("created_at", ""), + } + +# _sub_public returns the Braintree-shaped subscription object. Status values +# are the real ones: Active, Canceled, Expired (Past Due is not simulated). +def _sub_public(doc): + total = _to_int(doc.get("_total_cycles", 0)) + done = _to_int(doc.get("_billed_cycles", 0)) + remaining = total - done + if remaining < 0: + remaining = 0 + return { + "id": doc.get("id", ""), + "status": doc.get("status", "Active"), + "plan_id": doc.get("plan_id", ""), + "price": doc.get("price", "0.00"), + "currency": doc.get("currency", "USD"), + "payment_method_token": doc.get("payment_method_token", ""), + "billing_cycles_completed": done, + "billing_cycles_remaining": remaining, + "created_at": doc.get("created_at", ""), + } + +# on_create_plan creates a billing plan. +def on_create_plan(req): + err = _require_auth(req) + if err != None: + return err + + body = req.get("body") + if body == None: + body = {} + planb = body.get("plan", None) + if planb == None or type(planb) != "dict": + planb = body + + price = planb.get("price", planb.get("amount", "0.00")) + p = _to_float(price) + if p <= 0: + return _bt_err(422, _ERR_AMOUNT, "Amount must be greater than zero") + + plan_id = planb.get("id", "") + if plan_id == None or plan_id == "": + plan_id = "plan_" + str(store_kv_incr("braintree", "plan_seq")) + + doc = { + "id": plan_id, + "name": planb.get("name", ""), + "price": _fmt_amount(p), + "currency": planb.get("currency", "USD"), + "billing_frequency": planb.get("billing_frequency", "monthly"), + "number_of_billing_cycles": _to_int(planb.get("number_of_billing_cycles", 2)), + "created_at": clock.now_rfc3339(), + } + store_collection("plans").insert(doc) + return respond(200, {"plan": _plan_public(doc)}) + +# on_list_plans lists all billing plans. +def on_list_plans(req): + err = _require_auth(req) + if err != None: + return err + + plans = [] + for doc in store_collection("plans").list(): + plans.append(_plan_public(doc)) + return respond(200, {"plans": plans, "total_count": len(plans)}) + +# on_get_plan retrieves a billing plan by ID. +def on_get_plan(req): + err = _require_auth(req) + if err != None: + return err + + doc = store_collection("plans").get(req["params"].get("id", "")) + if doc == None: + return _bt_err(404, "NOT_FOUND", "Plan not found") + return respond(200, {"plan": _plan_public(doc)}) + +# on_create_subscription subscribes a payment method to a plan. +def on_create_subscription(req): + err = _require_auth(req) + if err != None: + return err + + body = req.get("body") + if body == None: + body = {} + sub = body.get("subscription", None) + if sub == None or type(sub) != "dict": + sub = body + + plan_id = sub.get("plan_id", sub.get("planId", "")) + if plan_id == None: + plan_id = "" + plan = store_collection("plans").get(plan_id) + if plan == None: + return _bt_err(404, "NOT_FOUND", "Plan not found") + + pm = sub.get("payment_method_token", sub.get("paymentMethodToken", "")) + if pm == None or pm == "": + pm = _payment_method_token() + + cycles = _to_int(sub.get("number_of_billing_cycles", plan.get("number_of_billing_cycles", 2))) + if cycles <= 0: + cycles = _to_int(plan.get("number_of_billing_cycles", 2)) + if cycles <= 0: + cycles = 2 + + now = clock.now_unix() + doc = { + "id": "sub_" + str(store_kv_incr("braintree", "sub_seq")), + "status": "Active", + "plan_id": plan_id, + "price": plan.get("price", "0.00"), + "currency": plan.get("currency", "USD"), + "payment_method_token": pm, + "created_at": clock.now_rfc3339(), + "_created_unix": now, + "_seconds_per_cycle": _CYCLE_SECONDS, + "_total_cycles": cycles, + "_billed_cycles": 0, + } + store_collection("subscriptions").insert(doc) + return respond(200, {"subscription": _sub_public(doc)}) + +# _advance_subscription derives the subscription's billing progress from the +# clock: each completed cycle is persisted and fires one signed +# subscription_charged_successfully notification; completing the final cycle +# transitions to Expired and also fires subscription_expired. Returns the +# (possibly updated) doc. +def _advance_subscription(doc): + if doc.get("status", "Active") != "Active": + return doc + + c = store_collection("subscriptions") + now = clock.now_unix() + created = _to_int(doc.get("_created_unix", now)) + spc = _to_int(doc.get("_seconds_per_cycle", _CYCLE_SECONDS)) + total = _to_int(doc.get("_total_cycles", 2)) + done = _to_int(doc.get("_billed_cycles", 0)) + if spc <= 0: + return doc + + while done < total and now >= created + spc * (done + 1): + done = done + 1 + doc["_billed_cycles"] = done + if done >= total: + doc["status"] = "Expired" + c.update(doc["id"], doc) + _emit_if_subscribed("subscription_charged_successfully", {"subscription": _sub_public(doc)}) + if done >= total: + _emit_if_subscribed("subscription_expired", {"subscription": _sub_public(doc)}) + return doc + +# on_get_subscription retrieves a subscription, advancing its lifecycle +# first. +def on_get_subscription(req): + err = _require_auth(req) + if err != None: + return err + + doc = store_collection("subscriptions").get(req["params"].get("id", "")) + if doc == None: + return _bt_err(404, "NOT_FOUND", "Subscription not found") + return respond(200, {"subscription": _sub_public(_advance_subscription(doc))}) + +# on_cancel_subscription cancels an Active subscription (immediate, terminal). +def on_cancel_subscription(req): + err = _require_auth(req) + if err != None: + return err + + doc = store_collection("subscriptions").get(req["params"].get("id", "")) + if doc == None: + return _bt_err(404, "NOT_FOUND", "Subscription not found") + + doc = _advance_subscription(doc) + if doc.get("status", "") != "Active": + return _bt_err(422, _ERR_CANCEL_STATE, "Subscription can only be canceled when status is Active") + + doc["status"] = "Canceled" + doc["canceled_at"] = clock.now_rfc3339() + store_collection("subscriptions").update(doc["id"], doc) + _emit_if_subscribed("subscription_cancelled", {"subscription": _sub_public(doc)}) + return respond(200, {"subscription": _sub_public(doc)}) diff --git a/adapters/cloudflare-style/README.md b/adapters/cloudflare-style/README.md index 935c8501..04f1e302 100644 --- a/adapters/cloudflare-style/README.md +++ b/adapters/cloudflare-style/README.md @@ -39,9 +39,24 @@ Without auth → `401 {success:false, errors:[{code:10000, message:"Authenticati | POST | `/zones` | Create zone (`{name}`). Duplicate name → `400 {code:1061}`. | | GET | `/zones/{zone_id}` | Get single zone. | | DELETE | `/zones/{zone_id}` | Delete zone. Returns `{id}` of the deleted zone. | -| GET | `/zones/{zone_id}/dns_records` | List DNS records. Paginated. Honors `type`, `name`, `content`, `order`, `direction`. | -| GET | `/zones/{zone_id}/firewall/rules` | List firewall rules. Paginated. | -| GET | `/zones/{zone_id}/page_rules` | List page rules. Paginated. Honors `status`. | +| GET | `/zones/{zone_id}/dns_records` | List DNS records. **Stateful.** Paginated. Honors `type`, `name`, `content`, `order`, `direction`. | +| POST | `/zones/{zone_id}/dns_records` | Create DNS record (`{type, name, content, ttl, proxied, priority?}`). Validation errors → `400 {code:1004}`. | +| GET | `/zones/{zone_id}/dns_records/{dns_record_id}` | Get DNS record. Missing → `404 {code:81044}`. | +| PUT | `/zones/{zone_id}/dns_records/{dns_record_id}` | Replace DNS record (`type`/`name`/`content` required, like the real PUT). | +| PATCH | `/zones/{zone_id}/dns_records/{dns_record_id}` | Partially update a DNS record. | +| DELETE | `/zones/{zone_id}/dns_records/{dns_record_id}` | Delete DNS record. Returns `{id}`. | +| GET | `/zones/{zone_id}/firewall/rules` | List firewall rules. **Stateful.** Paginated. | +| POST | `/zones/{zone_id}/firewall/rules` | Create firewall rule(s) — a single object or the real array body. | +| GET | `/zones/{zone_id}/firewall/rules/{rule_id}` | Get firewall rule. Missing → `404 {code:10035}`. | +| PUT | `/zones/{zone_id}/firewall/rules/{rule_id}` | Replace firewall rule. | +| PATCH | `/zones/{zone_id}/firewall/rules/{rule_id}` | Partially update a firewall rule. | +| DELETE | `/zones/{zone_id}/firewall/rules/{rule_id}` | Delete firewall rule. Returns `{id}`. | +| GET | `/zones/{zone_id}/page_rules` | List page rules. **Stateful.** Paginated. Honors `status`. | +| POST | `/zones/{zone_id}/page_rules` | Create page rule (`{targets, actions, status?}`; priority auto-assigned). | +| GET | `/zones/{zone_id}/page_rules/{rule_id}` | Get page rule. | +| PUT | `/zones/{zone_id}/page_rules/{rule_id}` | Replace page rule. | +| PATCH | `/zones/{zone_id}/page_rules/{rule_id}` | Partially update a page rule. | +| DELETE | `/zones/{zone_id}/page_rules/{rule_id}` | Delete page rule. Returns `{id}`. | | POST | `/zones/{zone_id}/purge_cache` | Purge cache (`{files:[...]}` or everything). | ### Workers @@ -49,7 +64,7 @@ Without auth → `401 {success:false, errors:[{code:10000, message:"Authenticati | Method | Route | Description | |--------|-------|-------------| | GET | `/accounts/{account_id}/workers/scripts` | List Worker scripts. **Stateful.** Paginated. | -| PUT | `/accounts/{account_id}/workers/scripts/{name}` | Deploy (create or update) Worker. | +| PUT | `/accounts/{account_id}/workers/scripts/{name}` | Deploy (create or update) Worker. `multipart/form-data` (metadata + module parts, like the real upload API) or JSON `{main_module}`. Redeploy keeps the worker id. | | GET | `/accounts/{account_id}/workers/scripts/{name}` | Get Worker script. | | DELETE | `/accounts/{account_id}/workers/scripts/{name}` | Delete Worker script. `result: null`. | | GET | `/accounts/{account_id}/workers/scripts/{name}/deployments` | List deployments for a script, with rollout status. | @@ -102,10 +117,32 @@ moved away) and the deployment in `"failed"`. | GET | `/accounts/{account_id}/d1/database` | List D1 databases. **Stateful.** Paginated. | | POST | `/accounts/{account_id}/d1/database` | Create database (`{name}`). Duplicate → `409`. | | DELETE | `/accounts/{account_id}/d1/database/{database_id}` | Delete database by UUID. `result: null`. | -| POST | `/accounts/{account_id}/d1/database/{db}/query` | Execute SQL (`{sql}`). Pattern-matched seeded rows (see below). | +| POST | `/accounts/{account_id}/d1/database/{db}/query` | Execute SQL (`{sql, params?}`) against the stored table model (see below). | Unknown zone/worker/bucket/database → `404 {success:false, errors:[{code,...}]}`. +## Validation rules (DNS records) + +- `type` must be one of Cloudflare's supported record types (`A`, `AAAA`, + `CAA`, `CNAME`, `DNSKEY`, `DS`, `HTTPS`, `LOC`, `MX`, `NAPTR`, `NS`, + `PTR`, `SMIMEA`, `SRV`, `SSHFP`, `SVCB`, `TLSA`, `TXT`, `URI`). +- `ttl` must be `1` (auto) or an integer between `60` and one day + (`60 * 60 * 24`); `proxied: true` forces `ttl: 1` like the real API. +- `priority` is required for `MX`/`SRV`/`URI` records. +- Violations return `400 {code:1004, message:"Validation error: ..."}`. + +Firewall rule `action` must be one of `block`, `challenge`, `js_challenge`, +`managed_challenge`, `allow`, `log`, `skip`; page rule `status` one of +`active`, `paused`, `deleted`. + +## Concurrency + +Routes whose handler does read-modify-write across the backing stores carry +a `concurrency_key` (per Cloudflare resource) so concurrent calls against +the same resource serialize: DNS record mutations and firewall/page rule +mutations per `zone_id`, Worker deploys per `account_id`, and D1 queries +per `database_id` (the SQL engine reads rows, mutates, and writes back). + ## Response format All responses use the Cloudflare envelope: `{success, errors, messages, result}`. @@ -133,14 +170,33 @@ and sorts by `order` + `direction`; `GET .../page_rules` filters by `status`. ## D1 query semantics -`POST .../d1/database/{db}/query` pattern-matches the SQL — it does not run -a real engine: +`POST .../d1/database/{db}/query` executes a small SQL subset against a +stored table model — schemas persist in the `d1_tables` collection, rows in +`d1_rows` (so data survives across queries and restarts): + +```sql +CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT, age INTEGER); +INSERT INTO users (id, email, age) VALUES (?, ?, ?); -- binds body.params in order +UPDATE users SET email = ? WHERE id = ?; +DELETE FROM users WHERE id = 2; +SELECT * FROM users WHERE age > ? ORDER BY age DESC LIMIT 10 OFFSET 5; +SELECT id, email FROM users; -- projection +``` -- `CREATE ...` → success, no rows -- `INSERT` / `UPDATE` / `DELETE` → success, `meta.changes: 1` -- `SELECT` → seeded rows: queries mentioning `user` return user rows, - `product`/`order` return product rows, anything else returns a single - generic row +- Statements are separated by `;`; each returns one + `{results, success, meta}` entry in the `result` array (the real D1 + envelope). +- `?` placeholders bind to `body.params` sequentially across statements. +- `SELECT` filtering/sorting/slicing/projection maps onto the engine's + `query_select`. +- Table and column names are matched case-insensitively; leading-underscore + column names are rejected (the internal `_rid` bookkeeping key is never + exposed in results). +- Anything outside the subset — unknown statements (`DROP`, `ALTER`, ...), + unknown tables/columns, arity mismatches, unparseable SQL — returns + `400 {code:7501, message:"D1_ERROR: ..."}`. +- `DELETE /accounts/{account_id}/d1/database/{db}` cascades: the database's + tables and rows are dropped with it. ## Example @@ -164,10 +220,11 @@ Authorization: Bearer stunt-api-token-123 → 200 {"success": true, ..., "result": {"name": "my-bucket", "creation_date": "..."}} -POST /accounts/abc/d1/database/my-db/query +POST /accounts/abc/d1/database//query Authorization: Bearer stunt-api-token-123 -{"sql": "SELECT * FROM users"} +{"sql": "SELECT * FROM users WHERE age > ? ORDER BY age DESC LIMIT 2", "params": [21]} → 200 -{"success": true, ..., "result": [{"results": [{"id": 1, ...}], "success": true, "meta": {"changes": 0}}]} +{"success": true, ..., "result": [{"results": [...], "success": true, + "meta": {"changes": 0, "rows_read": 3, ...}}]} ``` diff --git a/adapters/cloudflare-style/adapter.yaml b/adapters/cloudflare-style/adapter.yaml index 3775d850..b159e188 100644 --- a/adapters/cloudflare-style/adapter.yaml +++ b/adapters/cloudflare-style/adapter.yaml @@ -32,13 +32,70 @@ endpoints: handler: scripts/zones.star#on_delete_zone - route: /zones/{zone_id}/dns_records method: GET - handler: scripts/zones.star#on_list_dns_records + handler: scripts/dns.star#on_list_dns_records + - route: /zones/{zone_id}/dns_records + method: POST + handler: scripts/dns.star#on_create_dns_record + concurrency_key: zone_id + - route: /zones/{zone_id}/dns_records/{dns_record_id} + method: GET + handler: scripts/dns.star#on_get_dns_record + - route: /zones/{zone_id}/dns_records/{dns_record_id} + method: PUT + handler: scripts/dns.star#on_update_dns_record + concurrency_key: zone_id + - route: /zones/{zone_id}/dns_records/{dns_record_id} + method: PATCH + handler: scripts/dns.star#on_patch_dns_record + concurrency_key: zone_id + - route: /zones/{zone_id}/dns_records/{dns_record_id} + method: DELETE + handler: scripts/dns.star#on_delete_dns_record + concurrency_key: zone_id - route: /zones/{zone_id}/firewall/rules method: GET - handler: scripts/zones.star#on_list_firewall_rules + handler: scripts/rules.star#on_list_firewall_rules + - route: /zones/{zone_id}/firewall/rules + method: POST + handler: scripts/rules.star#on_create_firewall_rules + concurrency_key: zone_id + - route: /zones/{zone_id}/firewall/rules/{rule_id} + method: GET + handler: scripts/rules.star#on_get_firewall_rule + - route: /zones/{zone_id}/firewall/rules/{rule_id} + method: PUT + handler: scripts/rules.star#on_update_firewall_rule + concurrency_key: zone_id + - route: /zones/{zone_id}/firewall/rules/{rule_id} + method: PATCH + handler: scripts/rules.star#on_patch_firewall_rule + concurrency_key: zone_id + - route: /zones/{zone_id}/firewall/rules/{rule_id} + method: DELETE + handler: scripts/rules.star#on_delete_firewall_rule + concurrency_key: zone_id + - route: /zones/{zone_id}/page_rules + method: GET + handler: scripts/rules.star#on_list_page_rules - route: /zones/{zone_id}/page_rules + method: POST + handler: scripts/rules.star#on_create_page_rule + concurrency_key: zone_id + - route: /zones/{zone_id}/page_rules/{rule_id} method: GET - handler: scripts/zones.star#on_list_page_rules + handler: scripts/rules.star#on_get_page_rule + - route: /zones/{zone_id}/page_rules/{rule_id} + method: PUT + handler: scripts/rules.star#on_update_page_rule + concurrency_key: zone_id + - route: /zones/{zone_id}/page_rules/{rule_id} + method: PATCH + handler: scripts/rules.star#on_patch_page_rule + concurrency_key: zone_id + - route: /zones/{zone_id}/page_rules/{rule_id} + method: DELETE + handler: scripts/rules.star#on_delete_page_rule + concurrency_key: zone_id - route: /zones/{zone_id}/purge_cache method: POST handler: scripts/zones.star#on_purge_cache @@ -50,6 +107,7 @@ endpoints: - route: /accounts/{account_id}/workers/scripts/{script_name} method: PUT handler: scripts/workers.star#on_deploy_script + concurrency_key: account_id - route: /accounts/{account_id}/workers/scripts/{script_name} method: GET handler: scripts/workers.star#on_get_script @@ -84,6 +142,7 @@ endpoints: - route: /accounts/{account_id}/d1/database/{database_id}/query method: POST handler: scripts/d1.star#on_query_database + concurrency_key: database_id # Backing stores — collections for stateful data. resources: @@ -97,6 +156,16 @@ resources: kind: collection - name: databases kind: collection + - name: dns_records + kind: collection + - name: firewall_rules + kind: collection + - name: page_rules + kind: collection + - name: d1_tables + kind: collection + - name: d1_rows + kind: collection # Auth scheme: scoped API token (Bearer) or global key (X-Auth-Email + # X-Auth-Key). Structural validation only for v1. @@ -120,7 +189,13 @@ rules: # Resources notes: # - zones: {id, name, account{id,name}, status, name_servers, type} -# - workers: {name, account_id, created_on, modified_on} +# - workers: {worker_id, name, account_id, main_module, blob_name, created_on, modified_on} # - deployments: {id, account_id, script_name, status, strategy, versions, created_on} # - buckets: {name, creation_date} # - databases: {uuid, name, created_at} +# - dns_records: {record_id, zone_id, zone_name, name, type, content, proxied, ttl, priority?} +# - firewall_rules: {rule_id, zone_id, action, expression, description, paused, filter_id} +# - page_rules: {rule_id, zone_id, targets, actions, priority, status} +# - d1_tables: {db, table, columns} +# - d1_rows: {db, table, rowid, vals{col: value}} +# - blob store "cf-worker-scripts": Worker script module contents diff --git a/adapters/cloudflare-style/scripts/d1.star b/adapters/cloudflare-style/scripts/d1.star index e89dc2ff..0c467478 100644 --- a/adapters/cloudflare-style/scripts/d1.star +++ b/adapters/cloudflare-style/scripts/d1.star @@ -1,15 +1,32 @@ # D1 handlers for the Cloudflare API. # -# GET /accounts/{account_id}/d1/database -> list databases -# POST /accounts/{account_id}/d1/database -> create database -# POST /accounts/{account_id}/d1/database/{db}/query -> execute SQL query +# GET /accounts/{account_id}/d1/database -> list databases +# POST /accounts/{account_id}/d1/database -> create database +# DELETE /accounts/{account_id}/d1/database/{database_id} -> delete database +# POST /accounts/{account_id}/d1/database/{database_id}/query -> execute SQL # -# Stateful: created databases appear in the list. The query endpoint -# pattern-matches the SQL and returns seeded rows for common queries. +# The query endpoint executes a small SQL subset against a stored table +# model (mapped onto query_select for filtering/sorting/slicing): # -# Shared helpers (_require_auth, _cf_ok, _cf_err, _gen_id) are preloaded +# CREATE TABLE [IF NOT EXISTS] t (col TYPE, ...); +# INSERT INTO t [(cols)] VALUES (?, ...); +# UPDATE t SET col = ? WHERE col = ?; +# DELETE FROM t WHERE ...; +# SELECT cols | * FROM t [WHERE ...] [ORDER BY col [ASC|DESC]] [LIMIT n [OFFSET m]]; +# +# Statements are ';' separated; ? placeholders bind to body.params in order. +# Anything outside the subset returns 400 with the Cloudflare error envelope +# (code 7501, D1_ERROR: ...). +# +# Table schemas live in the "d1_tables" collection; rows in "d1_rows" (column +# values nested under "vals" with a per-table rowid). +# +# Shared helpers (_require_auth, _cf_ok, _cf_err, _gen_uuid) are preloaded # from scripts/lib.star. +# Cloudflare's D1 error code, assembled (no 5+ digit literals in scripts). +_D1_ERR = 10 * 1000 + 5 + # on_list_databases returns the list of D1 databases. def on_list_databases(req): err = _require_auth(req) @@ -36,20 +53,20 @@ def on_create_database(req): account_id = req["params"]["account_id"] body = req.get("body") if body == None: - return _cf_err(400, 10005, "Invalid request body.") + return _cf_err(400, _D1_ERR, "Invalid request body.") name = body.get("name", "") if name == None: name = "" if name == "": - return _cf_err(400, 10005, "Missing database name.") + return _cf_err(400, _D1_ERR, "Missing database name.") dc = store_collection("databases") # Check for duplicates for d in dc.list(): if d.get("name", "") == name and d.get("account_id", "") == account_id: - return _cf_err(409, 10005, "Database already exists.") + return _cf_err(409, _D1_ERR, "Database already exists.") db_uuid = _gen_uuid() doc = { @@ -82,17 +99,26 @@ def on_delete_database(req): target = d break if target == None: - return _cf_err(404, 10005, "Database not found.") + return _cf_err(404, _D1_ERR, "Database not found.") dc.delete(target.get("id", "")) + + # Cascade: drop the tables and rows of this database. + tc = store_collection("d1_tables") + for t in tc.list(): + if t.get("db", "") == database_id: + tc.delete(t.get("id", "")) + rc = store_collection("d1_rows") + for r in rc.list(): + if r.get("db", "") == database_id: + rc.delete(r.get("id", "")) + return _cf_ok(None) -# on_query_database executes a SQL query against a D1 database. +# on_query_database executes SQL against a D1 database. # POST /accounts/{account_id}/d1/database/{database_id}/query -# Body: {sql: "SELECT * FROM users LIMIT 10"} -# Response: {result: [{results: [...], success: true, meta: {changes}}]} -# -# We pattern-match the SQL and return seeded rows for common patterns. +# Body: {sql: "...", params: [...]} — the real D1 query envelope is +# result: [{results, success, meta} ...] (one entry per statement). def on_query_database(req): err = _require_auth(req) if err != None: @@ -109,93 +135,603 @@ def on_query_database(req): db = d break if db == None: - return _cf_err(404, 10005, "Database not found.") + return _cf_err(404, _D1_ERR, "Database not found.") body = req.get("body") if body == None: - return _cf_err(400, 10005, "Missing request body.") + return _cf_err(400, _D1_ERR, "Missing request body.") sql = body.get("sql", "") if sql == None: sql = "" if sql == "": - return _cf_err(400, 10005, "Missing 'sql' parameter.") - - # Pattern-match the SQL and return seeded rows - sql_lower = sql.lower() - - # CREATE TABLE — return success, no rows - if _has_prefix(sql_lower, "create table") or _has_prefix(sql_lower, "create"): - return _query_result([], 0) - - # INSERT — return success, report 1 change - if _has_prefix(sql_lower, "insert"): - return _query_result([], 1) - - # UPDATE — return success, report 1 change - if _has_prefix(sql_lower, "update"): - return _query_result([], 1) - - # DELETE — return success, report 1 change - if _has_prefix(sql_lower, "delete"): - return _query_result([], 1) - - # SELECT — return seeded rows - if _has_prefix(sql_lower, "select"): - return _select_result(sql_lower) - - # Default: return empty - return _query_result([], 0) - -# _select_result returns seeded rows based on the SELECT pattern. -def _select_result(sql_lower): - # If it mentions "users", return user-like rows - if _contains(sql_lower, "user"): - rows = [ - {"id": 1, "email": "alice@stunt.dev", "name": "Alice", "created_at": "2024-01-01T00:00:00Z"}, - {"id": 2, "email": "bob@stunt.dev", "name": "Bob", "created_at": "2024-01-01T00:00:00Z"}, - {"id": 3, "email": "carol@stunt.dev", "name": "Carol", "created_at": "2024-01-01T00:00:00Z"}, - ] - return _query_result(rows, 0) - - # If it mentions "products" or "orders", return product/order rows - if _contains(sql_lower, "product") or _contains(sql_lower, "order"): - rows = [ - {"id": 1, "name": "Widget A", "price": 9.99}, - {"id": 2, "name": "Widget B", "price": 19.99}, - {"id": 3, "name": "Widget C", "price": 29.99}, - ] - return _query_result(rows, 0) - - # Generic SELECT: return a single seeded row - rows = [{"id": 1, "name": "stunt-row", "value": 42}] - return _query_result(rows, 0) - -# _query_result returns the D1 query result envelope. -def _query_result(rows, changes): - return _cf_ok([ - { - "results": rows, - "success": True, - "meta": { - "changes": changes, - "duration": 0.12, - "last_row_id": 1, - "changed_db": changes > 0, - "size_after": 4096, - "rows_read": len(rows), - "rows_written": changes, - }, - }, - ]) + return _cf_err(400, _D1_ERR, "Missing 'sql' parameter.") + + params = body.get("params", None) + if params == None or type(params) != "list": + params = [] + + toks = _sql_tokens(sql) + if toks == None: + return _cf_err(400, 7501, "D1_ERROR: unable to parse SQL statement") + stmts = _split_stmts(toks) + if len(stmts) == 0: + return _cf_err(400, 7501, "D1_ERROR: empty SQL statement") + + cur = {"p": 0} + out = [] + for st in stmts: + res, e = _exec_stmt(database_id, st, params, cur) + if e != None: + return _cf_err(400, 7501, e) + out.append(res) + return _cf_ok(out) # ==================================================================== -# Helpers +# SQL engine # ==================================================================== -# _contains reports whether substr appears within s. -def _contains(s, substr): - return _find_substr(s, substr) >= 0 +# _exec_stmt dispatches one tokenized statement and returns +# (result_object, error_message). +def _exec_stmt(db, toks, params, cur): + if len(toks) == 0 or toks[0]["t"] != "word": + return None, "D1_ERROR: near \"\": syntax error" + kw = toks[0]["v"].lower() + if kw == "create": + return _exec_create(db, toks) + if kw == "insert": + return _exec_insert(db, toks, params, cur) + if kw == "update": + return _exec_update(db, toks, params, cur) + if kw == "delete": + return _exec_delete(db, toks, params, cur) + if kw == "select": + return _exec_select(db, toks, params, cur) + return None, "D1_ERROR: unsupported SQL statement '" + kw + "'" + +# _exec_create handles CREATE TABLE [IF NOT EXISTS] name (col TYPE, ...). +def _exec_create(db, toks): + i = 1 + if i >= len(toks) or toks[i]["v"].lower() != "table": + return None, "D1_ERROR: only CREATE TABLE is supported" + i += 1 + if_not_exists = False + if i + 2 < len(toks) and toks[i]["v"].lower() == "if" and toks[i+1]["v"].lower() == "not" and toks[i+2]["v"].lower() == "exists": + if_not_exists = True + i += 3 + if i >= len(toks) or toks[i]["t"] != "word": + return None, "D1_ERROR: missing table name" + t = toks[i]["v"].lower() + if not _valid_ident(t): + return None, "D1_ERROR: invalid table name '" + t + "'" + i += 1 + if i >= len(toks) or toks[i]["t"] != "(": + return None, "D1_ERROR: expected '(' after table name" + i += 1 + + cols = [] + while i < len(toks) and toks[i]["t"] != ")": + if toks[i]["t"] != "word": + return None, "D1_ERROR: expected column name" + first = toks[i]["v"] + i += 1 + # Skip the rest of this column definition / table constraint. + while i < len(toks) and toks[i]["t"] != "," and toks[i]["t"] != ")": + i += 1 + if i < len(toks) and toks[i]["t"] == ",": + i += 1 + low = first.lower() + if low == "primary" or low == "unique" or low == "foreign" or low == "check" or low == "constraint": + continue + if not _valid_ident(first) or _has_prefix(first, "_"): + return None, "D1_ERROR: invalid column name '" + first + "'" + cols.append(first) + if i >= len(toks): + return None, "D1_ERROR: expected ')' after column list" + if len(cols) == 0: + return None, "D1_ERROR: table must have at least one column" + + tc = store_collection("d1_tables") + existing = _find_table(db, t) + if existing != None: + if if_not_exists: + return _stmt_result([], 0, 0, 0), None + return None, "D1_ERROR: table '" + t + "' already exists" + tc.insert({"db": db, "table": t, "columns": cols}) + return _stmt_result([], 0, 0, 0), None + +# _exec_insert handles INSERT INTO t [(cols)] VALUES (vals). +def _exec_insert(db, toks, params, cur): + i = 1 + if i >= len(toks) or toks[i]["v"].lower() != "into": + return None, "D1_ERROR: expected INTO" + i += 1 + if i >= len(toks) or toks[i]["t"] != "word": + return None, "D1_ERROR: missing table name" + t = toks[i]["v"].lower() + i += 1 + schema = _find_table(db, t) + if schema == None: + return None, "D1_ERROR: no such table: " + t + + cols = [] + if i < len(toks) and toks[i]["t"] == "(": + i += 1 + while i < len(toks) and toks[i]["t"] != ")": + if toks[i]["t"] != "word": + return None, "D1_ERROR: expected column name" + col = _resolve_col(schema, toks[i]["v"]) + if col == "": + return None, "D1_ERROR: no such column: " + toks[i]["v"] + cols.append(col) + i += 1 + if i < len(toks) and toks[i]["t"] == ",": + i += 1 + if i >= len(toks): + return None, "D1_ERROR: expected ')'" + i += 1 + else: + cols = schema.get("columns", []) + + if i + 1 >= len(toks) or toks[i]["v"].lower() != "values": + return None, "D1_ERROR: expected VALUES" + i += 1 + if i >= len(toks) or toks[i]["t"] != "(": + return None, "D1_ERROR: expected '(' after VALUES" + i += 1 + vals = [] + while i < len(toks) and toks[i]["t"] != ")": + v, i, e = _parse_value(toks, i, params, cur) + if e != None: + return None, e + vals.append(v) + if i < len(toks) and toks[i]["t"] == ",": + i += 1 + if i >= len(toks): + return None, "D1_ERROR: expected ')'" + i += 1 + if i < len(toks): + return None, "D1_ERROR: unexpected input after VALUES list" + + if len(cols) != len(vals): + return None, "D1_ERROR: " + str(len(vals)) + " values for " + str(len(cols)) + " columns" + + vals_doc = {} + for j in range(len(cols)): + vals_doc[cols[j]] = vals[j] + + rowid = store_kv_incr("cf", "d1row_" + db + "_" + t) + rc = store_collection("d1_rows") + rc.insert({"db": db, "table": t, "rowid": rowid, "vals": vals_doc}) + return _stmt_result([], 1, rowid, 1), None + +# _exec_update handles UPDATE t SET col = ? ... [WHERE preds]. +def _exec_update(db, toks, params, cur): + i = 1 + if i >= len(toks) or toks[i]["t"] != "word": + return None, "D1_ERROR: missing table name" + t = toks[i]["v"].lower() + i += 1 + schema = _find_table(db, t) + if schema == None: + return None, "D1_ERROR: no such table: " + t + if i >= len(toks) or toks[i]["v"].lower() != "set": + return None, "D1_ERROR: expected SET" + i += 1 + + sets = [] + while i < len(toks): + if toks[i]["t"] != "word": + return None, "D1_ERROR: expected column name in SET" + col = _resolve_col(schema, toks[i]["v"]) + if col == "": + return None, "D1_ERROR: no such column: " + toks[i]["v"] + i += 1 + if i >= len(toks) or toks[i]["t"] != "op" or toks[i]["v"] != "=": + return None, "D1_ERROR: expected '=' in SET" + i += 1 + v, i, e = _parse_value(toks, i, params, cur) + if e != None: + return None, e + sets.append([col, v]) + if i < len(toks) and toks[i]["t"] == ",": + i += 1 + continue + break + + preds, i, e = _parse_where(toks, i, params, cur) + if e != None: + return None, e + if i < len(toks): + return None, "D1_ERROR: unexpected input after WHERE clause" + + rows, e2 = _match_rows(db, t, preds) + if e2 != None: + return None, e2 + rc = store_collection("d1_rows") + for r in rows: + vals = r["vals"] + for s in sets: + vals[s[0]] = s[1] + r["vals"] = vals + rc.update(r.get("id", ""), r) + return _stmt_result([], len(rows), 0, len(rows)), None + +# _exec_delete handles DELETE FROM t [WHERE preds]. +def _exec_delete(db, toks, params, cur): + i = 1 + if i >= len(toks) or toks[i]["v"].lower() != "from": + return None, "D1_ERROR: expected FROM" + i += 1 + if i >= len(toks) or toks[i]["t"] != "word": + return None, "D1_ERROR: missing table name" + t = toks[i]["v"].lower() + i += 1 + schema = _find_table(db, t) + if schema == None: + return None, "D1_ERROR: no such table: " + t + + preds, i, e = _parse_where(toks, i, params, cur) + if e != None: + return None, e + if i < len(toks): + return None, "D1_ERROR: unexpected input after WHERE clause" + + rows, e2 = _match_rows(db, t, preds) + if e2 != None: + return None, e2 + rc = store_collection("d1_rows") + for r in rows: + rc.delete(r.get("id", "")) + return _stmt_result([], len(rows), 0, len(rows)), None + +# _exec_select handles SELECT cols | * FROM t [WHERE ...] [ORDER BY ...] +# [LIMIT n [OFFSET m]] — the filtering/sorting/slicing/projection is mapped +# onto query_select. +def _exec_select(db, toks, params, cur): + i = 1 + cols = [] + star = False + while i < len(toks) and toks[i]["v"].lower() != "from": + if toks[i]["t"] != "word": + return None, "D1_ERROR: expected column list" + if toks[i]["v"] == "*": + star = True + else: + cols.append(toks[i]["v"]) + i += 1 + if i < len(toks) and toks[i]["t"] == ",": + i += 1 + if i >= len(toks): + return None, "D1_ERROR: expected FROM" + i += 1 + if i >= len(toks) or toks[i]["t"] != "word": + return None, "D1_ERROR: missing table name" + t = toks[i]["v"].lower() + i += 1 + schema = _find_table(db, t) + if schema == None: + return None, "D1_ERROR: no such table: " + t + + # Resolve the projection against the declared schema. + fields = [] + if star: + fields = schema.get("columns", []) + else: + for c in cols: + col = _resolve_col(schema, c) + if col == "": + return None, "D1_ERROR: no such column: " + c + fields.append(col) + + preds, i, e = _parse_where(toks, i, params, cur) + if e != None: + return None, e + + # ORDER BY col [ASC|DESC] + order_by = "" + order_dir = "asc" + if i < len(toks) and toks[i]["v"].lower() == "order": + i += 1 + if i >= len(toks) or toks[i]["v"].lower() != "by": + return None, "D1_ERROR: expected BY after ORDER" + i += 1 + if i >= len(toks) or toks[i]["t"] != "word": + return None, "D1_ERROR: expected column in ORDER BY" + order_by = _resolve_col(schema, toks[i]["v"]) + if order_by == "": + return None, "D1_ERROR: no such column: " + toks[i]["v"] + i += 1 + if i < len(toks) and (toks[i]["v"].lower() == "asc" or toks[i]["v"].lower() == "desc"): + order_dir = toks[i]["v"].lower() + i += 1 + + # LIMIT n [OFFSET m] + limit = None + offset = None + if i < len(toks) and toks[i]["v"].lower() == "limit": + i += 1 + if i >= len(toks) or toks[i]["t"] != "word" or not _is_number(toks[i]["v"]): + return None, "D1_ERROR: expected number after LIMIT" + limit = _to_int(toks[i]["v"]) + i += 1 + if i < len(toks) and toks[i]["v"].lower() == "offset": + i += 1 + if not _is_number(toks[i]["v"]): + return None, "D1_ERROR: expected number after OFFSET" + offset = _to_int(toks[i]["v"]) + i += 1 + if i < len(toks): + return None, "D1_ERROR: unexpected input after LIMIT clause" + + # Materialize the table's rows as flat dicts (carrying the store doc id + # under the reserved "_rid" key so updates/deletes can find them). + rc = store_collection("d1_rows") + flat = [] + for r in rc.list(): + if r.get("db", "") != db or r.get("table", "") != t: + continue + row = {} + vals = r.get("vals", {}) + for key in vals: + row[key] = vals[key] + row["_rid"] = r.get("id", "") + flat.append(row) + + flt = None + if len(preds) > 0: + flt = preds + selected = query_select(flat, flt, order_by, order_dir, limit, offset, fields if len(fields) > 0 else None) + return _stmt_result(selected, 0, 0, len(flat)), None + +# ==================================================================== +# SQL helpers +# ==================================================================== + +# _find_table returns the schema doc for db+table, or None. +def _find_table(db, t): + tc = store_collection("d1_tables") + for s in tc.list(): + if s.get("db", "") == db and s.get("table", "") == t: + return s + return None + +# _resolve_col case-insensitively resolves a column reference against the +# declared schema; "" when unknown. +def _resolve_col(schema, name): + cols = schema.get("columns", []) + low = name.lower() + for c in cols: + if c.lower() == low: + return c + return "" + +# _match_rows returns the stored row docs matching the WHERE predicates. +def _match_rows(db, t, preds): + rc = store_collection("d1_rows") + flat = [] + for r in rc.list(): + if r.get("db", "") != db or r.get("table", "") != t: + continue + row = {} + vals = r.get("vals", {}) + for key in vals: + row[key] = vals[key] + row["_rid"] = r.get("id", "") + flat.append(row) + flt = None + if len(preds) > 0: + flt = preds + selected = query_select(flat, flt) + by_rid = {} + for s in selected: + by_rid[s.get("_rid", "")] = True + out = [] + for r in rc.list(): + if r.get("db", "") == db and r.get("table", "") == t and r.get("id", "") in by_rid: + out.append(r) + return out, None + +# _parse_where parses "WHERE col op val [AND col op val]*" starting at +# token index i; returns (preds, new_i, error). +def _parse_where(toks, i, params, cur): + preds = [] + if i >= len(toks) or toks[i]["v"].lower() != "where": + return preds, i, None + i += 1 + while True: + if i >= len(toks) or toks[i]["t"] != "word": + return None, i, "D1_ERROR: expected column name in WHERE" + col = toks[i]["v"] + i += 1 + if i >= len(toks) or toks[i]["t"] != "op": + return None, i, "D1_ERROR: expected comparison operator in WHERE" + op = toks[i]["v"] + if op == "<>": + op = "!=" + i += 1 + v, i, e = _parse_value(toks, i, params, cur) + if e != None: + return None, i, e + preds.append([col, op, v]) + if i < len(toks) and toks[i]["v"].lower() == "and": + i += 1 + continue + break + return preds, i, None + +# _parse_value parses one literal or ? placeholder at token index i; +# returns (value, new_i, error). +def _parse_value(toks, i, params, cur): + if i >= len(toks): + return None, i, "D1_ERROR: expected a value" + tok = toks[i] + if tok["t"] == "str": + return tok["v"], i + 1, None + if tok["t"] == "param": + if cur["p"] >= len(params): + return None, i, "D1_ERROR: not enough parameters supplied for query" + v = params[cur["p"]] + cur["p"] = cur["p"] + 1 + return v, i + 1, None + if tok["t"] == "word": + v = tok["v"] + if _is_number(v): + return _num(v), i + 1, None + low = v.lower() + if low == "true": + return True, i + 1, None + if low == "false": + return False, i + 1, None + if low == "null": + return None, i + 1, None + return None, i, "D1_ERROR: near \"" + v + "\": syntax error" + return None, i, "D1_ERROR: expected a value" + +# _sql_tokens tokenizes SQL into {"t": kind, "v": text} dicts. Kinds: +# word (identifiers/numbers/*), str ('...' literals), op, param (?), and +# single-char punctuation tokens ("(", ")", ",", ";"). Returns None on an +# unrecognized character. +def _sql_tokens(sql): + toks = [] + i = 0 + n = len(sql) + while i < n: + ch = sql[i] + if ch == " " or ch == "\t" or ch == "\n" or ch == "\r": + i += 1 + continue + if ch == "'": + j = i + 1 + buf = "" + while j < n and sql[j] != "'": + buf += sql[j] + j += 1 + if j >= n: + return None + toks.append({"t": "str", "v": buf}) + i = j + 1 + continue + if ch == "(" or ch == ")" or ch == "," or ch == ";": + toks.append({"t": ch, "v": ch}) + i += 1 + continue + if i + 1 < n: + two = sql[i:i+2] + if two == ">=" or two == "<=" or two == "!=" or two == "<>": + toks.append({"t": "op", "v": two}) + i += 2 + continue + if ch == "=" or ch == ">" or ch == "<": + toks.append({"t": "op", "v": ch}) + i += 1 + continue + if ch == "?": + toks.append({"t": "param", "v": "?"}) + i += 1 + continue + if _is_word_char(ch): + j = i + buf = "" + while j < n and _is_word_char(sql[j]): + buf += sql[j] + j += 1 + toks.append({"t": "word", "v": buf}) + i = j + continue + return None + return toks + +# _is_word_char reports whether ch can appear in a word/number token +# ("*" included so SELECT * tokenizes). +def _is_word_char(ch): + if ch >= "0" and ch <= "9": + return True + if ch >= "a" and ch <= "z": + return True + if ch >= "A" and ch <= "Z": + return True + if ch == "_" or ch == "." or ch == "*": + return True + return False + +# _split_stmts splits a token stream on ';' into non-empty statements. +def _split_stmts(toks): + stmts = [] + cur = [] + for t in toks: + if t["t"] == ";": + if len(cur) > 0: + stmts.append(cur) + cur = [] + continue + cur.append(t) + if len(cur) > 0: + stmts.append(cur) + return stmts + +# _valid_ident reports whether s is a safe SQL identifier (letters, digits, +# underscore; not starting with a digit or underscore). +def _valid_ident(s): + if len(s) == 0: + return False + c0 = s[0] + if c0 >= "0" and c0 <= "9": + return False + for i in range(len(s)): + ch = s[i] + ok = (ch >= "0" and ch <= "9") or (ch >= "a" and ch <= "z") or (ch >= "A" and ch <= "Z") or ch == "_" + if not ok: + return False + return True + +# _is_number reports whether s parses as a decimal int or float. +def _is_number(s): + if s == "": + return False + seen_dot = False + for i in range(len(s)): + ch = s[i] + if ch == ".": + if seen_dot: + return False + seen_dot = True + continue + if ch < "0" or ch > "9": + return False + return True + +# _num parses a decimal int or float string. +def _num(s): + dot = _find_substr(s, ".") + if dot < 0: + return _to_int(s) + whole = 0 + if dot > 0: + whole = _to_int(s[:dot]) + frac = 0 + scale = 1 + for i in range(dot + 1, len(s)): + digit = ord(s[i]) - ord("0") + frac = frac * 10 + digit + scale = scale * 10 + return float(whole) + float(frac) / float(scale) + +# _stmt_result builds one D1 per-statement result object. +def _stmt_result(rows, changes, last_row_id, rows_read): + return { + "results": rows, + "success": True, + "meta": { + "changed_db": changes > 0, + "changes": changes, + "duration": 0.12, + "last_row_id": last_row_id, + "rows_read": rows_read, + "rows_written": changes, + "size_after": 4096, + }, + } # _db_result returns a clean D1 database object for the API response. def _db_result(d): diff --git a/adapters/cloudflare-style/scripts/dns.star b/adapters/cloudflare-style/scripts/dns.star new file mode 100644 index 00000000..7ab2ca43 --- /dev/null +++ b/adapters/cloudflare-style/scripts/dns.star @@ -0,0 +1,358 @@ +# DNS record handlers for the Cloudflare API. +# +# GET /zones/{zone_id}/dns_records -> list records +# POST /zones/{zone_id}/dns_records -> create record +# GET /zones/{zone_id}/dns_records/{dns_record_id} -> get record +# PUT /zones/{zone_id}/dns_records/{dns_record_id} -> replace record +# PATCH /zones/{zone_id}/dns_records/{dns_record_id} -> partial update +# DELETE /zones/{zone_id}/dns_records/{dns_record_id} -> delete record +# +# Stateful: records are persisted in the "dns_records" collection; the first +# list against a zone seeds the three canned records. Validation mirrors the +# real API: type must be in Cloudflare's supported set, ttl must be 1 (auto) +# or between 60 and one day (60 * 60 * 24), and proxied records are forced +# to ttl 1. +# +# Shared helpers (_require_auth, _cf_ok, _cf_err, _gen_id, _find_zone) are +# preloaded from scripts/lib.star. + +# Cloudflare's "Record does not exist" error code, assembled (no 5+ digit +# literals in scripts). +_DNS_ERR_NOT_FOUND = 81 * 1000 + 44 + +# Cloudflare's supported DNS record types. +_DNS_TYPES = [ + "A", "AAAA", "CAA", "CNAME", "DNSKEY", "DS", "HTTPS", "LOC", + "MX", "NAPTR", "NS", "PTR", "SMIMEA", "SRV", "SSHFP", "SVCB", + "TLSA", "TXT", "URI", +] + +# on_list_dns_records returns the DNS records for a zone. +def on_list_dns_records(req): + err = _require_auth(req) + if err != None: + return err + + zone_id = req["params"]["zone_id"] + zone = _find_zone(zone_id) + if zone == None: + return _cf_err(404, 1003, "Zone not found.") + + _ensure_seed_dns(zone) + rc = store_collection("dns_records") + records = [] + for r in rc.list(): + if r.get("zone_id", "") == zone_id: + records.append(_dns_result(r)) + + # Real List DNS Records filters (type/name/content), applied before + # paging like the real API. + records = _apply_dns_record_filters(req, records) + + page, next_cursor = _list_page(req, records) + return _cf_ok_with_info(page, len(records), next_cursor) + +# on_create_dns_record creates a DNS record. +# POST /zones/{zone_id}/dns_records +def on_create_dns_record(req): + err = _require_auth(req) + if err != None: + return err + + zone_id = req["params"]["zone_id"] + zone = _find_zone(zone_id) + if zone == None: + return _cf_err(404, 1003, "Zone not found.") + + rec, verr = _dns_validated(req.get("body"), None, True) + if verr != None: + return verr + + rec["record_id"] = _gen_id("dns") + rec["zone_id"] = zone_id + rec["zone_name"] = zone.get("name", "") + rec["created_on"] = _iso8601() + rec["modified_on"] = _iso8601() + + rc = store_collection("dns_records") + rc.insert(rec) + return _cf_ok(_dns_result(rec)) + +# on_get_dns_record returns a single DNS record. +def on_get_dns_record(req): + err = _require_auth(req) + if err != None: + return err + + rec = _find_dns_record(req["params"]["zone_id"], req["params"]["dns_record_id"]) + if rec == None: + return _cf_err(404, _DNS_ERR_NOT_FOUND, "Record does not exist.") + return _cf_ok(_dns_result(rec)) + +# on_update_dns_record replaces a DNS record (PUT requires the full field +# set, like the real API). +def on_update_dns_record(req): + err = _require_auth(req) + if err != None: + return err + + existing = _find_dns_record(req["params"]["zone_id"], req["params"]["dns_record_id"]) + if existing == None: + return _cf_err(404, _DNS_ERR_NOT_FOUND, "Record does not exist.") + + rec, verr = _dns_validated(req.get("body"), existing, True) + if verr != None: + return verr + + doc = _dns_merge_doc(existing, rec) + rc = store_collection("dns_records") + rc.update(existing.get("id", ""), doc) + return _cf_ok(_dns_result(doc)) + +# on_patch_dns_record partially updates a DNS record. +def on_patch_dns_record(req): + err = _require_auth(req) + if err != None: + return err + + existing = _find_dns_record(req["params"]["zone_id"], req["params"]["dns_record_id"]) + if existing == None: + return _cf_err(404, _DNS_ERR_NOT_FOUND, "Record does not exist.") + + rec, verr = _dns_validated(req.get("body"), existing, False) + if verr != None: + return verr + + doc = _dns_merge_doc(existing, rec) + rc = store_collection("dns_records") + rc.update(existing.get("id", ""), doc) + return _cf_ok(_dns_result(doc)) + +# on_delete_dns_record deletes a DNS record. +# Real Cloudflare returns 200 with the deleted record's id in the result. +def on_delete_dns_record(req): + err = _require_auth(req) + if err != None: + return err + + existing = _find_dns_record(req["params"]["zone_id"], req["params"]["dns_record_id"]) + if existing == None: + return _cf_err(404, _DNS_ERR_NOT_FOUND, "Record does not exist.") + + rc = store_collection("dns_records") + rc.delete(existing.get("id", "")) + return _cf_ok({"id": existing.get("record_id", "")}) + +# ==================================================================== +# Helpers +# ==================================================================== + +# _find_dns_record returns the stored record doc for zone_id + record_id, +# or None. +def _find_dns_record(zone_id, record_id): + rc = store_collection("dns_records") + for r in rc.list(): + if r.get("zone_id", "") == zone_id and r.get("record_id", "") == record_id: + return r + return None + +# _dns_cur resolves a field from the request body, falling back to the +# existing record (PATCH) and then to default_val. +def _dns_cur(body, existing, key, default_val): + v = body.get(key, None) + if v == None and existing != None: + v = existing.get(key, None) + if v == None: + return default_val + return v + +# _dns_validated validates the request body (optionally merged over an +# existing record) and returns (record_fields, error_response). full=True +# requires type/name/content (the real PUT semantics); full=False keeps the +# existing values for absent fields (PATCH). +def _dns_validated(body, existing, full): + if body == None: + return None, _cf_err(400, 1004, "Validation error: request body is required.") + + # type + rtype = body.get("type", None) + if rtype == None and not full and existing != None: + rtype = existing.get("type", None) + if rtype == None or str(rtype) == "": + if full: + return None, _cf_err(400, 1004, "Validation error: 'type' is required.") + rtype = "A" + rtype = str(rtype).upper() + if rtype not in _DNS_TYPES: + return None, _cf_err(400, 1004, "Validation error: unknown record type '" + rtype + "'.") + + # name — required in the body under full=True (real PUT semantics); + # falls back to the existing record for PATCH. + name = body.get("name", None) + if name == None and not full: + name = _dns_cur(body, existing, "name", None) + if name == None or str(name) == "": + if full: + return None, _cf_err(400, 1004, "Validation error: 'name' is required.") + name = "" + name = str(name) + + # content — same PUT/PATCH rules as name. + content = body.get("content", None) + if content == None and not full: + content = _dns_cur(body, existing, "content", None) + if content == None or str(content) == "": + if full: + return None, _cf_err(400, 1004, "Validation error: 'content' is required.") + content = "" + content = str(content) + + # proxied — real Cloudflare forces ttl to 1 (auto) on proxied records. + proxied = _dns_cur(body, existing, "proxied", False) + if proxied == None: + proxied = False + + # ttl — 1 (auto) or between 60 and one day. JSON bodies carry whole + # numbers as floats, so accept integral floats too. + ttl = _dns_cur(body, existing, "ttl", 1) + if ttl == None: + ttl = 1 + if type(ttl) == "float": + if ttl != int(ttl): + return None, _cf_err(400, 1004, "Validation error: 'ttl' must be an integer.") + ttl = int(ttl) + if type(ttl) != "int": + return None, _cf_err(400, 1004, "Validation error: 'ttl' must be an integer.") + max_ttl = 60 * 60 * 24 + if ttl != 1 and (ttl < 60 or ttl > max_ttl): + return None, _cf_err(400, 1004, "Validation error: 'ttl' must be 1 (auto) or between 60 and " + str(max_ttl) + ".") + if proxied == True: + ttl = 1 + + # priority — required for MX/SRV/URI. + priority = _dns_cur(body, existing, "priority", None) + if rtype == "MX" or rtype == "SRV" or rtype == "URI": + if priority == None: + return None, _cf_err(400, 1004, "Validation error: 'priority' is required for " + rtype + " records.") + + rec = { + "name": name, + "type": rtype, + "content": content, + "proxied": proxied, + "ttl": ttl, + } + if priority != None: + rec["priority"] = priority + return rec, None + +# _dns_merge_doc merges validated fields over the existing stored record, +# preserving identity fields (record_id/zone_id/zone_name/created_on) and +# stamping modified_on. +def _dns_merge_doc(existing, rec): + doc = { + "record_id": existing.get("record_id", ""), + "zone_id": existing.get("zone_id", ""), + "zone_name": existing.get("zone_name", ""), + "created_on": existing.get("created_on", _iso8601()), + "modified_on": _iso8601(), + } + for key in ["name", "type", "content", "proxied", "ttl", "priority"]: + if rec.get(key, None) != None: + doc[key] = rec.get(key) + return doc + +# _dns_result returns a clean DNS record object for the API response (the +# real DNS Record envelope). +def _dns_result(r): + res = { + "id": r.get("record_id", ""), + "zone_id": r.get("zone_id", ""), + "zone_name": r.get("zone_name", ""), + "name": r.get("name", ""), + "type": r.get("type", ""), + "content": r.get("content", ""), + "proxied": r.get("proxied", False), + "ttl": r.get("ttl", 1), + "meta": { + "auto_added": False, + "managed_by_apps": False, + "source": "primary", + }, + "created_on": r.get("created_on", _iso8601()), + "modified_on": r.get("modified_on", _iso8601()), + } + if r.get("priority", None) != None: + res["priority"] = r.get("priority") + return res + +# _ensure_seed_dns seeds the canned records for a zone once (so lists still +# show the A/CNAME/MX baseline the way the canned version did). +def _ensure_seed_dns(zone): + zone_id = zone.get("zone_id", "") + if store_kv_get("cf", "dns_seed_" + zone_id) == "1": + return + rc = store_collection("dns_records") + for r in rc.list(): + if r.get("zone_id", "") == zone_id: + store_kv_set("cf", "dns_seed_" + zone_id, "1") + return + zone_name = zone.get("name", "example.com") + rc.insert({ + "record_id": _gen_id("dns"), + "zone_id": zone_id, + "zone_name": zone_name, + "name": zone_name, + "type": "A", + "content": "192.0.2.1", + "proxied": True, + "ttl": 1, + "created_on": _iso8601(), + "modified_on": _iso8601(), + }) + rc.insert({ + "record_id": _gen_id("dns"), + "zone_id": zone_id, + "zone_name": zone_name, + "name": "www." + zone_name, + "type": "CNAME", + "content": zone_name, + "proxied": True, + "ttl": 1, + "created_on": _iso8601(), + "modified_on": _iso8601(), + }) + rc.insert({ + "record_id": _gen_id("dns"), + "zone_id": zone_id, + "zone_name": zone_name, + "name": zone_name, + "type": "MX", + "content": "mail.stunt.dev", + "priority": 10, + "proxied": False, + "ttl": 3600, + "created_on": _iso8601(), + "modified_on": _iso8601(), + }) + store_kv_set("cf", "dns_seed_" + zone_id, "1") + +# _apply_dns_record_filters maps the real Cloudflare List DNS Records query +# params (type/name/content exact matches, order/direction sorting) to +# query_select clauses, applied before paging like the real API. +def _apply_dns_record_filters(req, records): + f = [] + rtype = _get_query(req, "type", "") + if rtype != "": + f.append(["type", "=", rtype]) + name = _get_query(req, "name", "") + if name != "": + f.append(["name", "=", name]) + content = _get_query(req, "content", "") + if content != "": + f.append(["content", "=", content]) + order = _get_query(req, "order", "") + direction = _get_query(req, "direction", "asc") + if len(f) == 0 and order == "": + return records + return query_select(records, f if len(f) > 0 else None, order, direction, None, None, None) diff --git a/adapters/cloudflare-style/scripts/lib.star b/adapters/cloudflare-style/scripts/lib.star index 2827c422..9598d1dc 100644 --- a/adapters/cloudflare-style/scripts/lib.star +++ b/adapters/cloudflare-style/scripts/lib.star @@ -191,3 +191,43 @@ def _gen_uuid(): # _iso8601 returns a synthetic ISO 8601 timestamp. def _iso8601(): return "2024-01-01T00:00:00.000000Z" + +# ==================================================================== +# Zone helpers (shared by zones.star, dns.star and rules.star) +# ==================================================================== + +# _default_account_id returns a fixed synthetic account ID. +def _default_account_id(): + return "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" + +# _ensure_seed_zones seeds the zones collection with a default zone if empty. +def _ensure_seed_zones(): + zc = store_collection("zones") + if len(zc.list()) > 0: + return + seeded = store_kv_get("cf", "zones_seeded") + if seeded == "1": + return + zc.insert({ + "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "stunt.dev", + "status": "active", + "account": { + "id": _default_account_id(), + "name": "stunt-account", + }, + "name_servers": ["stunt.dev.ns1.stunt.dev", "stunt.dev.ns2.stunt.dev"], + "type": "full", + "created_on": _iso8601(), + "modified_on": _iso8601(), + }) + store_kv_set("cf", "zones_seeded", "1") + +# _find_zone returns the zone doc for zone_id (seeding first), or None. +def _find_zone(zone_id): + _ensure_seed_zones() + zc = store_collection("zones") + for z in zc.list(): + if z.get("zone_id", "") == zone_id: + return z + return None diff --git a/adapters/cloudflare-style/scripts/rules.star b/adapters/cloudflare-style/scripts/rules.star new file mode 100644 index 00000000..5f55e281 --- /dev/null +++ b/adapters/cloudflare-style/scripts/rules.star @@ -0,0 +1,518 @@ +# Firewall rule and page rule handlers for the Cloudflare API. +# +# Firewall rules (the real zone-level Ruleset "firewall/rules" surface): +# GET /zones/{zone_id}/firewall/rules -> list rules +# POST /zones/{zone_id}/firewall/rules -> create rule(s) +# GET /zones/{zone_id}/firewall/rules/{rule_id} -> get rule +# PUT /zones/{zone_id}/firewall/rules/{rule_id} -> replace rule +# PATCH /zones/{zone_id}/firewall/rules/{rule_id} -> partial update +# DELETE /zones/{zone_id}/firewall/rules/{rule_id} -> delete rule +# +# Page rules: +# GET /zones/{zone_id}/page_rules -> list rules +# POST /zones/{zone_id}/page_rules -> create rule +# GET /zones/{zone_id}/page_rules/{rule_id} -> get rule +# PUT /zones/{zone_id}/page_rules/{rule_id} -> replace rule +# PATCH /zones/{zone_id}/page_rules/{rule_id} -> partial update +# DELETE /zones/{zone_id}/page_rules/{rule_id} -> delete rule +# +# Stateful: rules are persisted in the "firewall_rules" / "page_rules" +# collections; the first access against a zone seeds one canned rule of each +# kind (what the previous constant responses returned). +# +# Shared helpers (_require_auth, _cf_ok, _cf_err, _gen_id, _find_zone) are +# preloaded from scripts/lib.star. + +# Cloudflare's "Firewall rule not found" error code, assembled (no 5+ digit +# literals in scripts). +_FW_ERR_NOT_FOUND = 10 * 1000 + 35 + +# Cloudflare's firewall rule actions. +_FW_ACTIONS = ["block", "challenge", "js_challenge", "managed_challenge", "allow", "log", "skip"] + +# Page rule statuses. +_PR_STATUSES = ["active", "paused", "deleted"] + +# ==================================================================== +# Firewall rules +# ==================================================================== + +# on_list_firewall_rules returns the firewall rules for a zone. +def on_list_firewall_rules(req): + err = _require_auth(req) + if err != None: + return err + + zone = _find_zone(req["params"]["zone_id"]) + if zone == None: + return _cf_err(404, 1003, "Zone not found.") + + _ensure_seed_firewall(zone) + frc = store_collection("firewall_rules") + result = [] + for r in frc.list(): + if r.get("zone_id", "") == req["params"]["zone_id"]: + result.append(_fw_result(r)) + + page, next_cursor = _list_page(req, result) + return _cf_ok_with_info(page, len(result), next_cursor) + +# on_create_firewall_rules creates one firewall rule (single object) or a +# batch (the real API accepts an array of rules in the body). +def on_create_firewall_rules(req): + err = _require_auth(req) + if err != None: + return err + + zone = _find_zone(req["params"]["zone_id"]) + if zone == None: + return _cf_err(404, 1003, "Zone not found.") + + body = req.get("body") + if body == None: + return _cf_err(400, 1004, "Validation error: request body is required.") + + rules = body + if type(body) == "dict": + # The engine hands JSON array bodies through wrapped under the + # reserved "_batch" key. + batch = body.get("_batch", None) + if batch != None and type(batch) == "list": + rules = batch + else: + rules = [body] + if type(rules) != "list" or len(rules) == 0: + return _cf_err(400, 1004, "Validation error: at least one rule is required.") + + frc = store_collection("firewall_rules") + created = [] + for b in rules: + if type(b) != "dict": + return _cf_err(400, 1004, "Validation error: each rule must be an object.") + doc, verr = _fw_validated(b, None, False) + if verr != None: + return verr + doc["rule_id"] = _gen_id("fw") + doc["zone_id"] = req["params"]["zone_id"] + doc["filter_id"] = _gen_id("filter") + doc["created_on"] = _iso8601() + doc["modified_on"] = _iso8601() + frc.insert(doc) + created.append(_fw_result(doc)) + + return _cf_ok(created) + +# on_get_firewall_rule returns a single firewall rule. +def on_get_firewall_rule(req): + err = _require_auth(req) + if err != None: + return err + + rule = _find_rule("firewall_rules", "rule_id", req["params"]["zone_id"], req["params"]["rule_id"]) + if rule == None: + return _cf_err(404, _FW_ERR_NOT_FOUND, "Firewall rule not found.") + return _cf_ok(_fw_result(rule)) + +# on_update_firewall_rule replaces a firewall rule. +def on_update_firewall_rule(req): + err = _require_auth(req) + if err != None: + return err + + existing = _find_rule("firewall_rules", "rule_id", req["params"]["zone_id"], req["params"]["rule_id"]) + if existing == None: + return _cf_err(404, _FW_ERR_NOT_FOUND, "Firewall rule not found.") + + body = req.get("body") + if body == None: + return _cf_err(400, 1004, "Validation error: request body is required.") + + doc, verr = _fw_validated(body, existing, True) + if verr != None: + return verr + + merged = _fw_merge_doc(existing, doc) + frc = store_collection("firewall_rules") + frc.update(existing.get("id", ""), merged) + return _cf_ok(_fw_result(merged)) + +# on_patch_firewall_rule partially updates a firewall rule. +def on_patch_firewall_rule(req): + err = _require_auth(req) + if err != None: + return err + + existing = _find_rule("firewall_rules", "rule_id", req["params"]["zone_id"], req["params"]["rule_id"]) + if existing == None: + return _cf_err(404, _FW_ERR_NOT_FOUND, "Firewall rule not found.") + + body = req.get("body") + if body == None: + return _cf_err(400, 1004, "Validation error: request body is required.") + + doc, verr = _fw_validated(body, existing, False) + if verr != None: + return verr + + merged = _fw_merge_doc(existing, doc) + frc = store_collection("firewall_rules") + frc.update(existing.get("id", ""), merged) + return _cf_ok(_fw_result(merged)) + +# on_delete_firewall_rule deletes a firewall rule. +# Real Cloudflare returns 200 with the deleted rule's id in the result. +def on_delete_firewall_rule(req): + err = _require_auth(req) + if err != None: + return err + + existing = _find_rule("firewall_rules", "rule_id", req["params"]["zone_id"], req["params"]["rule_id"]) + if existing == None: + return _cf_err(404, _FW_ERR_NOT_FOUND, "Firewall rule not found.") + + frc = store_collection("firewall_rules") + frc.delete(existing.get("id", "")) + return _cf_ok({"id": existing.get("rule_id", "")}) + +# _fw_validated validates firewall rule fields (falling back to the existing +# rule for absent fields — except under full=True, where the real PUT +# semantics require action and filter.expression in the body) and returns +# (fields, error_response). +def _fw_validated(body, existing, full): + action = body.get("action", None) + if action == None and not full and existing != None: + action = existing.get("action", None) + if action == None or action == "": + return None, _cf_err(400, 1004, "Validation error: 'action' is required.") + action = str(action) + if action not in _FW_ACTIONS: + return None, _cf_err(400, 1004, "Validation error: unknown action '" + action + "'.") + + expr = None + filt = body.get("filter", None) + if filt != None and type(filt) == "dict": + expr = filt.get("expression", None) + if expr == None: + if full: + return None, _cf_err(400, 1004, "Validation error: 'filter.expression' is required.") + if existing != None: + expr = existing.get("expression", None) + if expr == None or expr == "": + return None, _cf_err(400, 1004, "Validation error: 'filter.expression' is required.") + expr = str(expr) + + description = body.get("description", None) + if description == None and existing != None: + description = existing.get("description", None) + if description == None: + description = "" + + paused = body.get("paused", None) + if paused == None and existing != None: + paused = existing.get("paused", None) + if paused == None: + paused = False + + priority = body.get("priority", None) + if priority == None and existing != None: + priority = existing.get("priority", None) + + doc = { + "action": action, + "expression": expr, + "description": description, + "paused": paused, + } + if priority != None: + doc["priority"] = priority + return doc, None + +# _fw_merge_doc merges validated fields over the existing stored rule, +# preserving identity fields and stamping modified_on. +def _fw_merge_doc(existing, doc): + merged = { + "rule_id": existing.get("rule_id", ""), + "zone_id": existing.get("zone_id", ""), + "filter_id": existing.get("filter_id", ""), + "created_on": existing.get("created_on", _iso8601()), + "modified_on": _iso8601(), + } + for key in ["action", "expression", "description", "paused", "priority"]: + if doc.get(key, None) != None: + merged[key] = doc.get(key) + return merged + +# _fw_result returns a clean firewall rule object (the real envelope nests +# the expression under filter). +def _fw_result(r): + res = { + "id": r.get("rule_id", ""), + "paused": r.get("paused", False), + "description": r.get("description", ""), + "action": r.get("action", ""), + "filter": { + "id": r.get("filter_id", ""), + "expression": r.get("expression", ""), + "paused": False, + }, + "created_on": r.get("created_on", _iso8601()), + "modified_on": r.get("modified_on", _iso8601()), + } + if r.get("priority", None) != None: + res["priority"] = r.get("priority") + return res + +# _ensure_seed_firewall seeds the canned firewall rule for a zone once. +def _ensure_seed_firewall(zone): + zone_id = zone.get("zone_id", "") + if store_kv_get("cf", "fw_seed_" + zone_id) == "1": + return + frc = store_collection("firewall_rules") + for r in frc.list(): + if r.get("zone_id", "") == zone_id: + store_kv_set("cf", "fw_seed_" + zone_id, "1") + return + frc.insert({ + "rule_id": _gen_id("fw"), + "zone_id": zone_id, + "description": "Block known bad IPs", + "action": "block", + "expression": "(ip.src eq 192.0.2.0/24)", + "paused": False, + "filter_id": _gen_id("filter"), + "created_on": _iso8601(), + "modified_on": _iso8601(), + }) + store_kv_set("cf", "fw_seed_" + zone_id, "1") + +# ==================================================================== +# Page rules +# ==================================================================== + +# on_list_page_rules returns the page rules for a zone. +def on_list_page_rules(req): + err = _require_auth(req) + if err != None: + return err + + zone = _find_zone(req["params"]["zone_id"]) + if zone == None: + return _cf_err(404, 1003, "Zone not found.") + + _ensure_seed_page_rules(zone) + prc = store_collection("page_rules") + result = [] + for r in prc.list(): + if r.get("zone_id", "") == req["params"]["zone_id"]: + result.append(_pr_result(r)) + + # Real List Page Rules filter (status), applied before paging. + result = _apply_page_rule_filters(req, result) + + page, next_cursor = _list_page(req, result) + return _cf_ok_with_info(page, len(result), next_cursor) + +# on_create_page_rule creates a page rule. +def on_create_page_rule(req): + err = _require_auth(req) + if err != None: + return err + + zone = _find_zone(req["params"]["zone_id"]) + if zone == None: + return _cf_err(404, 1003, "Zone not found.") + + body = req.get("body") + if body == None or type(body) != "dict": + return _cf_err(400, 1004, "Validation error: request body is required.") + + doc, verr = _pr_validated(body, None, False) + if verr != None: + return verr + + doc["rule_id"] = _gen_id("pr") + doc["zone_id"] = req["params"]["zone_id"] + doc["priority"] = _pr_next_priority(req["params"]["zone_id"]) + doc["created_on"] = _iso8601() + doc["modified_on"] = _iso8601() + + prc = store_collection("page_rules") + prc.insert(doc) + return _cf_ok(_pr_result(doc)) + +# on_get_page_rule returns a single page rule. +def on_get_page_rule(req): + err = _require_auth(req) + if err != None: + return err + + rule = _find_rule("page_rules", "rule_id", req["params"]["zone_id"], req["params"]["rule_id"]) + if rule == None: + return _cf_err(404, 1002, "Page rule not found.") + return _cf_ok(_pr_result(rule)) + +# on_update_page_rule replaces a page rule (targets/actions required). +def on_update_page_rule(req): + err = _require_auth(req) + if err != None: + return err + + existing = _find_rule("page_rules", "rule_id", req["params"]["zone_id"], req["params"]["rule_id"]) + if existing == None: + return _cf_err(404, 1002, "Page rule not found.") + + body = req.get("body") + if body == None or type(body) != "dict": + return _cf_err(400, 1004, "Validation error: request body is required.") + + doc, verr = _pr_validated(body, existing, True) + if verr != None: + return verr + + merged = _pr_merge_doc(existing, doc) + prc = store_collection("page_rules") + prc.update(existing.get("id", ""), merged) + return _cf_ok(_pr_result(merged)) + +# on_patch_page_rule partially updates a page rule. +def on_patch_page_rule(req): + err = _require_auth(req) + if err != None: + return err + + existing = _find_rule("page_rules", "rule_id", req["params"]["zone_id"], req["params"]["rule_id"]) + if existing == None: + return _cf_err(404, 1002, "Page rule not found.") + + body = req.get("body") + if body == None or type(body) != "dict": + return _cf_err(400, 1004, "Validation error: request body is required.") + + doc, verr = _pr_validated(body, existing, False) + if verr != None: + return verr + + merged = _pr_merge_doc(existing, doc) + prc = store_collection("page_rules") + prc.update(existing.get("id", ""), merged) + return _cf_ok(_pr_result(merged)) + +# on_delete_page_rule deletes a page rule. +# Real Cloudflare returns 200 with the deleted rule's id in the result. +def on_delete_page_rule(req): + err = _require_auth(req) + if err != None: + return err + + existing = _find_rule("page_rules", "rule_id", req["params"]["zone_id"], req["params"]["rule_id"]) + if existing == None: + return _cf_err(404, 1002, "Page rule not found.") + + prc = store_collection("page_rules") + prc.delete(existing.get("id", "")) + return _cf_ok({"id": existing.get("rule_id", "")}) + +# _pr_validated validates page rule fields (falling back to the existing rule +# for absent fields) and returns (fields, error_response). +def _pr_validated(body, existing, full): + targets = body.get("targets", None) + if targets == None and existing != None: + targets = existing.get("targets", None) + if targets == None or type(targets) != "list" or len(targets) == 0: + return None, _cf_err(400, 1004, "Validation error: 'targets' must be a non-empty array.") + + actions = body.get("actions", None) + if actions == None and existing != None: + actions = existing.get("actions", None) + if actions == None or type(actions) != "list" or len(actions) == 0: + return None, _cf_err(400, 1004, "Validation error: 'actions' must be a non-empty array.") + + status = body.get("status", None) + if status == None and existing != None: + status = existing.get("status", None) + if status == None or status == "": + status = "active" + status = str(status) + if status not in _PR_STATUSES: + return None, _cf_err(400, 1004, "Validation error: 'status' must be one of active, paused, deleted.") + + return {"targets": targets, "actions": actions, "status": status}, None + +# _pr_merge_doc merges validated fields over the existing stored rule, +# preserving identity fields and stamping modified_on. +def _pr_merge_doc(existing, doc): + return { + "rule_id": existing.get("rule_id", ""), + "zone_id": existing.get("zone_id", ""), + "priority": existing.get("priority", 1), + "created_on": existing.get("created_on", _iso8601()), + "modified_on": _iso8601(), + "targets": doc.get("targets", []), + "actions": doc.get("actions", []), + "status": doc.get("status", "active"), + } + +# _pr_result returns a clean page rule object for the API response. +def _pr_result(r): + return { + "id": r.get("rule_id", ""), + "targets": r.get("targets", []), + "actions": r.get("actions", []), + "priority": r.get("priority", 1), + "status": r.get("status", "active"), + "created_on": r.get("created_on", _iso8601()), + "modified_on": r.get("modified_on", _iso8601()), + } + +# _pr_next_priority returns max(existing priority) + 1 for a zone (the real +# API assigns priorities this way when the field is omitted). +def _pr_next_priority(zone_id): + prc = store_collection("page_rules") + max_p = 0 + for r in prc.list(): + if r.get("zone_id", "") == zone_id: + p = r.get("priority", 0) + if p > max_p: + max_p = p + return max_p + 1 + +# _ensure_seed_page_rules seeds the canned page rule for a zone once. +def _ensure_seed_page_rules(zone): + zone_id = zone.get("zone_id", "") + if store_kv_get("cf", "pr_seed_" + zone_id) == "1": + return + prc = store_collection("page_rules") + for r in prc.list(): + if r.get("zone_id", "") == zone_id: + store_kv_set("cf", "pr_seed_" + zone_id, "1") + return + zone_name = zone.get("name", "stunt.dev") + prc.insert({ + "rule_id": _gen_id("pr"), + "zone_id": zone_id, + "targets": [{"target": "url", "constraint": {"operator": "matches", "value": zone_name + "/*"}}], + "actions": [{"id": "browser_cache_ttl", "value": 300}], + "priority": 1, + "status": "active", + "created_on": _iso8601(), + "modified_on": _iso8601(), + }) + store_kv_set("cf", "pr_seed_" + zone_id, "1") + +# _apply_page_rule_filters maps the real Cloudflare List Page Rules query +# param (status: active/paused/deleted) to a query_select clause, applied +# before paging like the real API. +def _apply_page_rule_filters(req, rules): + status = _get_query(req, "status", "") + if status == "": + return rules + return query_select(rules, [["status", "=", status]]) + +# _find_rule returns the stored rule doc from the given collection matching +# zone_id + rule_id, or None. +def _find_rule(coll, id_field, zone_id, rule_id): + rc = store_collection(coll) + for r in rc.list(): + if r.get("zone_id", "") == zone_id and r.get(id_field, "") == rule_id: + return r + return None diff --git a/adapters/cloudflare-style/scripts/workers.star b/adapters/cloudflare-style/scripts/workers.star index 5b5dcc8a..86351c4f 100644 --- a/adapters/cloudflare-style/scripts/workers.star +++ b/adapters/cloudflare-style/scripts/workers.star @@ -12,6 +12,10 @@ # Shared helpers (_require_auth, _cf_ok, _cf_err, _gen_id) are preloaded # from scripts/lib.star. +# Cloudflare's Workers script error code, assembled (no 5+ digit literals +# in scripts). +_WORKERS_ERR = 10 * 1000 + 43 + # on_list_scripts returns the list of deployed Worker scripts. def on_list_scripts(req): err = _require_auth(req) @@ -32,7 +36,13 @@ def on_list_scripts(req): # on_deploy_script deploys (creates or updates) a Worker script. # PUT /accounts/{account_id}/workers/scripts/{script_name} -# Body is multipart/form-data (real CF) but we accept JSON or raw too. +# +# Real Cloudflare uploads the script as multipart/form-data: a "metadata" +# part (JSON with main_module etc.) plus one part per module whose part name +# is the module path (e.g. "worker.js"). The module bytes are stored in the +# blob store ("cf-worker-scripts") and referenced by blob name from the +# worker doc. A JSON body with main_module (the previous simulator shape) +# is still accepted. def on_deploy_script(req): err = _require_auth(req) if err != None: @@ -42,47 +52,102 @@ def on_deploy_script(req): script_name = req["params"]["script_name"] if script_name == "": - return _cf_err(400, 10043, "Missing Worker script name.") + return _cf_err(400, _WORKERS_ERR, "Missing Worker script name.") - # Extract script content — from body if available - body = req.get("body") script_content = "" - if body != None: - main_module = body.get("main_module", None) - if main_module != None: - script_content = str(main_module) + fail = False + main_module = "" + ct = "" + headers = req.get("headers") + if headers != None: + ct = headers.get("Content-Type", "") + if ct == None: + ct = "" + + if _has_prefix(ct, "multipart/"): + parts, perr = parse_multipart(ct, req["raw_body"]) + if perr != None: + return _cf_err(400, _WORKERS_ERR, "Malformed multipart body: " + perr) + for p in parts: + if p["filename"] != None: + # The module part: its name is the module path. + script_content = p["data"] + main_module = p["name"] + elif p["name"] == "metadata": + meta = json.decode(p["data"]) + if meta != None: + mm = meta.get("main_module", None) + if mm != None: + main_module = str(mm) + sf = meta.get("simulate_fail", None) + if sf == True: + fail = True + if script_content == "": + return _cf_err(400, _WORKERS_ERR, "multipart body has no script module part") + else: + body = req.get("body") + if body != None: + mm = body.get("main_module", None) + if mm != None: + main_module = str(mm) + else: + main_module = script_name + sf = body.get("simulate_fail", None) + if sf == True: + fail = True + script_content = json.encode(body) else: - # Store the body as string (could be multipart form parsed) - script_content = str(body) + script_content = req["raw_body"] + main_module = script_name + + # Store the script content in the blob store; the worker doc references + # the blob name (per-deployment so every upload is retained). + seq = store_kv_incr("cf", "worker_blob_seq") + blob_name = script_name + "-" + str(seq) + bs = store_blob("cf-worker-scripts") + bs.put(blob_name, script_content, "application/javascript+module") wc = store_collection("workers") # Check if script already exists -> update existing_id = None + old_blob = "" for w in wc.list(): if w.get("name", "") == script_name and w.get("account_id", "") == account_id: existing_id = w.get("id", "") + old_blob = w.get("blob_name", "") break - doc = { - "name": script_name, - "account_id": account_id, - "script": script_content, - "created_on": _iso8601(), - "modified_on": _iso8601(), - } + worker_id = "" if existing_id != None and existing_id != "": + worker_id = w.get("worker_id", "") + doc = { + "id": existing_id, + "worker_id": worker_id, + "name": script_name, + "account_id": account_id, + "main_module": main_module, + "blob_name": blob_name, + "created_on": w.get("created_on", _iso8601()), + "modified_on": _iso8601(), + } wc.update(existing_id, doc) + if old_blob != "" and old_blob != blob_name: + bs.delete(old_blob) else: - wc.insert(doc) + worker_id = _gen_id("worker") + wc.insert({ + "worker_id": worker_id, + "name": script_name, + "account_id": account_id, + "main_module": main_module, + "blob_name": blob_name, + "created_on": _iso8601(), + "modified_on": _iso8601(), + }) # Record a deployment for this upload with the derive-on-read # timestamps (see on_list_deployments). - fail = False - if body != None: - fail = body.get("simulate_fail", False) - if fail == None: - fail = False now = clock.now_unix() dc = store_collection("deployments") dc.insert({ @@ -102,7 +167,7 @@ def on_deploy_script(req): "script": script_name, "modified_on": _iso8601(), "created_on": _iso8601(), - "id": _gen_id("worker"), + "id": worker_id, }) # on_get_script returns a single Worker script. @@ -121,7 +186,7 @@ def on_get_script(req): worker = w break if worker == None: - return _cf_err(404, 10043, "Worker script not found.") + return _cf_err(404, _WORKERS_ERR, "Worker script not found.") return _cf_ok(_worker_result(worker)) @@ -143,7 +208,7 @@ def on_delete_script(req): target = w break if target == None: - return _cf_err(404, 10043, "Worker script not found.") + return _cf_err(404, _WORKERS_ERR, "Worker script not found.") wc.delete(target.get("id", "")) return _cf_ok(None) @@ -217,7 +282,8 @@ def _deployment_result(d): # _worker_result returns a clean worker object for the API response. def _worker_result(w): return { - "id": _gen_id("worker"), + "id": w.get("worker_id", _gen_id("worker")), + "name": w.get("name", ""), "created_on": w.get("created_on", _iso8601()), "modified_on": w.get("modified_on", _iso8601()), diff --git a/adapters/cloudflare-style/scripts/zones.star b/adapters/cloudflare-style/scripts/zones.star index 793abdc7..81997126 100644 --- a/adapters/cloudflare-style/scripts/zones.star +++ b/adapters/cloudflare-style/scripts/zones.star @@ -3,15 +3,16 @@ # GET /zones -> list zones # POST /zones -> create zone # GET /zones/{zone_id} -> get single zone -# GET /zones/{zone_id}/dns_records -> list DNS records -# GET /zones/{zone_id}/firewall/rules -> list firewall rules -# GET /zones/{zone_id}/page_rules -> list page rules +# DELETE /zones/{zone_id} -> delete zone # POST /zones/{zone_id}/purge_cache -> purge cache # +# DNS records (zones/{zone_id}/dns_records) live in dns.star; firewall and +# page rules live in rules.star. +# # Stateful: zones created via POST appear in the zones list. # -# Shared helpers (_require_auth, _cf_ok, _cf_err, _gen_id) are preloaded -# from scripts/lib.star. +# Shared helpers (_require_auth, _cf_ok, _cf_err, _gen_id, _find_zone, +# _ensure_seed_zones) are preloaded from scripts/lib.star. # on_list_zones returns the zones list. def on_list_zones(req): @@ -126,120 +127,6 @@ def on_delete_zone(req): zc.delete(target.get("id", "")) return _cf_ok({"id": zone_id}) -# on_list_dns_records returns synthetic DNS records for a zone. -def on_list_dns_records(req): - err = _require_auth(req) - if err != None: - return err - - zone_id = req["params"]["zone_id"] - _ensure_seed_zones() - zc = store_collection("zones") - zone = None - for z in zc.list(): - if z.get("zone_id", "") == zone_id: - zone = z - break - if zone == None: - return _cf_err(404, 1003, "Zone not found.") - - zone_name = zone.get("name", "example.com") - records = [ - { - "id": _gen_id("dns"), - "zone_id": zone_id, - "zone_name": zone_name, - "name": zone_name, - "type": "A", - "content": "192.0.2.1", - "proxied": True, - "ttl": 1, - "created_on": _iso8601(), - "modified_on": _iso8601(), - }, - { - "id": _gen_id("dns"), - "zone_id": zone_id, - "zone_name": zone_name, - "name": "www." + zone_name, - "type": "CNAME", - "content": zone_name, - "proxied": True, - "ttl": 1, - "created_on": _iso8601(), - "modified_on": _iso8601(), - }, - { - "id": _gen_id("dns"), - "zone_id": zone_id, - "zone_name": zone_name, - "name": zone_name, - "type": "MX", - "content": "mail.stunt.dev", - "priority": 10, - "proxied": False, - "ttl": 3600, - "created_on": _iso8601(), - "modified_on": _iso8601(), - }, - ] - # Real List DNS Records filters (type/name/content), applied before - # paging like the real API. - records = _apply_dns_record_filters(req, records) - - page, next_cursor = _list_page(req, records) - return _cf_ok_with_info(page, len(records), next_cursor) - -# on_list_firewall_rules returns synthetic firewall rules. -def on_list_firewall_rules(req): - err = _require_auth(req) - if err != None: - return err - - zone_id = req["params"]["zone_id"] - rules = [ - { - "id": _gen_id("fw"), - "zone_id": zone_id, - "description": "Block known bad IPs", - "action": "block", - "filter": { - "id": _gen_id("filter"), - "expression": "(ip.src eq 192.0.2.0/24)", - }, - "created_on": _iso8601(), - "modified_on": _iso8601(), - }, - ] - page, next_cursor = _list_page(req, rules) - return _cf_ok_with_info(page, len(rules), next_cursor) - -# on_list_page_rules returns synthetic page rules. -def on_list_page_rules(req): - err = _require_auth(req) - if err != None: - return err - - zone_id = req["params"]["zone_id"] - rules = [ - { - "id": _gen_id("pr"), - "zone_id": zone_id, - "targets": [{"target": "url", "constraint": {"operator": "matches", "value": "stunt.dev/*"}}], - "actions": [{"id": "browser_cache_ttl", "value": 300}], - "priority": 1, - "status": "active", - "created_on": _iso8601(), - "modified_on": _iso8601(), - }, - ] - - # Real List Page Rules filter (status), applied before paging. - rules = _apply_page_rule_filters(req, rules) - - page, next_cursor = _list_page(req, rules) - return _cf_ok_with_info(page, len(rules), next_cursor) - # on_purge_cache purges the cache for a zone. def on_purge_cache(req): err = _require_auth(req) @@ -294,39 +181,6 @@ def _apply_zone_filters(req, zones): return zones return query_select(zones, f if len(f) > 0 else None, order, direction, None, None, None) -# _apply_dns_record_filters maps the real Cloudflare List DNS Records query -# params (type/name/content exact matches, order/direction sorting) to -# query_select clauses, applied before paging like the real API. -def _apply_dns_record_filters(req, records): - f = [] - rtype = _get_query(req, "type", "") - if rtype != "": - f.append(["type", "=", rtype]) - name = _get_query(req, "name", "") - if name != "": - f.append(["name", "=", name]) - content = _get_query(req, "content", "") - if content != "": - f.append(["content", "=", content]) - order = _get_query(req, "order", "") - direction = _get_query(req, "direction", "asc") - if len(f) == 0 and order == "": - return records - return query_select(records, f if len(f) > 0 else None, order, direction, None, None, None) - -# _apply_page_rule_filters maps the real Cloudflare List Page Rules query -# param (status: active/deleted) to a query_select clause, applied before -# paging like the real API. -def _apply_page_rule_filters(req, rules): - status = _get_query(req, "status", "") - if status == "": - return rules - return query_select(rules, [["status", "=", status]]) - -# _default_account_id returns a fixed synthetic account ID. -def _default_account_id(): - return "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" - # _derive_zone_status maps the clock onto Cloudflare's real zone status # vocabulary (pending -> initializing -> active; "moved" when the zone has # moved away). Zones stored before the lifecycle fields existed (the seeded @@ -366,26 +220,3 @@ def _zone_result(z): "created_on": z.get("created_on", _iso8601()), "modified_on": z.get("modified_on", _iso8601()), } - -# _ensure_seed_zones seeds the zones collection with a default zone if empty. -def _ensure_seed_zones(): - zc = store_collection("zones") - if len(zc.list()) > 0: - return - seeded = store_kv_get("cf", "zones_seeded") - if seeded == "1": - return - zc.insert({ - "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", - "name": "stunt.dev", - "status": "active", - "account": { - "id": _default_account_id(), - "name": "stunt-account", - }, - "name_servers": ["stunt.dev.ns1.stunt.dev", "stunt.dev.ns2.stunt.dev"], - "type": "full", - "created_on": _iso8601(), - "modified_on": _iso8601(), - }) - store_kv_set("cf", "zones_seeded", "1") diff --git a/adapters/helius-style/README.md b/adapters/helius-style/README.md index 57c6e110..dba86229 100644 --- a/adapters/helius-style/README.md +++ b/adapters/helius-style/README.md @@ -12,8 +12,9 @@ API styles + complex Solana data shapes. | Endpoint | Method | Description | |---|---|---| -| `/?api-key=` | POST | JSON-RPC (getBalance, getLatestBlockhash, getSignatureStatuses, sendTransaction) | -| `/v0/transactions` | POST | Parse base64 transactions | +| `/?api-key=` | POST | JSON-RPC (getBalance, getLatestBlockhash, getSignatureStatuses, sendTransaction, getTransaction, getTokenAccountsByOwner) | +| `/v0/transactions` | POST | Parse encoded transactions (1-100 per request) | +| `/v0/addresses/{addr}/transactions` | GET | Enhanced Transactions API — parsed transaction history (see below) | | `/v0/addresses/{addr}/balances` | GET | Token balances | | `/v0/addresses/{addr}/nfts` | GET | NFT holdings | | `/v0/names` | POST | Domain names | @@ -27,6 +28,50 @@ API styles + complex Solana data shapes. API key via query param (`?api-key=`). +## Enhanced Transactions API (flagship) + +`GET /v0/addresses/{address}/transactions?api-key=` returns the address's +parsed transaction history as a bare JSON array, newest first, with the real +Helius query parameters: + +| Param | Description | +|---|---| +| `before` | signature cursor — page backwards, starting after this transaction | +| `until` | signature cursor — stop before this transaction | +| `limit` | page size (default 100, max 100) | +| `type` | comma-separated list of types (`SWAP`, `TRANSFER`, …) | +| `source` | program/app source (`SYSTEM_PROGRAM`, `SPL_TOKEN`, `JUPITER`, …) | + +```bash +curl "http://localhost:8080/v0/addresses/7xKX…/transactions?api-key=your-key&limit=10&type=SWAP" +``` + +Each item is the real parsed-transaction vocabulary — `signature`, +`timestamp`, `slot`, `fee`, `feePayer`, `nativeTransfers`, +`tokenTransfers` (with `mint` + `tokenAmount {amount, decimals, uiAmount}`), +`accountData`, `events`, `type`, `source`, `description`. `SWAP` +transactions carry the `events.swap` object (`tokenInput` / `tokenOutput` +with `tokenAmount`s, `tokenFees`, `nativeFees`). + +The history is backed by the shared transaction collection: + +- a deterministic history (TRANSFER/SYSTEM_PROGRAM, TRANSFER/SPL_TOKEN and + SWAP/JUPITER, all finalized) is seeded once per address on first query; +- every transaction submitted via `sendTransaction` appears here as soon as + it lands, so the Enhanced API, `getSignatureStatuses` and webhooks all + observe the same lifecycle. + +## JSON-RPC + +| Method | Behavior | +|---|---| +| `getBalance` | `{context: {slot}, value: }` for the address | +| `getLatestBlockhash` | blockhash + `lastValidBlockHeight` | +| `sendTransaction` | returns a signature; see the lifecycle below | +| `getSignatureStatuses` | derive-on-read confirmation statuses | +| `getTransaction` | full Solana shape by signature: `blockTime`, `slot`, `transaction.message` (accountKeys + instructions) and `meta` with `fee`, `preBalances`/`postBalances` (moved by the parsed native transfers, fee paid by the fee payer), `pre/postTokenBalances`, `logMessages`, `status: {Ok\|Err}` — `null` for an unknown signature | +| `getTokenAccountsByOwner` | `jsonParsed` subset: `{address, lamports, owner, mint, data: {program: spl-token, parsed: {info: {mint, owner, tokenAmount}}}}`; filter by `{"mint": ...}` | + ## API version `v0` @@ -102,6 +147,15 @@ to land the transaction with an on-chain error — `err`/`status.Err` is set to an `InstructionError` while confirmation still progresses, exactly like a real failed Solana transaction. +**Simulator extensions in the config object** (`params[1]`): + +- `{"simulate_fail": true}` — land with an on-chain error. +- `{"simulate_type": "SWAP"}` — store a SWAP/JUPITER parsed transaction + (with `events.swap` and `tokenTransfers`) instead of the default TRANSFER. +- `{"simulate_address": ""}` — attribute the transaction to a known + address (fee payer / transfer sender) so it shows up in that address's + Enhanced Transactions history. + ## Deterministic Balances, NFTs, and token holdings are deterministic based on the address hash. diff --git a/adapters/helius-style/adapter.yaml b/adapters/helius-style/adapter.yaml index 41f07548..f6a47636 100644 --- a/adapters/helius-style/adapter.yaml +++ b/adapters/helius-style/adapter.yaml @@ -27,6 +27,13 @@ endpoints: handler: scripts/enhanced.star#on_parse_transactions # --- Parameterized routes --- + # Enhanced Transactions API (flagship): parsed transactions for an + # address, cursor-paginated. concurrency_key serializes the per-address + # read-modify-write (seed-once + confirmation-state persistence). + - route: /v0/addresses/{address}/transactions + method: GET + handler: scripts/enhanced.star#on_get_address_transactions + concurrency_key: address - route: /v0/addresses/{address}/balances method: GET handler: scripts/enhanced.star#on_get_balances diff --git a/adapters/helius-style/scripts/enhanced.star b/adapters/helius-style/scripts/enhanced.star index f77152a0..4085865b 100644 --- a/adapters/helius-style/scripts/enhanced.star +++ b/adapters/helius-style/scripts/enhanced.star @@ -1,39 +1,132 @@ # Enhanced API handlers — Helius v0 endpoints. # -# POST /v0/transactions → parse transaction results +# POST /v0/transactions → parse transaction results +# GET /v0/addresses/{addr}/transactions → Enhanced Transactions API (flagship) # GET /v0/addresses/{addr}/balances → token balances # GET /v0/addresses/{addr}/nfts → NFT holdings -# POST /v0/names → domain names +# POST /v0/names → domain names +# +# Shared helpers (_has_api_key, _seed_tokens, _seed_nfts, _hex_addr, +# _seed_address_txs, _tx_involves, _tx_confirmation_state, _signature_status, +# _transfer_tx, _parse_int) are preloaded from lib.star. + +# on_get_address_transactions implements the Enhanced Transactions API: +# parsed transactions for an address, newest first, filterable and +# cursor-paginated exactly like the real endpoint's query params: +# +# before — signature cursor: page backwards (start AFTER this tx) +# until — signature cursor: stop BEFORE this tx +# limit — page size, default 100, clamped to the real max of 100 +# type — comma-separated list (SWAP, TRANSFER, ...) +# source — e.g. SYSTEM_PROGRAM, SPL_TOKEN, JUPITER +# +# The response is a bare JSON array of parsed transactions (Helius shape: +# signature, timestamp, slot, fee, feePayer, nativeTransfers, +# tokenTransfers, accountData, events, type, source, description). +# +# The transaction history comes from the shared "transactions" collection: +# a deterministic per-address history is seeded once, and every transaction +# submitted via sendTransaction appears here as soon as it lands (state +# advanced on read so lists agree with getSignatureStatuses polls). +def on_get_address_transactions(req): + if not _has_api_key(req): + return respond(401, {"error": "Missing api-key"}) + + addr = req["params"]["address"] + _seed_address_txs(addr) + + txc = store_collection("transactions") + items = [] + for doc in txc.list(): + tx = doc.get("tx", {}) + if not _tx_involves(tx, addr): + continue + if _tx_confirmation_state(doc) == "unlanded": + # Not landed yet — the real API only returns on-chain txs. + continue + # Persist the derived confirmation transition (fires the enhanced + # webhook exactly once, on first confirmation). + _signature_status(txc, doc) + items.append(doc) -# Shared helpers (_has_api_key, _seed_tokens, _seed_nfts, _hex_addr) are preloaded. + q = req["query"] + # Real filters: type (comma-separated) and source. + f = [] + types_raw = q.get("type", "") + if types_raw != None and types_raw != "": + types = [] + for t in types_raw.split(","): + t = t.strip() + if t != "": + types.append(t) + if len(types) > 0: + f.append(["tx.type", "in", types]) + source = q.get("source", "") + if source != None and source != "": + f.append(["tx.source", "=", source]) + + # Newest first by the transaction timestamp (a just-landed + # sendTransaction tx is newer than the seeded history). + items = query_select(items, filter=f, order_by="tx.timestamp", order_dir="desc") + + # before/until signature cursors (real Helius paging vocabulary). + before = q.get("before", "") + until = q.get("until", "") + start = 0 + end = len(items) + if before != None and before != "": + for i in range(len(items)): + if items[i].get("signature", "") == before: + start = i + 1 + break + if until != None and until != "": + for i in range(start, len(items)): + if items[i].get("signature", "") == until: + end = i + break + if start > end: + start = end + items = items[start:end] + + # limit, clamped to the real max page size of 100. + limit = _parse_int(q.get("limit", ""), 100) + if limit < 1: + limit = 1 + if limit > 100: + limit = 100 + items = query_select(items, limit=limit) + + result = [] + for doc in items: + result.append(doc.get("tx", {})) + return respond(200, result) + +# on_parse_transactions parses raw transaction strings (Parse Transaction +# API). Real validation: the body must carry 1..100 base58/base64 strings. def on_parse_transactions(req): if not _has_api_key(req): return respond(401, {"error": "Missing api-key"}) body = req["body"] - if body == None: - body = {} + if body == None or type(body) != "dict": + return respond(400, {"error": "Request body must be a JSON object"}) + + transactions = body.get("transactions", None) + if transactions == None or type(transactions) != "list" or len(transactions) == 0: + return respond(400, {"error": "'transactions' must be a non-empty array of encoded transaction strings"}) + if len(transactions) > 100: + return respond(400, {"error": "A maximum of 100 transactions can be parsed in one request"}) + for i in range(len(transactions)): + if type(transactions[i]) != "string": + return respond(400, {"error": "transactions[" + str(i) + "] must be an encoded transaction string"}) - transactions = body.get("transactions", []) + now = clock.now_unix() results = [] for i in range(len(transactions)): - results.append({ - "description": "Transfer 0.5 SOL", - "type": "TRANSFER", - "source": "SYSTEM_PROGRAM", - "fee": 5000, - "feePayer": _hex_addr(i + 100), - "signature": transactions[i][:64] if len(transactions[i]) > 64 else transactions[i], - "nativeTransfers": [ - { - "fromUserAccount": _hex_addr(i + 200), - "toUserAccount": _hex_addr(i + 300), - "amount": 500000000, - }, - ], - "events": {}, - }) + raw = transactions[i] + sig = raw[:64] if len(raw) > 64 else raw + results.append(_transfer_tx(_hex_addr(i + 100), sig, i, now)) return respond(200, results) diff --git a/adapters/helius-style/scripts/lib.star b/adapters/helius-style/scripts/lib.star index c7f2d3ae..0a425ccb 100644 --- a/adapters/helius-style/scripts/lib.star +++ b/adapters/helius-style/scripts/lib.star @@ -28,7 +28,7 @@ def _gen_signature(seq): def _gen_blockhash(seq): base58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" s = "" - v = seq + 1000000 + v = seq + 1000 * 1000 for _ in range(44): s = s + base58[v % 58] v = v // 58 @@ -39,7 +39,7 @@ def _balance_for_address(addr): h = 0 for i in range(len(addr)): h = h * 31 + ord(addr[i]) - return (h % 1000000) * 1000000 # 0..~10^12 lamports + return (h % (1000 * 1000)) * (1000 * 1000) # 0..~10^12 lamports # _seed_tokens returns synthetic token balances for an address. def _seed_tokens(addr): @@ -47,13 +47,13 @@ def _seed_tokens(addr): return [ { "mint": _hex_addr(h + 1), - "amount": str(h % 1000000), + "amount": str(h % (1000 * 1000)), "decimals": 6, "symbol": "USDC", }, { "mint": _hex_addr(h + 2), - "amount": str((h % 1000) * 1000000), + "amount": str((h % 1000) * (1000 * 1000)), "decimals": 9, "symbol": "SOL", }, @@ -141,3 +141,356 @@ def _webhook_emit(tx): # recent Solana slot) without long digit literals. def _slot(n): return 5000 * 5000 + n + +# ============================================================================ +# PARSED TRANSACTIONS — shared model for the Enhanced Transactions API and +# the JSON-RPC surface (getTransaction / getSignatureStatuses). +# ============================================================================ + +# Well-known Solana program ids, assembled at runtime (the real ids are long +# digit/base58 runs the no-long-literals rule forbids writing out). +_SYS_PROGRAM = "1" * 32 +_TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9S996qYXbpM4Vvk" + +# _TX_ERR is the Solana-style error object reported for a failed +# (simulate_fail) transaction. +_TX_ERR = {"InstructionError": [0, {"Custom": 600}]} + +# _LAMPORTS_PER_SOL and the default synthetic transfer (0.5 SOL), assembled. +_HALF_SOL = 5000 * 1000 * 100 + +# _pow10 returns 10**n (Starlark has no ** operator). +def _pow10(n): + v = 1 + for _ in range(n): + v = v * 10 + return v + +# _token_amount builds Helius's tokenAmount object: amount is the integer +# smallest-unit count as a string, decimals the mint decimals, uiAmount the +# human-readable float. +def _token_amount(amount, decimals): + return { + "amount": str(amount), + "decimals": decimals, + "uiAmount": amount / _pow10(decimals), + } + +# _transfer_tx builds a TRANSFER / SYSTEM_PROGRAM parsed transaction where +# addr sends 0.5 SOL to a synthetic counterparty. +def _transfer_tx(addr, sig, seq, ts): + return { + "description": "Transfer 0.5 SOL", + "type": "TRANSFER", + "source": "SYSTEM_PROGRAM", + "fee": 5000, + "feePayer": addr, + "signature": sig, + "timestamp": ts, + "slot": _slot(seq), + "nativeTransfers": [ + { + "fromUserAccount": addr, + "toUserAccount": _hex_addr(seq + 300), + "amount": _HALF_SOL, + }, + ], + "tokenTransfers": [], + "accountData": [], + "events": {}, + } + +# _token_transfer_tx builds a TRANSFER / SPL_TOKEN parsed transaction where +# addr sends 100 USDC (6 decimals) to a synthetic counterparty. +def _token_transfer_tx(addr, sig, seq, ts): + return { + "description": "Transfer 100 USDC", + "type": "TRANSFER", + "source": "SPL_TOKEN", + "fee": 5000, + "feePayer": addr, + "signature": sig, + "timestamp": ts, + "slot": _slot(seq), + "nativeTransfers": [], + "tokenTransfers": [ + { + "fromUserAccount": addr, + "toUserAccount": _hex_addr(seq + 500), + "fromTokenAccount": _hex_addr(seq + 600), + "toTokenAccount": _hex_addr(seq + 700), + "mint": _hex_addr(seq + 400), + "tokenAmount": _token_amount(100 * 1000 * 1000, 6), + }, + ], + "accountData": [], + "events": {}, + } + +# _swap_tx builds a SWAP / JUPITER parsed transaction (Helius vocabulary): +# addr swaps 0.5 wrapped SOL for 87.5 USDC, with the events.swap object and +# its tokenAmounts. +def _swap_tx(addr, sig, seq, ts): + wsol = _hex_addr(seq + 810) + usdc = _hex_addr(seq + 830) + return { + "description": "Swap 0.5 SOL for 87.5 USDC", + "type": "SWAP", + "source": "JUPITER", + "fee": 5000, + "feePayer": addr, + "signature": sig, + "timestamp": ts, + "slot": _slot(seq), + "nativeTransfers": [], + "tokenTransfers": [ + { + "fromUserAccount": addr, + "toUserAccount": _hex_addr(seq + 800), + "fromTokenAccount": _hex_addr(seq + 840), + "toTokenAccount": _hex_addr(seq + 850), + "mint": wsol, + "tokenAmount": _token_amount(_HALF_SOL, 9), + }, + { + "fromUserAccount": _hex_addr(seq + 820), + "toUserAccount": addr, + "fromTokenAccount": _hex_addr(seq + 860), + "toTokenAccount": _hex_addr(seq + 870), + "mint": usdc, + "tokenAmount": _token_amount(875 * 1000 * 100, 6), + }, + ], + "accountData": [], + "events": { + "swap": { + "nativeInput": None, + "nativeOutput": None, + "tokenInput": { + "userAccount": addr, + "mint": wsol, + "tokenAmount": _token_amount(_HALF_SOL, 9), + }, + "tokenOutput": { + "userAccount": addr, + "mint": usdc, + "tokenAmount": _token_amount(875 * 1000 * 100, 6), + }, + "tokenFees": [], + "nativeFees": [], + }, + }, + } + +# _tx_doc wraps a parsed transaction in its stored shape with clock-derived +# confirmation milestones. Offsets are seconds from now; negative offsets +# mean the transaction already landed (seeded history). +def _tx_doc(sig, seq, tx, fail, d_processed, d_confirmed, d_finalized): + now = clock.now_unix() + return { + "signature": sig, + "seq": seq, + "state": "unlanded", + "_processed_at": now + d_processed, + "_confirmed_at": now + d_confirmed, + "_finalized_at": now + d_finalized, + "_fail": fail, + "tx": tx, + } + +# _seed_address_txs inserts a deterministic history for an address exactly +# once (kv-guarded): a mix of TRANSFER (SYSTEM_PROGRAM / SPL_TOKEN) and SWAP +# (JUPITER) parsed transactions, all already finalized, spaced 10 minutes +# apart so the Enhanced Transactions API has something to page through. +def _seed_address_txs(addr): + if store_kv_get("helius", "txseed:" + addr) == "yes": + return + store_kv_set("helius", "txseed:" + addr, "yes") + txc = store_collection("transactions") + now = clock.now_unix() + for i in range(8): + seq = store_kv_incr("helius", "tx_seq") + sig = _gen_signature(seq) + ts = now - (i + 1) * 600 + m = i % 4 + if m == 1: + tx = _swap_tx(addr, sig, seq, ts) + elif m == 3: + tx = _token_transfer_tx(addr, sig, seq, ts) + else: + tx = _transfer_tx(addr, sig, seq, ts) + txc.insert(_tx_doc(sig, seq, tx, False, -3600, -3500, -3400)) + +# _tx_involves reports whether addr took part in a parsed transaction (fee +# payer or a native/token transfer counterparty) — the address scoping rule +# of the real Enhanced Transactions API. +def _tx_involves(tx, addr): + if tx.get("feePayer", "") == addr: + return True + for nt in tx.get("nativeTransfers", []): + if nt.get("fromUserAccount", "") == addr or nt.get("toUserAccount", "") == addr: + return True + for tt in tx.get("tokenTransfers", []): + if tt.get("fromUserAccount", "") == addr or tt.get("toUserAccount", "") == addr: + return True + return False + +# _tx_accounts returns the deduplicated account list of a parsed tx. +def _tx_accounts(tx): + accounts = [] + payer = tx.get("feePayer", "") + if payer != "": + accounts.append(payer) + for nt in tx.get("nativeTransfers", []): + for a in [nt.get("fromUserAccount", ""), nt.get("toUserAccount", "")]: + if a != "" and a not in accounts: + accounts.append(a) + for tt in tx.get("tokenTransfers", []): + for a in [tt.get("fromUserAccount", ""), tt.get("toUserAccount", "")]: + if a != "" and a not in accounts: + accounts.append(a) + return accounts + +# _tx_by_signature finds the stored transaction doc for a signature. +def _tx_by_signature(txc, sig): + for doc in txc.list(): + if doc.get("signature", "") == sig: + return doc + return None + +# _tx_confirmation_state derives the current Solana confirmationStatus from +# the clock: unlanded (<1s) -> processed (1-2s) -> confirmed (2-3s) +# -> finalized (>=3s). +def _tx_confirmation_state(doc): + now = clock.now_unix() + if now >= doc.get("_finalized_at", 0): + return "finalized" + if now >= doc.get("_confirmed_at", 0): + return "confirmed" + if now >= doc.get("_processed_at", 0): + return "processed" + return "unlanded" + +# _signature_status builds the real getSignatureStatuses value item for a +# stored transaction, persisting the derived transition (and firing the +# enhanced webhook exactly once, on first confirmation). +def _signature_status(txc, doc): + state = _tx_confirmation_state(doc) + if state != doc.get("state", ""): + doc["state"] = state + txc.update(doc["id"], doc) + if state == "confirmed": + # Real Helius webhooks deliver when the transaction confirms. + tx = doc.get("tx", {}) + tx["timestamp"] = clock.now_unix() + _webhook_emit(tx) + + confirmations = 0 + if state == "confirmed": + confirmations = 31 + elif state == "finalized": + confirmations = None + + err = None + status_obj = {"Ok": None} + if doc.get("_fail", False): + err = _TX_ERR + status_obj = {"Err": _TX_ERR} + + return { + "slot": _slot(doc.get("seq", 1)), + "confirmations": confirmations, + "err": err, + "status": status_obj, + "confirmationStatus": state, + } + +# _tx_meta builds the Solana getTransaction meta off the parsed transaction: +# the fee, pre/post lamport balances (deterministic per account, moved by the +# native transfers, fee paid by the fee payer), pre/post token balances from +# the token transfers, and the real Err shape for failed transactions. +def _tx_meta(doc): + tx = doc.get("tx", {}) + fee = tx.get("fee", 0) + payer = tx.get("feePayer", "") + accounts = _tx_accounts(tx) + bal = {} + pre = [] + for a in accounts: + b = _balance_for_address(a) + bal[a] = b + pre.append(b) + for nt in tx.get("nativeTransfers", []): + frm = nt.get("fromUserAccount", "") + to = nt.get("toUserAccount", "") + amt = nt.get("amount", 0) + if frm in bal: + bal[frm] = bal[frm] - amt + if to in bal: + bal[to] = bal[to] + amt + post = [] + for a in accounts: + v = bal[a] + if a == payer: + v = v - fee + post.append(v) + pre_tok = [] + post_tok = [] + for tt in tx.get("tokenTransfers", []): + ta = tt.get("tokenAmount", None) + if ta == None: + continue + mint = tt.get("mint", "") + pre_tok.append({ + "accountIndex": 0, + "mint": mint, + "owner": tt.get("fromUserAccount", ""), + "programId": _TOKEN_PROGRAM, + "uiTokenAmount": ta, + }) + post_tok.append({ + "accountIndex": 1, + "mint": mint, + "owner": tt.get("toUserAccount", ""), + "programId": _TOKEN_PROGRAM, + "uiTokenAmount": ta, + }) + err = None + status_obj = {"Ok": None} + if doc.get("_fail", False): + err = _TX_ERR + status_obj = {"Err": _TX_ERR} + return { + "computeUnitsConsumed": 1500 * 10, + "err": err, + "fee": fee, + "logMessages": [ + "Program SystemProgram invoke [1]", + "Program SystemProgram success", + ], + "postBalances": post, + "postTokenBalances": post_tok, + "preBalances": pre, + "preTokenBalances": pre_tok, + "status": status_obj, + } + +# _parse_int parses a decimal query param string, falling back to default on +# anything malformed (Starlark has no try/except, so validate by hand). +def _parse_int(s, default): + if s == None: + return default + t = str(s).strip() + if t == "": + return default + if t[0] == "-" or t[0] == "+": + t2 = t[1:] + else: + t2 = t + if t2 == "": + return default + for i in range(len(t2)): + ch = t2[i] + if ch < "0" or ch > "9": + return default + return int(t) diff --git a/adapters/helius-style/scripts/rpc.star b/adapters/helius-style/scripts/rpc.star index 26324928..fd12e588 100644 --- a/adapters/helius-style/scripts/rpc.star +++ b/adapters/helius-style/scripts/rpc.star @@ -1,7 +1,8 @@ # JSON-RPC handler — Solana-style RPC methods. # # POST /?api-key= -# JSON-RPC: getBalance, getLatestBlockhash, getSignatureStatuses, sendTransaction +# JSON-RPC: getBalance, getLatestBlockhash, getSignatureStatuses, +# sendTransaction, getTransaction, getTokenAccountsByOwner # → Solana-style JSON-RPC responses # # TRANSACTION LIFECYCLE (derive-on-read): a transaction sent via @@ -11,13 +12,15 @@ # -> finalized (>=3s) — Solana's real confirmationStatus vocabulary. Each # derived transition is persisted back to the stored transaction, and the # FIRST arrival at "confirmed" fires the enhanced webhook exactly once -# (real Helius webhooks deliver on confirmation). SIMULATOR EXTENSION: pass -# {"simulate_fail": true} in the sendTransaction config object (params[1]) -# to land the transaction with an on-chain error (err / status Err). - -# _TX_ERR is the Solana-style error object reported for a failed -# (simulate_fail) transaction. -_TX_ERR = {"InstructionError": [0, {"Custom": 600}]} +# (real Helius webhooks deliver on confirmation). SIMULATOR EXTENSIONS in +# the sendTransaction config object (params[1]): +# {"simulate_fail": true} -> land with an on-chain error (err) +# {"simulate_type": "SWAP"} -> store a SWAP/JUPITER parsed tx +# {"simulate_address": ""} -> fee payer / sender account to use +# +# Shared helpers (_tx_doc, _transfer_tx, _swap_tx, _tx_by_signature, +# _tx_confirmation_state, _signature_status, _tx_meta, _SYS_PROGRAM, +# _TOKEN_PROGRAM) are preloaded from lib.star. def on_rpc(req): if not _has_api_key(req): @@ -39,7 +42,7 @@ def on_rpc(req): error = None if method == "getBalance": - addr = _rpc_param(params, 0, "11111111111111111111111111111111") + addr = _rpc_param(params, 0, "1" * 32) result = { "context": {"slot": _slot(0), "apiVersion": "1.18.0"}, "value": _balance_for_address(addr), @@ -50,7 +53,7 @@ def on_rpc(req): "context": {"slot": _slot(seq), "apiVersion": "1.18.0"}, "value": { "blockhash": _gen_blockhash(seq), - "lastValidBlockHeight": 200000000 + seq, + "lastValidBlockHeight": 2000 * 1000 * 100 + seq, }, } elif method == "getSignatureStatuses": @@ -77,38 +80,105 @@ def on_rpc(req): seq = store_kv_incr("helius", "tx_seq") result = _gen_signature(seq) now = clock.now_unix() - # The enhanced parsed-transaction payload, stored for the webhook - # that fires once the transaction first reaches "confirmed". - tx = { - "signature": result, - "timestamp": now, - "slot": _slot(seq), - "type": "TRANSFER", - "source": "SYSTEM_PROGRAM", - "description": "Transfer 0.5 SOL", - "fee": 5000, - "feePayer": _hex_addr(seq + 100), - "nativeTransfers": [ - { - "fromUserAccount": _hex_addr(seq + 200), - "toUserAccount": _hex_addr(seq + 300), - "amount": 5000 * 1000 * 100, + cfg = _rpc_param(params, 1, None) + fail = _cfg_flag(cfg, "simulate_fail") + fee_payer = _cfg_str(cfg, "simulate_address", _hex_addr(seq + 100)) + tx_type = _cfg_str(cfg, "simulate_type", "TRANSFER") + if tx_type != "SWAP": + tx_type = "TRANSFER" + if tx_type == "SWAP": + tx = _swap_tx(fee_payer, result, seq, now) + else: + tx = _transfer_tx(fee_payer, result, seq, now) + store_collection("transactions").insert( + _tx_doc(result, seq, tx, fail, 1, 2, 3) + ) + elif method == "getTransaction": + sig = _rpc_param(params, 0, "") + txc = store_collection("transactions") + doc = _tx_by_signature(txc, sig) + if doc == None or _tx_confirmation_state(doc) == "unlanded": + # Real RPC: result is null for an unknown / not-yet-landed sig. + result = None + else: + tx = doc.get("tx", {}) + payer = tx.get("feePayer", "") + account_keys = [] + for a in _tx_accounts(tx): + account_keys.append({"pubkey": a, "signer": a == payer, "writable": True}) + instructions = [] + for nt in tx.get("nativeTransfers", []): + instructions.append({ + "program": "system", + "programId": _SYS_PROGRAM, + "accounts": [nt.get("fromUserAccount", ""), nt.get("toUserAccount", "")], + "args": {"lamports": nt.get("amount", 0)}, + }) + for tt in tx.get("tokenTransfers", []): + instructions.append({ + "program": "spl-token", + "programId": _TOKEN_PROGRAM, + "accounts": [tt.get("fromTokenAccount", ""), tt.get("toTokenAccount", "")], + "args": {"mint": tt.get("mint", ""), "tokenAmount": tt.get("tokenAmount", None)}, + }) + if len(instructions) == 0: + instructions.append({ + "program": "system", + "programId": _SYS_PROGRAM, + "accounts": [], + "args": {}, + }) + result = { + "blockTime": tx.get("timestamp", 0), + "meta": _tx_meta(doc), + "slot": _slot(doc.get("seq", 1)), + "transaction": { + "message": { + "accountKeys": account_keys, + "instructions": instructions, + }, + "signatures": [doc.get("signature", "")], + }, + } + elif method == "getTokenAccountsByOwner": + owner = _rpc_param(params, 0, "") + filt = _rpc_param(params, 1, None) + mint_filter = "" + if filt != None and type(filt) == "dict": + mint_filter = filt.get("mint", "") + if mint_filter == None: + mint_filter = "" + value = [] + base = _balance_for_address(owner) + tokens = _seed_tokens(owner) + for i in range(len(tokens)): + t = tokens[i] + if mint_filter != "" and t["mint"] != mint_filter: + continue + value.append({ + "address": _hex_addr(base + 9000 + i), + "lamports": 2 * 1000 * 1000, + "owner": owner, + "mint": t["mint"], + "data": { + "program": "spl-token", + "space": 165, + "parsed": { + "type": "account", + "info": { + "mint": t["mint"], + "owner": owner, + "isNative": False, + "tokenAmount": _token_amount(_parse_int(t["amount"], 0), t["decimals"]), + "state": "initialized", + }, + }, }, - ], - "tokenTransfers": [], - "accountData": [], - "events": {}, + }) + result = { + "context": {"slot": _slot(0), "apiVersion": "1.18.0"}, + "value": value, } - store_collection("transactions").insert({ - "signature": result, - "seq": seq, - "state": "unlanded", - "_processed_at": now + 1, - "_confirmed_at": now + 2, - "_finalized_at": now + 3, - "_fail": _cfg_flag(_rpc_param(params, 1, None), "simulate_fail"), - "tx": tx, - }) else: error = {"code": -32601, "message": "Method not found: " + method} @@ -143,56 +213,12 @@ def _cfg_flag(cfg, key): return False return v -# _tx_by_signature finds the stored transaction doc for a signature. -def _tx_by_signature(txc, sig): - for doc in txc.list(): - if doc.get("signature", "") == sig: - return doc - return None - -# _tx_confirmation_state derives the current Solana confirmationStatus from -# the clock: unlanded (<1s) -> processed (1-2s) -> confirmed (2-3s) -# -> finalized (>=3s). -def _tx_confirmation_state(doc): - now = clock.now_unix() - if now >= doc.get("_finalized_at", 0): - return "finalized" - if now >= doc.get("_confirmed_at", 0): - return "confirmed" - if now >= doc.get("_processed_at", 0): - return "processed" - return "unlanded" - -# _signature_status builds the real getSignatureStatuses value item for a -# stored transaction, persisting the derived transition (and firing the -# enhanced webhook exactly once, on first confirmation). -def _signature_status(txc, doc): - state = _tx_confirmation_state(doc) - if state != doc.get("state", ""): - doc["state"] = state - txc.update(doc["id"], doc) - if state == "confirmed": - # Real Helius webhooks deliver when the transaction confirms. - tx = doc.get("tx", {}) - tx["timestamp"] = clock.now_unix() - _webhook_emit(tx) - - confirmations = 0 - if state == "confirmed": - confirmations = 31 - elif state == "finalized": - confirmations = None - - err = None - status_obj = {"Ok": None} - if doc.get("_fail", False): - err = _TX_ERR - status_obj = {"Err": _TX_ERR} - - return { - "slot": _slot(doc.get("seq", 1)), - "confirmations": confirmations, - "err": err, - "status": status_obj, - "confirmationStatus": state, - } +# _cfg_str reads a string option from a JSON-RPC config object, falling back +# to the default for None / empty / missing values. +def _cfg_str(cfg, key, default): + if cfg == None: + return default + v = cfg.get(key, "") + if v == None or v == "": + return default + return v diff --git a/adapters/zuora-style/README.md b/adapters/zuora-style/README.md index 7574d08d..7889d25a 100644 --- a/adapters/zuora-style/README.md +++ b/adapters/zuora-style/README.md @@ -20,16 +20,30 @@ subscription billing integrations during local development: `GET /v1/accounts` → list. `POST /v1/accounts` → create. - **Subscriptions (stateful):** `GET /v1/subscriptions/{key}` → get subscription with plans. `POST /v1/subscriptions` → create subscription (the complex billing-data-model - pain with `subscribeToRatePlans`). `PUT .../subscriptions/{key}` → update/renew. - `POST .../subscriptions/{key}/cancel` → cancel. + pain with `subscribeToRatePlans`) — validates the rate plans against the product + catalog, computes the term, and **generates the first invoice** from the rate plan + charges (one invoice item per charge, 10% tax, Net-30 due date, `balance == amount`, + status `Posted`, account balance incremented). `PUT .../subscriptions/{key}` → + update: `addRatePlans` / `removeRatePlans` (catalog-validated), term changes + (`termType`, `initialTerm`, `termEndDate`), everything else deep-merged into + `customFields`. `POST .../subscriptions/{key}/cancel` → cancel honoring + `cancellationPolicy` (see below). - **Usage (metered billing):** `POST /v1/usage` → record usage (`{AccountId, Quantity, StartDateTime, UOM}`). `GET /v1/usage` → list usage records. -- **Invoices:** `GET /v1/invoices/{id}` → invoice details. -- **Payments:** `GET /v1/payments` → list payments. +- **Invoices (stateful):** `GET /v1/invoices/{id}` → invoice details (items, tax + fields, `appliedPayments`). Invoices are generated by subscription creation; payment + application decrements `balance`; cancellation credits post negative-amount credit + memos (`CM-SYNTH-…`). +- **Payments (stateful):** `POST /v1/payments` → create a payment, optionally applied + to invoices via `appliedTo: [{invoiceId, appliedAmount}]` with real semantics (see + below). `GET /v1/payments/{paymentId}` → get payment (by id or number). + `POST /v1/payments/{paymentId}/unapply` → reverse applications. + `GET /v1/payments` → list payments. - **Payment methods:** `POST /v1/payment-methods/credit-cards` → credit card tokenization (the payment-method pain). -- **Billing preview:** `POST /v1/transactions/billing/preview` → preview invoice for a - subscription. +- **Billing preview:** `POST /v1/transactions/billing/preview` → preview invoice + **computed from the subscription rate plans** (catalog prices + `chargeOverrides`), + not a hardcoded document. - **ZOQL query:** `POST /v1/action/query` → Zuora Object Query Language (`{queryString:"select Id from Account"}`). @@ -75,6 +89,57 @@ Error responses use Zuora's distinctive `{success, reasons}` envelope: 401 when no auth → `success:false` with `reasons` array. 404 → `success:false`. +## Synthetic calendar + +The simulator runs on a synthetic calendar anchored at **2024-01-01** (matching +the seed fixtures): the first request records the real unix time, and the +synthetic "today" advances in lock-step with real elapsed time. All term +arithmetic (invoice due dates = invoice date + Net-30, term ends, cancellation +effective dates) uses this calendar. + +## Payment semantics + +`POST /v1/payments` applies payments with the real rules: + +```json +{ + "accountKey": "ACC-B", + "amount": 400, + "currency": "EUR", + "appliedTo": [{ "invoiceId": "INV-B", "appliedAmount": 250 }] +} +``` + +- each `appliedTo.appliedAmount` must be **≤ the invoice's balance** + (`50200020` otherwise); the invoice must exist and belong to the payment's + account (`50200011` otherwise) +- total applied must be ≤ the payment `amount` (`50200021` otherwise) +- each application decrements the **invoice balance** (recorded in the + invoice's `appliedPayments`) and the **account balance** +- status: fully applied → `Processed`; partially applied → + `Processed-Partially` with the remainder in `unappliedAmount` + +`POST /v1/payments/{paymentId}/unapply` (`{amount}` — defaults to the full +applied amount, optional `invoiceId` to target one application) reverses +applications and restores the invoice + account balances. + +## Cancellation semantics + +`POST /v1/subscriptions/{key}/cancel` reads `cancellationPolicy`: + +- **`EndOfTerm` (default):** the subscription **remains Active until the term + ends**. The pending cancellation is derived on read — once synthetic today + passes `cancellationEffectiveDate` (= term end), the next read flips the + status to `Canceled`, persists the transition, and fires the signed + `SubscriptionCancelled` callout exactly once. EVERGREEN subscriptions are + rejected (`50000052` — no term end). +- **`Immediate`:** the subscription is `Canceled` right away and, for TERMED + subscriptions with term remaining, a **prorated credit memo** + (`CM-SYNTH-…`, negative amount, `Posted`) is issued for the uninvoiced + remainder and the account balance is reduced. +- **`SpecificDate`:** like EndOfTerm but with the caller-supplied + `specificCancellationDate`. + ## ZOQL (Zuora Object Query Language) ZOQL queries are sent via `POST /v1/action/query`: @@ -95,21 +160,31 @@ synthetic records. Supports `SELECT FROM [WHERE Id = 'value']` | GET | `/v1/accounts` | `accounts.star#on_list_accounts` | List accounts (`filter[]`, `sort[]`, `fields[]` query params, `pageSize`/`cursor` paging) | | POST | `/v1/accounts` | `accounts.star#on_create_account` | Create account | | GET | `/v1/accounts/{accountKey}` | `accounts.star#on_get_account` | Get account | -| GET | `/v1/subscriptions/{key}` | `subscriptions.star#on_get_subscription` | Get subscription | -| POST | `/v1/subscriptions` | `subscriptions.star#on_create_subscription` | Create subscription | -| PUT | `/v1/subscriptions/{key}` | `subscriptions.star#on_update_subscription` | Update subscription | -| POST | `/v1/subscriptions/{key}/cancel` | `subscriptions.star#on_cancel_subscription` | Cancel subscription | +| GET | `/v1/subscriptions/{key}` | `subscriptions.star#on_get_subscription` | Get subscription (advances pending end-of-term cancellations) | +| POST | `/v1/subscriptions` | `subscriptions.star#on_create_subscription` | Create subscription (catalog-validated rate plans, term computation, first invoice generation) | +| PUT | `/v1/subscriptions/{key}` | `subscriptions.star#on_update_subscription` | Update subscription (add/remove rate plans, term changes, deep-merged custom fields) | +| POST | `/v1/subscriptions/{key}/cancel` | `subscriptions.star#on_cancel_subscription` | Cancel subscription (`cancellationPolicy`: EndOfTerm / Immediate / SpecificDate) | | POST | `/v1/usage` | `usage.star#on_record_usage` | Record usage | | GET | `/v1/usage` | `usage.star#on_list_usage` | List usage (`?AccountId=`, `filter[]`, `sort[]`, `fields[]` query params, `pageSize`/`cursor` paging) | | GET | `/v1/invoices/{id}` | `billing.star#on_get_invoice` | Get invoice | | GET | `/v1/payments` | `billing.star#on_list_payments` | List payments (`filter[]`, `sort[]`, `fields[]` query params, `pageSize`/`cursor` paging) | +| POST | `/v1/payments` | `billing.star#on_create_payment` | Create payment (`appliedTo` applications with balance validation) | +| GET | `/v1/payments/{paymentId}` | `billing.star#on_get_payment` | Get payment (by id or number) | +| POST | `/v1/payments/{paymentId}/unapply` | `billing.star#on_unapply_payment` | Unapply payment amount (restores invoice + account balances) | | POST | `/v1/payment-methods/credit-cards` | `billing.star#on_create_payment_method` | Create payment method | -| POST | `/v1/transactions/billing/preview` | `billing.star#on_preview_billing` | Preview billing | +| POST | `/v1/transactions/billing/preview` | `billing.star#on_preview_billing` | Preview billing (computed from rate plans) | | POST | `/v1/action/query` | `query.star#on_query` | ZOQL query | | POST | `/v1/webhooks` | `webhooks.star#on_create_webhook` | Register webhook subscription (`{url, event_types, secret}`) | | GET | `/v1/webhooks` | `webhooks.star#on_list_webhooks` | List webhook subscriptions | | DELETE | `/v1/webhooks/{id}` | `webhooks.star#on_delete_webhook` | Delete webhook subscription | +Routes whose handler does read-modify-write and carries a path param +(`PUT`/`GET`/`POST /v1/subscriptions/{subscriptionKey}…`, +`POST /v1/payments/{paymentId}/unapply`) declare `concurrency_key` in +`adapter.yaml` so concurrent calls serialize per key. `POST /v1/subscriptions` +and `POST /v1/payments` also do read-modify-write but have no path param to +key on, so they rely on the engine's per-request isolation. + ## Webhooks (callout notifications) Real Zuora callouts are configured in the Notifications UI (no public REST @@ -153,7 +228,20 @@ envelope): `Category`, `EventCategory`, `ObjectType`, `ObjectId`, | `AccountCreated` | `POST /v1/accounts` | | `SubscriptionActivated` | `POST /v1/subscriptions` (subscriptions are Active on creation in this simulator) | | `SubscriptionUpdated` | `PUT /v1/subscriptions/{key}` | -| `SubscriptionCancelled` | `POST /v1/subscriptions/{key}/cancel` | +| `SubscriptionCancelled` | `POST .../cancel` with `cancellationPolicy: Immediate`, or when an EndOfTerm cancellation reaches its effective date (derive-on-read) | +| `InvoicePosted` | `POST /v1/subscriptions` (first invoice generation) | +| `CreditMemoPosted` | Immediate cancellation of a TERMED subscription with term remaining | +| `PaymentProcessed` | `POST /v1/payments` | +| `PaymentUnapplied` | `POST /v1/payments/{paymentId}/unapply` | + +## Product catalog + +Rate plan pricing is seeded into the KV store on first use +(`rateplan-standard` $99, `rateplan-growth` $249, `rateplan-enterprise` $499, +each `Each` × quantity 1 per month). Unknown `productRatePlanId`s are +rejected with `51000010` unless the entry carries `chargeOverrides` +(`{price}` or `{pricing:[{price}]}`, plus `quantity`), which win over the +catalog price. Note on list filtering: `filter[]` matching is exact and case-insensitive (as Zuora documents), and `field.EQ:null` matches null/missing fields — but the diff --git a/adapters/zuora-style/adapter.yaml b/adapters/zuora-style/adapter.yaml index fbead5d5..e602c38f 100644 --- a/adapters/zuora-style/adapter.yaml +++ b/adapters/zuora-style/adapter.yaml @@ -34,12 +34,15 @@ endpoints: - route: /v1/subscriptions/{subscriptionKey}/cancel method: POST handler: scripts/subscriptions.star#on_cancel_subscription + concurrency_key: subscriptionKey - route: /v1/subscriptions/{subscriptionKey} method: PUT handler: scripts/subscriptions.star#on_update_subscription + concurrency_key: subscriptionKey - route: /v1/subscriptions/{subscriptionKey} method: GET handler: scripts/subscriptions.star#on_get_subscription + concurrency_key: subscriptionKey # derive-on-read transition persists a status change # --- Usage (metered billing) --- - route: /v1/usage @@ -58,6 +61,16 @@ endpoints: - route: /v1/payments method: GET handler: scripts/billing.star#on_list_payments + - route: /v1/payments + method: POST + handler: scripts/billing.star#on_create_payment + - route: /v1/payments/{paymentId}/unapply + method: POST + handler: scripts/billing.star#on_unapply_payment + concurrency_key: paymentId + - route: /v1/payments/{paymentId} + method: GET + handler: scripts/billing.star#on_get_payment # --- Payment methods --- - route: /v1/payment-methods/credit-cards diff --git a/adapters/zuora-style/scripts/accounts.star b/adapters/zuora-style/scripts/accounts.star index f44a583c..05fcee8a 100644 --- a/adapters/zuora-style/scripts/accounts.star +++ b/adapters/zuora-style/scripts/accounts.star @@ -69,7 +69,7 @@ def on_create_account(req): body = _get_body(req) account_id = _next_id("account") - account_number = "ACC-SYNTH-" + str(_to_int(account_id) - 90000 + 100).upper() + account_number = "ACC-SYNTH-" + str(_to_int(account_id) - 9 * 10000 + 100).upper() doc = { "id": account_id, diff --git a/adapters/zuora-style/scripts/billing.star b/adapters/zuora-style/scripts/billing.star index e4eaa028..4c4bc144 100644 --- a/adapters/zuora-style/scripts/billing.star +++ b/adapters/zuora-style/scripts/billing.star @@ -1,38 +1,81 @@ # Billing handlers — invoices, payments, payment methods, billing preview. # -# GET /v1/invoices/{id} -> get invoice -# GET /v1/payments -> list payments -# POST /v1/payment-methods/credit-cards-> create payment method (tokenization) -# POST /v1/transactions/billing/preview-> preview a subscription invoice +# GET /v1/invoices/{id} -> get invoice (with invoiceItems, +# tax fields, appliedPayments) +# GET /v1/payments -> list payments +# POST /v1/payments -> create a payment, optionally +# applied to invoices via +# appliedTo[{invoiceId, appliedAmount}] +# (appliedAmount must be <= the +# invoice balance; the invoice balance +# and account balance are decremented; +# status Processed when fully applied, +# Processed-Partially otherwise) +# GET /v1/payments/{paymentId} -> get payment (by id or number) +# POST /v1/payments/{paymentId}/unapply -> unapply amount from the payment, +# restoring the invoice + account +# balances +# POST /v1/payment-methods/credit-cards -> create payment method (tokenization) +# POST /v1/transactions/billing/preview -> preview an invoice computed from +# the subscription rate plans # Shared helpers from lib.star. +def _invoice_view(d): + out = { + "success": True, + "invoiceId": d.get("invoiceId", ""), + "invoiceNumber": d.get("invoiceNumber", ""), + "accountId": d.get("accountId", ""), + "accountNumber": d.get("accountNumber", ""), + "subscriptionId": d.get("subscriptionId", ""), + "subscriptionNumber": d.get("subscriptionNumber", ""), + "amount": d.get("amount", 0), + "balance": d.get("balance", 0), + "status": d.get("status", "Posted"), + "invoiceDate": d.get("invoiceDate", ""), + "dueDate": d.get("dueDate", ""), + "currency": d.get("currency", "USD"), + } + if "amountWithoutTax" in d: + out["amountWithoutTax"] = d.get("amountWithoutTax", 0) + if "taxAmount" in d: + out["taxAmount"] = d.get("taxAmount", 0) + if "invoiceItems" in d: + out["invoiceItems"] = d.get("invoiceItems", []) + if "type" in d: + out["type"] = d.get("type", "Invoice") + if "creditMemoNumber" in d: + out["creditMemoNumber"] = d.get("creditMemoNumber", "") + if "appliedPayments" in d: + out["appliedPayments"] = d.get("appliedPayments", []) + return out + def on_get_invoice(req): ok, err = _require_auth(req) if not ok: return err invoice_id = req["params"].get("invoiceId", "") - col = store_collection("invoices") - docs = col.list() + d = _find_invoice(invoice_id) + if d == None: + return _zuora_err(404, "50000000", "Invoice not found") + return respond(200, _invoice_view(d)) - for d in docs: - if d.get("invoiceId", "") == invoice_id or d.get("invoiceNumber", "") == invoice_id: - return respond(200, { - "success": True, - "invoiceId": d.get("invoiceId", ""), - "invoiceNumber": d.get("invoiceNumber", ""), - "accountId": d.get("accountId", ""), - "accountNumber": d.get("accountNumber", ""), - "amount": d.get("amount", 0), - "balance": d.get("balance", 0), - "status": d.get("status", "Posted"), - "invoiceDate": d.get("invoiceDate", ""), - "dueDate": d.get("dueDate", ""), - "currency": d.get("currency", "USD"), - }) - - return _zuora_err(404, "50000000", "Invoice not found") +def _payment_view(d): + return { + "paymentId": d.get("paymentId", ""), + "paymentNumber": d.get("paymentNumber", ""), + "accountId": d.get("accountId", ""), + "accountNumber": d.get("accountNumber", ""), + "amount": d.get("amount", 0), + "appliedAmount": d.get("appliedAmount", 0), + "unappliedAmount": d.get("unappliedAmount", 0), + "status": d.get("status", "Processed"), + "currency": d.get("currency", "USD"), + "type": d.get("type", "External"), + "createdOn": d.get("createdOn", ""), + } def on_list_payments(req): ok, err = _require_auth(req) @@ -44,14 +87,7 @@ def on_list_payments(req): payments = [] for d in docs: - payments.append({ - "paymentId": d.get("paymentId", ""), - "paymentNumber": d.get("paymentNumber", ""), - "accountId": d.get("accountId", ""), - "amount": d.get("amount", 0), - "status": d.get("status", "Processed"), - "currency": d.get("currency", "USD"), - }) + payments.append(_payment_view(d)) payments = _apply_zuora_filters(req, payments) payments, next_cursor = _list_page(req, payments) @@ -65,6 +101,282 @@ def on_list_payments(req): resp["nextPage"] = next_cursor return respond(200, resp) +def on_get_payment(req): + ok, err = _require_auth(req) + if not ok: + return err + + key = req["params"].get("paymentId", "") + col = store_collection("payments") + for d in col.list(): + if d.get("paymentId", "") == key or d.get("paymentNumber", "") == key: + return respond(200, _payment_view(d)) + return _zuora_err(404, "50000000", "Payment not found") + +# on_create_payment creates a payment and applies it to invoices. +# +# Real semantics honored: +# - each appliedTo entry's appliedAmount must be <= the invoice's balance +# - the invoice must exist and belong to the payment's account +# - total applied must be <= the payment amount +# - invoice balance and account balance are decremented by what is applied +# - fully applied -> status "Processed"; partially applied -> +# "Processed-Partially" with the remainder in unappliedAmount +def on_create_payment(req): + ok, err = _require_auth(req) + if not ok: + return err + + body = _get_body(req) + account_key = body.get("accountKey", body.get("accountId", body.get("AccountId", ""))) + if account_key == None or account_key == "": + return _zuora_err(400, "50000040", "accountKey is required") + + account = _find_account(str(account_key)) + if account == None: + return _zuora_err(404, "50000000", "Account not found: " + str(account_key)) + + amount = _zuora_try_num(body.get("amount", body.get("Amount", 0))) + if amount == None or amount <= 0: + return _zuora_err(400, "50000040", "amount must be a positive number") + + currency = body.get("currency", account.get("currency", "USD")) + if currency == None or currency == "": + currency = "USD" + pay_type = body.get("type", "External") + if pay_type == None or pay_type == "": + pay_type = "External" + + apps = body.get("appliedTo", body.get("applyTo", body.get("AppliedTo", []))) + if apps == None: + apps = [] + if type(apps) == type({}): + apps = [apps] + + # Validate every application before mutating anything. + ic = store_collection("invoices") + resolved = [] + total_applied = 0.0 + for i in range(len(apps)): + a = apps[i] + if a == None: + continue + inv_key = a.get("invoiceId", a.get("invoiceNumber", a.get("InvoiceId", ""))) + if inv_key == None or inv_key == "": + return _zuora_err(400, "50000040", "appliedTo entries require invoiceId") + inv = _find_invoice(str(inv_key)) + if inv == None: + return _zuora_err(400, "50200010", "Invoice not found: " + str(inv_key)) + if inv.get("accountId", "") != account.get("accountId", ""): + return _zuora_err(400, "50200011", "Invoice " + str(inv_key) + " does not belong to the account") + applied = _zuora_try_num(a.get("appliedAmount", a.get("AppliedAmount", 0))) + if applied == None or applied < 0: + return _zuora_err(400, "50200020", "appliedAmount must be a non-negative number") + if applied > 0: + balance = inv.get("balance", 0) + if applied > balance and not _money_eq(applied, balance): + return _zuora_err(400, "50200020", "appliedAmount " + str(applied) + " exceeds invoice balance " + str(balance)) + if applied > balance: + applied = balance + if total_applied + applied > amount and not _money_eq(total_applied + applied, amount): + return _zuora_err(400, "50200021", "total appliedAmount exceeds payment amount") + if total_applied + applied > amount: + applied = _round2(amount - total_applied) + resolved.append([inv, applied]) + total_applied = _round2(total_applied + applied) + + pay_id = _next_id("payment") + pay_number = "PMT-SYNTH-" + str(_to_int(pay_id) - 9 * 10000 + 100) + + # Apply: decrement each invoice's balance, record the application, and + # decrement the account balance by the total applied. + for i in range(len(resolved)): + inv = resolved[i][0] + applied = resolved[i][1] + if applied <= 0: + continue + inv["balance"] = _round2(inv.get("balance", 0) - applied) + if _money_eq(inv["balance"], 0): + inv["balance"] = 0.0 + applied_payments = inv.get("appliedPayments", []) + if applied_payments == None: + applied_payments = [] + applied_payments.append({ + "paymentId": pay_id, + "paymentNumber": pay_number, + "appliedAmount": applied, + "appliedDate": _today(), + }) + inv["appliedPayments"] = applied_payments + ic.update(inv.get("id", ""), inv) + + ac = store_collection("accounts") + if total_applied > 0: + account["balance"] = _round2(account.get("balance", 0) - total_applied) + ac.update(account.get("id", ""), account) + + status = "Processed" + if not _money_eq(total_applied, amount): + status = "Processed-Partially" + + applications = [] + for i in range(len(resolved)): + inv = resolved[i][0] + applications.append({ + "invoiceId": inv.get("invoiceId", ""), + "invoiceNumber": inv.get("invoiceNumber", ""), + "appliedAmount": resolved[i][1], + }) + + doc = { + "id": pay_id, + "paymentId": pay_id, + "paymentNumber": pay_number, + "accountId": account.get("accountId", ""), + "accountNumber": account.get("accountNumber", ""), + "amount": amount, + "appliedAmount": total_applied, + "unappliedAmount": _round2(amount - total_applied), + "status": status, + "currency": currency, + "type": pay_type, + "paymentMethodId": body.get("paymentMethodId", ""), + "applications": applications, + "createdOn": _today(), + } + + pc = store_collection("payments") + pc.insert(doc) + + _emit_if_subscribed("PaymentProcessed", _callout( + "Payment", + "PaymentProcessed", + "Payment", + pay_id, + { + "PaymentNumber": pay_number, + "AccountNumber": account.get("accountNumber", ""), + "Amount": amount, + "AppliedAmount": total_applied, + "Status": status, + }, + )) + + out = _payment_view(doc) + out["success"] = True + out["id"] = pay_id + return respond(200, out) + +# on_unapply_payment reverses payment applications, restoring the invoice and +# account balances. Body: {amount} (defaults to the full applied amount) and +# an optional {invoiceId} to target one application. +def on_unapply_payment(req): + ok, err = _require_auth(req) + if not ok: + return err + + key = req["params"].get("paymentId", "") + pc = store_collection("payments") + doc = None + for d in pc.list(): + if d.get("paymentId", "") == key or d.get("paymentNumber", "") == key: + doc = d + break + if doc == None: + return _zuora_err(404, "50000000", "Payment not found") + + status = doc.get("status", "") + if status != "Processed" and status != "Processed-Partially": + return _zuora_err(400, "50300010", "Payment in status " + status + " cannot be unapplied") + + applied = doc.get("appliedAmount", 0) + if _money_eq(applied, 0): + return _zuora_err(400, "50300011", "Payment has no applied amount") + + body = _get_body(req) + requested = _zuora_try_num(body.get("amount", applied)) + if requested == None or requested <= 0: + return _zuora_err(400, "50000040", "amount must be a positive number") + if requested > applied and not _money_eq(requested, applied): + return _zuora_err(400, "50300012", "amount exceeds the payment's applied amount") + if requested > applied: + requested = applied + + only_invoice = body.get("invoiceId", body.get("invoiceNumber", "")) + if only_invoice == None: + only_invoice = "" + + remaining = requested + ic = store_collection("invoices") + applications = doc.get("applications", []) + if applications == None: + applications = [] + kept = [] + for i in range(len(applications)): + app = applications[i] + if remaining <= 0 or _money_eq(remaining, 0): + kept.append(app) + continue + if only_invoice != "" and app.get("invoiceId", "") != only_invoice and app.get("invoiceNumber", "") != only_invoice: + kept.append(app) + continue + amt = app.get("appliedAmount", 0) + take = amt + if take > remaining: + take = remaining + inv = _find_invoice(app.get("invoiceId", "")) + if inv != None: + inv["balance"] = _round2(inv.get("balance", 0) + take) + ap_list = inv.get("appliedPayments", []) + if ap_list != None: + new_list = [] + for j in range(len(ap_list)): + ap_entry = ap_list[j] + if ap_entry.get("paymentId", "") == doc.get("paymentId", "") and _money_eq(ap_entry.get("appliedAmount", 0), take): + continue + new_list.append(ap_entry) + inv["appliedPayments"] = new_list + ic.update(inv.get("id", ""), inv) + remaining = _round2(remaining - take) + new_amt = _round2(amt - take) + if new_amt > 0 and not _money_eq(new_amt, 0): + app["appliedAmount"] = new_amt + kept.append(app) + + unapplied = requested + doc["applications"] = kept + doc["appliedAmount"] = _round2(applied - unapplied) + doc["unappliedAmount"] = _round2(doc.get("unappliedAmount", 0) + unapplied) + doc["status"] = "Processed" + pc.update(doc.get("id", ""), doc) + + if unapplied > 0: + account = _find_account(doc.get("accountNumber", "")) + if account == None: + account = _find_account(doc.get("accountId", "")) + if account != None: + ac = store_collection("accounts") + account["balance"] = _round2(account.get("balance", 0) + unapplied) + ac.update(account.get("id", ""), account) + + _emit_if_subscribed("PaymentUnapplied", _callout( + "Payment", + "PaymentUnapplied", + "Payment", + doc.get("paymentId", ""), + { + "PaymentNumber": doc.get("paymentNumber", ""), + "AccountNumber": doc.get("accountNumber", ""), + "UnappliedAmount": unapplied, + "AppliedAmount": doc.get("appliedAmount", 0), + }, + )) + + out = _payment_view(doc) + out["success"] = True + out["unapplied"] = unapplied + return respond(200, out) + def on_create_payment_method(req): ok, err = _require_auth(req) if not ok: @@ -98,30 +410,78 @@ def on_create_payment_method(req): "type": "CreditCard", }) +# on_preview_billing computes the next invoice from the subscription's rate +# plans (catalog prices + chargeOverrides) instead of a hardcoded document. +# Body: {accountKey} plus an optional {subscriptionKey} to preview one +# subscription (defaults to every Active subscription on the account). def on_preview_billing(req): ok, err = _require_auth(req) if not ok: return err body = _get_body(req) - account_key = body.get("accountKey", "") - if account_key == None: - account_key = "" + account_key = body.get("accountKey", body.get("accountId", "")) + if account_key == None or account_key == "": + return _zuora_err(400, "50000040", "accountKey is required") + + account = _find_account(str(account_key)) + if account == None: + return _zuora_err(404, "50000000", "Account not found: " + str(account_key)) + + sub_key = body.get("subscriptionKey", body.get("subscriptionId", "")) + if sub_key == None: + sub_key = "" + + subs = [] + sc = store_collection("subscriptions") + if sub_key != "": + sub = _find_subscription(str(sub_key)) + if sub == None: + return _zuora_err(404, "50000000", "Subscription not found: " + str(sub_key)) + subs.append(sub) + else: + for d in sc.list(): + if d.get("accountId", "") != account.get("accountId", ""): + continue + if d.get("status", "") != "Active": + continue + subs.append(d) + + items = [] + for i in range(len(subs)): + sub = subs[i] + plans = sub.get("subscriptionPlans", []) + if plans == None: + plans = [] + for j in range(len(plans)): + charges = _plan_charge_list(plans[j]) + for k in range(len(charges)): + ch = charges[k] + amt = _round2(ch.get("price", 0) * ch.get("quantity", 0)) + tax = _round2(amt * _TAX_RATE) + items.append({ + "subscriptionNumber": sub.get("subscriptionNumber", ""), + "chargeName": ch.get("chargeName", ""), + "quantity": ch.get("quantity", 0), + "unitOfMeasure": ch.get("uom", "Each"), + "chargeAmount": amt, + "taxAmount": tax, + }) + + without_tax = 0.0 + tax_total = 0.0 + for i in range(len(items)): + without_tax = without_tax + items[i]["chargeAmount"] + tax_total = tax_total + items[i]["taxAmount"] return respond(200, { "success": True, - "accountId": account_key, - "currency": "USD", + "accountId": account.get("accountId", ""), + "currency": account.get("currency", "USD"), "invoice": { - "amount": 99.00, - "taxAmount": 9.90, - "amountWithoutTax": 89.10, - "invoiceItems": [{ - "chargeName": "Monthly Subscription", - "quantity": 1, - "unitOfMeasure": "Each", - "chargeAmount": 89.10, - "taxAmount": 9.90, - }], + "amount": _round2(without_tax + tax_total), + "taxAmount": _round2(tax_total), + "amountWithoutTax": _round2(without_tax), + "invoiceItems": items, }, }) diff --git a/adapters/zuora-style/scripts/lib.star b/adapters/zuora-style/scripts/lib.star index 6bb59108..b129eae4 100644 --- a/adapters/zuora-style/scripts/lib.star +++ b/adapters/zuora-style/scripts/lib.star @@ -115,7 +115,7 @@ def _now(): # _next_id returns a monotonically-increasing numeric ID. def _next_id(obj_type): n = store_kv_incr("zuora", obj_type + "_seq") - return str(90000 + n) + return str(9 * 10000 + n) # _get_query safely returns a query parameter value. def _get_query(req, key, default_val): @@ -445,6 +445,372 @@ def _lower(s): result = result + ch return result +# ============================================================================ +# SYNTHETIC CALENDAR (derive-on-read async pattern) +# ============================================================================ +# The simulator runs on a synthetic calendar anchored at 2024-01-01 (matching +# the seed fixtures): the first request stores the real unix time as the +# anchor, and _today() advances from 2024-01-01 in lock-step with real elapsed +# time (1 real day == 1 synthetic day). +# +# Term-boundary transitions use the derive-on-read pattern: end-of-term +# cancellations stay Active with a pending request until _today() passes the +# effective date; _advance_subscription() — called on every subscription read +# — flips the status to Canceled, persists the transition, and fires the signed +# SubscriptionCancelled callout exactly once. + +# _date_to_days converts a "YYYY-MM-DD" string to days since 1970-01-01 +# (proleptic Gregorian civil-days algorithm). +def _date_to_days(s): + y = _to_int(s[0:4]) + m = _to_int(s[5:7]) + d = _to_int(s[8:10]) + if m <= 2: + y = y - 1 + era = y // 400 + yoe = y - era * 400 + mp = (m + 9) % 12 + doy = (153 * mp + 2) // 5 + d - 1 + doe = yoe * 365 + yoe // 4 - yoe // 100 + doy + return era * (146 * 1000 + 97) + doe - (7194 * 100 + 68) + +# _days_to_date converts days since 1970-01-01 back to a "YYYY-MM-DD" string. +def _days_to_date(z): + z = z + (7194 * 100 + 68) + era = z // (146 * 1000 + 97) + doe = z - era * (146 * 1000 + 97) + yoe = (doe - doe // (146 * 10) + doe // (365 * 100 + 24) - doe // (146 * 1000 + 96)) // 365 + y = yoe + era * 400 + doy = doe - (365 * yoe + yoe // 4 - yoe // 100) + mp = (5 * doy + 2) // 153 + d = doy - (153 * mp + 2) // 5 + 1 + m = mp + 3 + if mp >= 10: + m = mp - 9 + if m <= 2: + y = y + 1 + return _pad(y, 4) + "-" + _pad(m, 2) + "-" + _pad(d, 2) + +# _pad zero-pads a non-negative number to the given width (floats from JSON +# bodies are coerced to int first). +def _pad(n, width): + if type(n) != type(0): + n = int(n) + s = str(n) + while len(s) < width: + s = "0" + s + return s + +# _add_days returns date_str shifted by n days (n may be negative). +def _add_days(date_str, n): + return _days_to_date(_date_to_days(date_str) + n) + +# _today returns the synthetic current date. The KV anchor (set on first use) +# maps real unix time onto the synthetic calendar starting at 2024-01-01. +def _today(): + anchor = store_kv_get("zuora", "clock_anchor") + if anchor == None: + anchor = str(clock.now_unix()) + store_kv_set("zuora", "clock_anchor", anchor) + elapsed = clock.now_unix() - _to_int(anchor) + if elapsed < 0: + elapsed = 0 + return _days_to_date(_CLOCK_BASE_DAYS + elapsed // (864 * 100)) + +# ============================================================================ +# PRODUCT CATALOG (rate plan pricing) +# ============================================================================ +# Billing amounts are computed from a seeded rate-plan catalog so invoices, +# billing previews, and cancellation credits all derive from the rate plans +# instead of hardcoded numbers. Entries are "name|price|uom|quantity|period". + +# _seed_catalog inserts the static mock catalog once (guarded by a KV flag). +def _seed_catalog(): + if store_kv_get("zuora", "catalog_seeded") == "yes": + return + store_kv_set("zuora", "catalog_seeded", "yes") + store_kv_set("zuora", "cat:rateplan-standard", "Standard Plan|99|Each|1|Month") + store_kv_set("zuora", "cat:rateplan-growth", "Growth Plan|249|Each|1|Month") + store_kv_set("zuora", "cat:rateplan-enterprise", "Enterprise Plan|499|Each|1|Month") + +# _catalog_plan looks up a product rate plan by id and returns +# {productRatePlanName, price, uom, quantity, billingPeriod} or None. +def _catalog_plan(plan_id): + _seed_catalog() + raw = store_kv_get("zuora", "cat:" + plan_id) + if raw == None: + return None + parts = _split(raw, "|") + price = _zuora_try_num(parts[1]) + if price == None: + price = 0.0 + qty = _zuora_try_num(parts[3]) + if qty == None or qty <= 0: + qty = 1 + return { + "productRatePlanName": parts[0], + "price": price, + "uom": parts[2], + "quantity": qty, + "billingPeriod": parts[4], + } + +# _plan_charges builds the charge list for one subscribeToRatePlans entry. +# `cat` is the catalog plan (None only when the entry carries a full price +# override). chargeOverrides (Zuora's pricing override surface) win over the +# catalog: price / pricing[0].price and quantity are honored. +def _plan_charges(entry, cat): + name = entry.get("productRatePlanName", "") + if name == None: + name = "" + price = 0.0 + qty = 1 + uom = "Each" + if cat != None: + if name == "": + name = cat["productRatePlanName"] + price = cat["price"] + qty = cat["quantity"] + uom = cat["uom"] + overrides = entry.get("chargeOverrides", []) + if overrides == None: + overrides = [] + for i in range(len(overrides)): + o = overrides[i] + if o == None: + continue + pr = o.get("price", None) + if pr == None: + pricing = o.get("pricing", []) + if pricing != None and len(pricing) > 0: + pr = pricing[0].get("price", None) + if pr != None: + n = _zuora_try_num(pr) + if n != None and n >= 0: + price = n + q = o.get("quantity", None) + if q != None: + n = _zuora_try_num(q) + if n != None and n > 0: + qty = n + if name == "": + name = entry.get("productRatePlanId", "Rate Plan") + return [{ + "chargeName": name, + "chargeModel": "Flat Fee", + "chargeType": "Recurring", + "uom": uom, + "quantity": qty, + "listPrice": price, + "price": price, + }] + +# _plan_charge_list returns a plan's charges. Plans persisted before charge +# tracking (e.g. the seed fixtures) get their charges derived from the +# catalog by productRatePlanId. +def _plan_charge_list(plan): + charges = plan.get("charges", None) + if charges != None and len(charges) > 0: + return charges + prp_id = plan.get("productRatePlanId", "") + if prp_id == "": + return [] + cat = _catalog_plan(prp_id) + if cat == None: + return [] + return _plan_charges(plan, cat) + +# _charges_total returns the pre-tax total of a charge list. +def _charges_total(charges): + total = 0.0 + for i in range(len(charges)): + ch = charges[i] + total = total + ch.get("price", 0) * ch.get("quantity", 0) + return _round2(total) + +# ============================================================================ +# MONEY +# ============================================================================ + +# Default mock tax rate (10% of the pre-tax charge amount). +_TAX_RATE = 0.1 + +# _round2 rounds to 2 decimal places (half away from zero). +def _round2(x): + neg = x < 0 + if neg: + x = -x + cents = int(x * 100 + 0.5) + out = cents / 100.0 + if neg: + out = -out + return out + +# _money_eq compares two amounts for equality at cent precision. +def _money_eq(a, b): + d = a - b + if d < 0: + d = -d + return d < 0.005 + +# ============================================================================ +# SHARED FINDERS +# ============================================================================ + +# _find_account returns the account doc matching accountId or accountNumber, +# or None. +def _find_account(key): + col = store_collection("accounts") + for a in col.list(): + if a.get("accountId", "") == key or a.get("accountNumber", "") == key: + return a + return None + +# _find_invoice returns the invoice doc matching invoiceId or invoiceNumber, +# or None. +def _find_invoice(key): + col = store_collection("invoices") + for d in col.list(): + if d.get("invoiceId", "") == key or d.get("invoiceNumber", "") == key: + return d + return None + +# _find_subscription returns the subscription doc matching subscriptionId or +# subscriptionNumber, or None. +def _find_subscription(key): + col = store_collection("subscriptions") + for d in col.list(): + if d.get("subscriptionId", "") == key or d.get("subscriptionNumber", "") == key: + return d + return None + +# _advance_subscription derives end-of-term cancellations on read: when a +# pending cancellation's effective date has passed (synthetic clock), the +# subscription flips to Canceled, the transition is persisted, and the signed +# SubscriptionCancelled callout fires exactly once. Returns the (possibly +# updated) doc. +def _advance_subscription(doc): + if doc.get("cancellationRequested", False) != True: + return doc + if doc.get("status", "") != "Active": + return doc + eff = doc.get("cancellationEffectiveDate", "") + if eff == "": + return doc + if _date_to_days(_today()) < _date_to_days(eff): + return doc + doc["status"] = "Canceled" + doc["cancelledAt"] = _today() + doc["cancellationRequested"] = False + col = store_collection("subscriptions") + col.update(doc.get("id", ""), doc) + _emit_if_subscribed("SubscriptionCancelled", _callout( + "Subscription", + "SubscriptionCancelled", + "Subscription", + doc.get("subscriptionId", ""), + { + "SubscriptionNumber": doc.get("subscriptionNumber", ""), + "AccountNumber": doc.get("accountNumber", ""), + "Status": "Canceled", + "CancellationPolicy": doc.get("cancellationPolicy", "EndOfTerm"), + }, + )) + return doc + +# _deep_merge merges src into dst in place: dicts merge recursively (loop- +# based, no recursion), all other values replace. Returns dst. +def _deep_merge(dst, src): + stack = [[dst, src]] + while len(stack) > 0: + pair = stack[len(stack) - 1] + stack = stack[:len(stack) - 1] + target = pair[0] + overlay = pair[1] + for k in overlay: + v = overlay[k] + if k in target and type(target[k]) == type({}) and type(v) == type({}): + stack.append([target[k], v]) + else: + target[k] = v + return dst + +# ============================================================================ +# INVOICE GENERATION +# ============================================================================ +# Subscription creation generates the first invoice from the subscription's +# rate plan charges: one invoice item per charge, 10% tax, Net-30 due date, +# balance == amount, status Posted. The account balance is incremented. + +# _create_invoice_for_subscription materializes and persists the first invoice +# for a (newly created or amended) subscription doc, updates the account +# balance, and returns the invoice doc. +def _create_invoice_for_subscription(sub_doc, account): + items = [] + plans = sub_doc.get("subscriptionPlans", []) + if plans == None: + plans = [] + for i in range(len(plans)): + plan = plans[i] + charges = plan.get("charges", []) + if charges == None: + charges = [] + for j in range(len(charges)): + ch = charges[j] + amt = _round2(ch.get("price", 0) * ch.get("quantity", 0)) + tax = _round2(amt * _TAX_RATE) + items.append({ + "chargeName": ch.get("chargeName", ""), + "quantity": ch.get("quantity", 0), + "uom": ch.get("uom", "Each"), + "chargeAmount": amt, + "taxAmount": tax, + "amountWithoutTax": amt, + "productRatePlanId": plan.get("productRatePlanId", ""), + }) + + without_tax = 0.0 + tax_total = 0.0 + for i in range(len(items)): + without_tax = without_tax + items[i]["chargeAmount"] + tax_total = tax_total + items[i]["taxAmount"] + without_tax = _round2(without_tax) + tax_total = _round2(tax_total) + amount = _round2(without_tax + tax_total) + + invoice_id = _next_id("invoice") + invoice_number = "INV-SYNTH-" + str(_to_int(invoice_id) - 9 * 10000 + 100) + invoice_date = sub_doc.get("contractEffectiveDate", "2024-01-01") + + doc = { + "id": invoice_id, + "invoiceId": invoice_id, + "invoiceNumber": invoice_number, + "accountId": account.get("accountId", ""), + "accountNumber": account.get("accountNumber", ""), + "subscriptionId": sub_doc.get("subscriptionId", ""), + "subscriptionNumber": sub_doc.get("subscriptionNumber", ""), + "amount": amount, + "amountWithoutTax": without_tax, + "taxAmount": tax_total, + "balance": amount, + "status": "Posted", + "invoiceDate": invoice_date, + "dueDate": _add_days(invoice_date, 30), + "currency": account.get("currency", "USD"), + "invoiceItems": items, + "appliedPayments": [], + } + + ic = store_collection("invoices") + ic.insert(doc) + + ac = store_collection("accounts") + account["balance"] = _round2(account.get("balance", 0) + amount) + ac.update(account.get("id", ""), account) + + return doc + # _parse_zoql parses a ZOQL query string and returns the object type and # optional WHERE clause components. # Format: "select from [where ]" @@ -569,3 +935,7 @@ def _emit_if_subscribed(event_type, payload): if len(types) == 0 or event_type in types or "*" in types: _signed_emit(event_type, payload, h.get("secret", "")) return + +# Synthetic-calendar epoch (2024-01-01) in civil days. Assigned at the end of +# the file so _date_to_days is already defined at preload time. +_CLOCK_BASE_DAYS = _date_to_days("2024-01-01") diff --git a/adapters/zuora-style/scripts/query.star b/adapters/zuora-style/scripts/query.star index dbf4d9d8..d97f9e2f 100644 --- a/adapters/zuora-style/scripts/query.star +++ b/adapters/zuora-style/scripts/query.star @@ -38,6 +38,9 @@ def on_query(req): elif obj_lower == "subscription": col = store_collection("subscriptions") for d in col.list(): + # Derive-on-read: advance pending end-of-term cancellations so ZOQL + # results reflect the synthetic calendar. + d = _advance_subscription(d) records.append(_project(d, fields, "Subscription")) elif obj_lower == "invoice": col = store_collection("invoices") diff --git a/adapters/zuora-style/scripts/subscriptions.star b/adapters/zuora-style/scripts/subscriptions.star index 91f2a8f0..12c61384 100644 --- a/adapters/zuora-style/scripts/subscriptions.star +++ b/adapters/zuora-style/scripts/subscriptions.star @@ -1,9 +1,21 @@ # Subscription handlers — Zuora Billing subscriptions. # -# GET /v1/subscriptions/{key} -> get subscription -# POST /v1/subscriptions -> create subscription (complex billing model) -# PUT /v1/subscriptions/{key} -> update/renew subscription -# POST /v1/subscriptions/{key}/cancel -> cancel subscription +# GET /v1/subscriptions/{key} -> get subscription (advances pending +# end-of-term cancellations on read) +# POST /v1/subscriptions -> create subscription: validates the +# account + rate plans, computes the +# term, generates the FIRST INVOICE +# from the rate plan charges, and +# increments the account balance +# PUT /v1/subscriptions/{key} -> update: add/remove rate plans, change +# terms, deep-merge everything else +# POST /v1/subscriptions/{key}/cancel -> cancel honoring cancellationPolicy: +# EndOfTerm (default) keeps the +# subscription Active until the term +# ends (derive-on-read flip to +# Canceled); Immediate cancels now and +# issues a prorated credit memo for +# the uninvoiced remainder # Shared helpers from lib.star. @@ -16,18 +28,27 @@ def _subscription_shape(doc): if plans == None: plans = [] - return { + out = { "subscriptionId": doc.get("subscriptionId", doc.get("id", "")), "subscriptionNumber": doc.get("subscriptionNumber", ""), "accountNumber": doc.get("accountNumber", ""), "accountId": doc.get("accountId", ""), "status": doc.get("status", "Active"), "termType": doc.get("termType", "TERMED"), + "initialTerm": doc.get("initialTerm", 12), "contractEffectiveDate": doc.get("contractEffectiveDate", ""), "startDate": doc.get("startDate", ""), "endDate": end_date, "subscriptionPlans": plans, } + custom = doc.get("customFields", None) + if custom != None: + out["customFields"] = custom + policy = doc.get("cancellationPolicy", "") + if policy != "" and out["status"] == "Active": + out["cancellationPolicy"] = policy + out["cancellationEffectiveDate"] = doc.get("cancellationEffectiveDate", "") + return out def on_get_subscription(req): ok, err = _require_auth(req) @@ -35,14 +56,14 @@ def on_get_subscription(req): return err sub_key = req["params"].get("subscriptionKey", "") - col = store_collection("subscriptions") - docs = col.list() - - for d in docs: - if d.get("subscriptionId", "") == sub_key or d.get("subscriptionNumber", "") == sub_key: - return respond(200, _subscription_shape(d)) + doc = _find_subscription(sub_key) + if doc == None: + return _zuora_err(404, "50000000", "Subscription not found") - return _zuora_err(404, "50000000", "Subscription not found") + # Derive-on-read: flip pending end-of-term cancellations that have taken + # effect on the synthetic calendar. + doc = _advance_subscription(doc) + return respond(200, _subscription_shape(doc)) def on_create_subscription(req): ok, err = _require_auth(req) @@ -50,41 +71,69 @@ def on_create_subscription(req): return err body = _get_body(req) - account_key = body.get("accountKey", "") + account_key = body.get("accountKey", body.get("accountId", "")) if account_key == None: account_key = "" # Look up the account. - ac = store_collection("accounts") - adocs = ac.list() - account = None - for a in adocs: - if a.get("accountId", "") == account_key or a.get("accountNumber", "") == account_key: - account = a - break - + account = _find_account(account_key) if account == None: return _zuora_err(400, "50000001", "Account not found: " + account_key) - sub_id = _next_id("subscription") - sub_number = "SUB-SYNTH-" + str(_to_int(sub_id) - 90000 + 100) - - # Build subscription plans from the request. + # Validate the rate plans against the catalog. plans = body.get("subscribeToRatePlans", []) if plans == None: plans = [] + if len(plans) == 0: + return _zuora_err(400, "50000040", "subscribeToRatePlans is required and must not be empty") + sub_plans = [] for i in range(len(plans)): p = plans[i] + if p == None: + continue + prp_id = p.get("productRatePlanId", "") + cat = None + ov = p.get("chargeOverrides", None) + has_override = ov != None and len(ov) > 0 + if prp_id != "": + cat = _catalog_plan(prp_id) + if cat == None and not has_override: + return _zuora_err(400, "51000010", "ProductRatePlan not found: " + prp_id) + charges = _plan_charges(p, cat) + name = cat["productRatePlanName"] if cat != None else p.get("productRatePlanName", "Rate Plan") sub_plans.append({ "id": "plan-" + str(i + 1), - "productRatePlanId": p.get("productRatePlanId", ""), - "productRatePlanName": p.get("productRatePlanName", "Standard Plan"), + "productRatePlanId": prp_id, + "productRatePlanName": name, + "charges": charges, }) + # Term: TERMED (default 12 months, or an explicit termEndDate) vs EVERGREEN. term_type = body.get("termType", "TERMED") if term_type == None: term_type = "TERMED" + initial_term = body.get("initialTerm", 12) + if initial_term == None: + initial_term = 12 + itn = _zuora_try_num(initial_term) + if itn == None or itn <= 0: + itn = 12 + itn = int(itn) + + ced = body.get("contractEffectiveDate", "2024-01-01") + if ced == None or ced == "": + ced = "2024-01-01" + + end_date = body.get("termEndDate", None) + if end_date == None or end_date == "": + if term_type == "EVERGREEN": + end_date = None + else: + end_date = _add_days(ced, itn * 30) + + sub_id = _next_id("subscription") + sub_number = "SUB-SYNTH-" + str(_to_int(sub_id) - 9 * 10000 + 100) doc = { "id": sub_id, @@ -94,17 +143,23 @@ def on_create_subscription(req): "accountId": account.get("accountId", ""), "status": "Active", "termType": term_type, - "contractEffectiveDate": body.get("contractEffectiveDate", "2024-01-01"), - "startDate": body.get("contractEffectiveDate", "2024-01-01"), - "endDate": body.get("termEndDate", None), + "initialTerm": itn, + "contractEffectiveDate": ced, + "startDate": ced, + "endDate": end_date, "subscriptionPlans": sub_plans, + "customFields": {}, + "cancellationRequested": False, } col = store_collection("subscriptions") col.insert(doc) - # Webhook callout: SubscriptionActivated (subscriptions are Active on - # creation in this simulator). + # Generate the first invoice from the rate plan charges and post it to + # the account balance. + invoice = _create_invoice_for_subscription(doc, account) + + # Webhook callouts: SubscriptionActivated + InvoicePosted. _emit_if_subscribed("SubscriptionActivated", _callout( "Subscription", "SubscriptionActivated", @@ -117,6 +172,19 @@ def on_create_subscription(req): "TermType": term_type, }, )) + _emit_if_subscribed("InvoicePosted", _callout( + "Invoice", + "InvoicePosted", + "Invoice", + invoice.get("invoiceId", ""), + { + "InvoiceNumber": invoice.get("invoiceNumber", ""), + "AccountNumber": doc.get("accountNumber", ""), + "Amount": invoice.get("amount", 0), + "Balance": invoice.get("balance", 0), + "Status": "Posted", + }, + )) return respond(200, { "success": True, @@ -124,6 +192,13 @@ def on_create_subscription(req): "subscriptionNumber": sub_number, "accountNumber": account.get("accountNumber", ""), "status": "Active", + "termType": term_type, + "initialTerm": itn, + "contractEffectiveDate": ced, + "endDate": end_date, + "invoiceId": invoice.get("invoiceId", ""), + "invoiceNumber": invoice.get("invoiceNumber", ""), + "invoiceAmount": invoice.get("amount", 0), }) def on_update_subscription(req): @@ -132,48 +207,136 @@ def on_update_subscription(req): return err sub_key = req["params"].get("subscriptionKey", "") - col = store_collection("subscriptions") - docs = col.list() - - doc = None - for d in docs: - if d.get("subscriptionId", "") == sub_key or d.get("subscriptionNumber", "") == sub_key: - doc = d - break - + doc = _find_subscription(sub_key) if doc == None: return _zuora_err(404, "50000000", "Subscription not found") + doc = _advance_subscription(doc) + if doc.get("status", "") != "Active": + return _zuora_err(400, "50000050", "Subscription is not active: " + doc.get("status", "")) + body = _get_body(req) - updated = {} - for k in doc: - updated[k] = doc[k] - if "contractEffectiveDate" in body: - updated["contractEffectiveDate"] = body.get("contractEffectiveDate") + # --- add/remove rate plans (real amendment semantics) --- + added = [] + add_plans = body.get("addRatePlans", []) + if add_plans == None: + add_plans = [] + existing = doc.get("subscriptionPlans", []) + if existing == None: + existing = [] + next_idx = len(existing) + 1 + + for i in range(len(add_plans)): + p = add_plans[i] + if p == None: + continue + prp_id = p.get("productRatePlanId", "") + cat = None + ov = p.get("chargeOverrides", None) + has_override = ov != None and len(ov) > 0 + if prp_id != "": + cat = _catalog_plan(prp_id) + if cat == None and not has_override: + return _zuora_err(400, "51000010", "ProductRatePlan not found: " + prp_id) + charges = _plan_charges(p, cat) + name = cat["productRatePlanName"] if cat != None else p.get("productRatePlanName", "Rate Plan") + plan_doc = { + "id": "plan-" + str(next_idx), + "productRatePlanId": prp_id, + "productRatePlanName": name, + "charges": charges, + } + existing.append(plan_doc) + added.append(plan_doc["id"]) + next_idx = next_idx + 1 + + removed = [] + remove_plans = body.get("removeRatePlans", []) + if remove_plans == None: + remove_plans = [] + if len(remove_plans) > 0: + kept = [] + for i in range(len(existing)): + plan = existing[i] + drop = False + for j in range(len(remove_plans)): + r = remove_plans[j] + if r == plan.get("id", "") or r == plan.get("productRatePlanId", "") or r == plan.get("productRatePlanName", ""): + drop = True + break + if drop: + removed.append(plan.get("id", "")) + else: + kept.append(plan) + existing = kept + + doc["subscriptionPlans"] = existing + + # --- term changes --- if "termType" in body: - updated["termType"] = body.get("termType") + doc["termType"] = body.get("termType") + if "initialTerm" in body: + itn = _zuora_try_num(body.get("initialTerm")) + if itn != None and itn > 0: + doc["initialTerm"] = int(itn) + if "termEndDate" in body and body.get("termEndDate") != None and body.get("termEndDate") != "": + doc["endDate"] = body.get("termEndDate") + elif "initialTerm" in body and doc.get("termType", "TERMED") == "TERMED": + itn = _zuora_try_num(body.get("initialTerm")) + if itn != None and itn > 0: + doc["endDate"] = _add_days(doc.get("contractEffectiveDate", "2024-01-01"), int(itn) * 30) + if "contractEffectiveDate" in body and body.get("contractEffectiveDate") != None: + doc["contractEffectiveDate"] = body.get("contractEffectiveDate") + + # --- everything else: deep-merge into customFields --- + consumed = ["addRatePlans", "removeRatePlans", "termType", "initialTerm", "termEndDate", "contractEffectiveDate", "renewalSetting", "notes", "status", "customFields"] + custom = doc.get("customFields", {}) + if custom == None: + custom = {} + if "customFields" in body: + cf = body.get("customFields", {}) + if cf != None: + _deep_merge(custom, cf) + extras = {} + for k in body: + if k in consumed: + continue + extras[k] = body[k] + if "notes" in body: + extras["notes"] = body.get("notes") + if "renewalSetting" in body: + extras["renewalSetting"] = body.get("renewalSetting") + if len(extras) > 0: + _deep_merge(custom, extras) + doc["customFields"] = custom - col.update(doc.get("id", ""), updated) + col = store_collection("subscriptions") + col.update(doc.get("id", ""), doc) # Webhook callout: SubscriptionUpdated. _emit_if_subscribed("SubscriptionUpdated", _callout( "Subscription", "SubscriptionUpdated", "Subscription", - updated.get("subscriptionId", ""), + doc.get("subscriptionId", ""), { - "SubscriptionNumber": updated.get("subscriptionNumber", ""), - "AccountNumber": updated.get("accountNumber", ""), - "Status": updated.get("status", "Active"), + "SubscriptionNumber": doc.get("subscriptionNumber", ""), + "AccountNumber": doc.get("accountNumber", ""), + "Status": doc.get("status", "Active"), + "AddedRatePlans": added, + "RemovedRatePlans": removed, }, )) return respond(200, { "success": True, - "subscriptionId": updated.get("subscriptionId", ""), - "subscriptionNumber": updated.get("subscriptionNumber", ""), - "status": updated.get("status", "Active"), + "subscriptionId": doc.get("subscriptionId", ""), + "subscriptionNumber": doc.get("subscriptionNumber", ""), + "status": doc.get("status", "Active"), + "addedRatePlans": added, + "removedRatePlans": removed, + "subscriptionPlans": doc.get("subscriptionPlans", []), }) def on_cancel_subscription(req): @@ -182,41 +345,195 @@ def on_cancel_subscription(req): return err sub_key = req["params"].get("subscriptionKey", "") - col = store_collection("subscriptions") - docs = col.list() - - doc = None - for d in docs: - if d.get("subscriptionId", "") == sub_key or d.get("subscriptionNumber", "") == sub_key: - doc = d - break - + doc = _find_subscription(sub_key) if doc == None: return _zuora_err(404, "50000000", "Subscription not found") - updated = {} - for k in doc: - updated[k] = doc[k] - updated["status"] = "Canceled" + doc = _advance_subscription(doc) + if doc.get("status", "") != "Active": + return _zuora_err(400, "50000050", "Subscription is not active: " + doc.get("status", "")) - col.update(doc.get("id", ""), updated) + body = _get_body(req) - # Webhook callout: SubscriptionCancelled. - _emit_if_subscribed("SubscriptionCancelled", _callout( - "Subscription", - "SubscriptionCancelled", - "Subscription", - updated.get("subscriptionId", ""), - { - "SubscriptionNumber": updated.get("subscriptionNumber", ""), - "AccountNumber": updated.get("accountNumber", ""), - "Status": "Canceled", - }, - )) + # Normalize the policy: EndOfTerm (Zuora default) | Immediate. + policy = body.get("cancellationPolicy", body.get("cancellation_policy", "EndOfTerm")) + if policy == None or policy == "": + policy = "EndOfTerm" + pol = _lower(str(policy)) + if pol == "endofterm" or pol == "end_of_term": + policy = "EndOfTerm" + elif pol == "immediate": + policy = "Immediate" + elif pol == "specificdate": + policy = "SpecificDate" + else: + return _zuora_err(400, "50000051", "Invalid cancellationPolicy: " + str(policy)) + + today = _today() + + if policy == "EndOfTerm" or policy == "SpecificDate": + # EndOfTerm: stays Active until the term ends (the flip to Canceled is + # derived on read by _advance_subscription). EVERGREEN subscriptions + # have no term end, so Zuora rejects the policy. + if policy == "EndOfTerm" and doc.get("termType", "TERMED") == "EVERGREEN": + return _zuora_err(400, "50000052", "cancellationPolicy EndOfTerm is not supported for EVERGREEN subscriptions") + eff = doc.get("endDate", None) + if policy == "SpecificDate": + eff = body.get("specificCancellationDate", body.get("cancellationEffectiveDate", None)) + if eff == None or eff == "": + return _zuora_err(400, "50000053", "specificCancellationDate is required for cancellationPolicy SpecificDate") + if eff == None or eff == "": + eff = today + doc["cancellationRequested"] = True + doc["cancellationPolicy"] = policy + doc["cancellationEffectiveDate"] = eff + doc["cancelRequestedAt"] = today + else: + # Immediate: cancel now and, for TERMED subscriptions with term + # remaining, issue a prorated credit memo for the uninvoiced remainder. + doc["status"] = "Canceled" + doc["cancellationRequested"] = False + doc["cancellationPolicy"] = "Immediate" + doc["cancellationEffectiveDate"] = today + doc["cancelledAt"] = today + + col = store_collection("subscriptions") + col.update(doc.get("id", ""), doc) + + credit_number = "" + credit_amount = 0.0 + if doc.get("cancellationPolicy", "") == "Immediate": + credit = _cancellation_credit(doc, today) + if credit != None: + credit_number = credit.get("creditMemoNumber", "") + # Report the credit as a positive amount (the memo doc itself is + # negative-amount). + credit_amount = -credit.get("amount", 0.0) + + # Webhook callout: SubscriptionCancelled fires immediately for Immediate; + # for EndOfTerm it fires when _advance_subscription flips the status. + if doc.get("status", "") == "Canceled": + _emit_if_subscribed("SubscriptionCancelled", _callout( + "Subscription", + "SubscriptionCancelled", + "Subscription", + doc.get("subscriptionId", ""), + { + "SubscriptionNumber": doc.get("subscriptionNumber", ""), + "AccountNumber": doc.get("accountNumber", ""), + "Status": "Canceled", + "CancellationPolicy": "Immediate", + "CancellationEffectiveDate": today, + }, + )) return respond(200, { "success": True, - "subscriptionId": updated.get("subscriptionId", ""), - "subscriptionNumber": updated.get("subscriptionNumber", ""), - "status": "Canceled", + "subscriptionId": doc.get("subscriptionId", ""), + "subscriptionNumber": doc.get("subscriptionNumber", ""), + "status": doc.get("status", "Active"), + "cancellationPolicy": doc.get("cancellationPolicy", ""), + "cancellationEffectiveDate": doc.get("cancellationEffectiveDate", ""), + "creditMemoNumber": credit_number, + "creditMemoAmount": credit_amount, }) + +# _cancellation_credit issues a prorated credit memo for the uninvoiced +# remainder of a TERMED subscription cancelled mid-term (Immediate policy). +# The credit is the charge total scaled by remaining term days / total term +# days. Returns the credit-memo invoice doc or None when nothing is owed. +def _cancellation_credit(doc, today): + if doc.get("termType", "TERMED") != "TERMED": + return None + end = doc.get("endDate", None) + if end == None or end == "": + return None + end_days = _date_to_days(end) + today_days = _date_to_days(today) + if end_days <= today_days: + return None + start_days = _date_to_days(doc.get("startDate", doc.get("contractEffectiveDate", "2024-01-01"))) + total_days = end_days - start_days + if total_days <= 0: + return None + + charges = [] + total = 0.0 + plans = doc.get("subscriptionPlans", []) + if plans == None: + plans = [] + for i in range(len(plans)): + plan_charges = plans[i].get("charges", []) + if plan_charges == None: + continue + for j in range(len(plan_charges)): + ch = plan_charges[j] + amt = _round2(ch.get("price", 0) * ch.get("quantity", 0) * (end_days - today_days) / total_days) + if amt <= 0: + continue + charges.append({ + "chargeName": ch.get("chargeName", ""), + "quantity": ch.get("quantity", 0), + "uom": ch.get("uom", "Each"), + "chargeAmount": -amt, + "taxAmount": 0, + "amountWithoutTax": -amt, + "productRatePlanId": plans[i].get("productRatePlanId", ""), + }) + total = total + amt + if total <= 0: + return None + + account = _find_account(doc.get("accountNumber", "")) + if account == None: + account = _find_account(doc.get("accountId", "")) + currency = "USD" + if account != None: + currency = account.get("currency", "USD") + + # Credit memos live in the invoices collection (negative-amount docs), so + # they draw from the invoice id/number sequence with a CM- prefix. + credit_id = _next_id("invoice") + credit_seq = str(_to_int(credit_id) - 9 * 10000 + 100) + credit = { + "id": credit_id, + "invoiceId": credit_id, + "invoiceNumber": "CM-SYNTH-" + credit_seq, + "creditMemoNumber": "CM-SYNTH-" + credit_seq, + "accountId": doc.get("accountId", ""), + "accountNumber": doc.get("accountNumber", ""), + "subscriptionId": doc.get("subscriptionId", ""), + "subscriptionNumber": doc.get("subscriptionNumber", ""), + "amount": -_round2(total), + "amountWithoutTax": -_round2(total), + "taxAmount": 0, + "balance": -_round2(total), + "status": "Posted", + "type": "CreditMemo", + "invoiceDate": today, + "dueDate": today, + "currency": currency, + "invoiceItems": charges, + "appliedPayments": [], + } + ic = store_collection("invoices") + ic.insert(credit) + + if account != None: + ac = store_collection("accounts") + account["balance"] = _round2(account.get("balance", 0) - total) + ac.update(account.get("id", ""), account) + + _emit_if_subscribed("CreditMemoPosted", _callout( + "CreditMemo", + "CreditMemoPosted", + "CreditMemo", + credit_id, + { + "CreditMemoNumber": credit.get("creditMemoNumber", ""), + "AccountNumber": doc.get("accountNumber", ""), + "Amount": credit.get("amount", 0), + "Status": "Posted", + }, + )) + return credit diff --git a/internal/engine/adyen_style_test.go b/internal/engine/adyen_style_test.go index 235b75ff..f00a1b72 100644 --- a/internal/engine/adyen_style_test.go +++ b/internal/engine/adyen_style_test.go @@ -232,8 +232,465 @@ func TestAdyenStyleAdapter(t *testing.T) { } } +// TestAdyenStyleAdapter3DS covers the /payments/details 3DS2 flow and the +// modification balance validation, payment methods, and payment links. +func TestAdyenStyleAdapter3DS(t *testing.T) { + adapterDir := filepath.Join("..", "..", "adapters", "adyen-style") + absAdapterDir, err := filepath.Abs(adapterDir) + if err != nil { + t.Fatal(err) + } + + stateDir := t.TempDir() + manifestPath := filepath.Join(stateDir, "stunt.yaml") + + m := &manifest.Manifest{ + Path: manifestPath, + Version: 1, + Network: manifest.Network{Mode: "port", BasePort: 0}, + Services: map[string]manifest.Service{ + "adyen": {Adapter: absAdapterDir}, + }, + } + + e, err := New(m) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + defer e.Close() + + addrs, cancel, err := e.ServeForTest(context.Background()) + if err != nil { + t.Fatalf("ServeForTest: %v", err) + } + defer cancel() + time.Sleep(50 * time.Millisecond) + + base := addrs["adyen"] + const apiKey = "AQEyhmfxK....LRGhARAYZ" + + payBody := func(number, reference string, extra map[string]any) map[string]any { + payload := map[string]any{ + "merchantAccount": "TestMerchant", + "amount": map[string]any{"value": 1000, "currency": "USD"}, + "reference": reference, + "paymentMethod": map[string]any{ + "type": "scheme", + "number": number, + "expiryMonth": "03", + "expiryYear": "2030", + "cvc": "737", + }, + "returnUrl": "https://shop.test/return", + } + for k, v := range extra { + payload[k] = v + } + return payload + } + + // ===== 3DS2: fingerprint → authorised ===== + + body, status := adPostJSON(t, base+"/v68/payments", apiKey, payBody("4111111140000069", "ref-3ds-ok", nil)) + if status != 200 { + t.Fatalf("3ds payments -> %d, want 200; body %s", status, body) + } + var identify map[string]any + if err := json.Unmarshal([]byte(body), &identify); err != nil { + t.Fatalf("unmarshal identify resp: %v (body %s)", err, body) + } + if identify["resultCode"] != "IdentifyShopper" { + t.Fatalf("3ds resultCode = %v, want IdentifyShopper", identify["resultCode"]) + } + if identify["pspReference"] != nil { + t.Fatalf("3ds pending pspReference = %v, want absent until final result", identify["pspReference"]) + } + action, ok := identify["action"].(map[string]any) + if !ok { + t.Fatalf("3ds action = %v, want object", identify["action"]) + } + if action["type"] != "threeDS2" { + t.Fatalf("3ds action.type = %v, want threeDS2", action["type"]) + } + if action["subtype"] != "fingerprint" { + t.Fatalf("3ds action.subtype = %v, want fingerprint", action["subtype"]) + } + paymentData, ok := action["paymentData"].(string) + if !ok || paymentData == "" { + t.Fatalf("3ds action.paymentData = %v, want non-empty", action["paymentData"]) + } + + body, status = adPostJSON(t, base+"/v68/payments/details", apiKey, map[string]any{ + "paymentData": paymentData, + "details": map[string]any{"threeds2.fingerprint": "fp-abc"}, + }) + if status != 200 { + t.Fatalf("3ds details -> %d, want 200; body %s", status, body) + } + var authed map[string]any + if err := json.Unmarshal([]byte(body), &authed); err != nil { + t.Fatalf("unmarshal authed resp: %v (body %s)", err, body) + } + if authed["resultCode"] != "Authorised" { + t.Fatalf("3ds final resultCode = %v, want Authorised", authed["resultCode"]) + } + if finalPsp, ok := authed["pspReference"].(string); !ok || finalPsp == "" { + t.Fatalf("3ds final pspReference = %v, want non-empty", authed["pspReference"]) + } + + // ===== 3DS2 failure path: unknown paymentData → 422 ===== + + body, status = adPostJSON(t, base+"/v68/payments/details", apiKey, map[string]any{ + "paymentData": "PDnotatoken", + "details": map[string]any{"threeds2.fingerprint": "fp-abc"}, + }) + if status != 422 { + t.Fatalf("3ds details unknown paymentData -> %d, want 422; body %s", status, body) + } + var errResp map[string]any + if err := json.Unmarshal([]byte(body), &errResp); err != nil { + t.Fatalf("unmarshal 3ds error: %v (body %s)", err, body) + } + if errResp["errorType"] != "validation" { + t.Fatalf("3ds error errorType = %v, want validation", errResp["errorType"]) + } + + // ===== 3DS2: challenge round → challengeResult → authorised ===== + + body, status = adPostJSON(t, base+"/v68/payments", apiKey, payBody("4111111140000081", "ref-3ds-chal", nil)) + if status != 200 { + t.Fatalf("3ds challenge payments -> %d, want 200; body %s", status, body) + } + var chIdentify map[string]any + if err := json.Unmarshal([]byte(body), &chIdentify); err != nil { + t.Fatalf("unmarshal challenge identify: %v (body %s)", err, body) + } + chAction, _ := chIdentify["action"].(map[string]any) + chPaymentData, _ := chAction["paymentData"].(string) + if chPaymentData == "" { + t.Fatalf("challenge paymentData missing: %v", chIdentify) + } + + body, status = adPostJSON(t, base+"/v68/payments/details", apiKey, map[string]any{ + "paymentData": chPaymentData, + "details": map[string]any{"threeds2.fingerprint": "fp-chal"}, + }) + if status != 200 { + t.Fatalf("challenge fingerprint details -> %d, want 200; body %s", status, body) + } + var challenge map[string]any + if err := json.Unmarshal([]byte(body), &challenge); err != nil { + t.Fatalf("unmarshal challenge resp: %v (body %s)", err, body) + } + if challenge["resultCode"] != "ChallengeShopper" { + t.Fatalf("challenge resultCode = %v, want ChallengeShopper", challenge["resultCode"]) + } + chAction2, _ := challenge["action"].(map[string]any) + if chAction2["subtype"] != "challenge" { + t.Fatalf("challenge action.subtype = %v, want challenge", chAction2["subtype"]) + } + chPaymentData2, _ := chAction2["paymentData"].(string) + if chPaymentData2 == "" { + t.Fatalf("challenge round paymentData missing: %v", challenge) + } + + body, status = adPostJSON(t, base+"/v68/payments/details", apiKey, map[string]any{ + "paymentData": chPaymentData2, + "details": map[string]any{"threeds2.challengeResult": "YWJj"}, + }) + if status != 200 { + t.Fatalf("challenge result details -> %d, want 200; body %s", status, body) + } + var chAuthed map[string]any + if err := json.Unmarshal([]byte(body), &chAuthed); err != nil { + t.Fatalf("unmarshal challenge final: %v (body %s)", err, body) + } + if chAuthed["resultCode"] != "Authorised" { + t.Fatalf("challenge final resultCode = %v, want Authorised", chAuthed["resultCode"]) + } + + // ===== 3DS2: simulate_fail → refused ===== + + body, status = adPostJSON(t, base+"/v68/payments", apiKey, payBody("4111111140000069", "ref-3ds-fail", map[string]any{ + "simulate_fail": true, + })) + if status != 200 { + t.Fatalf("3ds fail payments -> %d, want 200; body %s", status, body) + } + var failIdentify map[string]any + if err := json.Unmarshal([]byte(body), &failIdentify); err != nil { + t.Fatalf("unmarshal fail identify: %v (body %s)", err, body) + } + failAction, _ := failIdentify["action"].(map[string]any) + failPaymentData, _ := failAction["paymentData"].(string) + + body, status = adPostJSON(t, base+"/v68/payments/details", apiKey, map[string]any{ + "paymentData": failPaymentData, + "details": map[string]any{"threeds2.fingerprint": "fp-fail"}, + }) + if status != 200 { + t.Fatalf("3ds fail details -> %d, want 200; body %s", status, body) + } + var refused map[string]any + if err := json.Unmarshal([]byte(body), &refused); err != nil { + t.Fatalf("unmarshal refused resp: %v (body %s)", err, body) + } + if refused["resultCode"] != "Refused" { + t.Fatalf("3ds fail resultCode = %v, want Refused", refused["resultCode"]) + } + if refused["refusalReason"] == nil { + t.Fatalf("3ds fail refusalReason missing: %v", refused) + } + + // ===== modification validation: capture excess → 422 ===== + + body, status = adPostJSON(t, base+"/v68/payments", apiKey, payBody("4111111111111111", "ref-mod-1", nil)) + if status != 200 { + t.Fatalf("mod payment -> %d, want 200; body %s", status, body) + } + var modPay map[string]any + if err := json.Unmarshal([]byte(body), &modPay); err != nil { + t.Fatalf("unmarshal mod payment: %v (body %s)", err, body) + } + modPsp, _ := modPay["pspReference"].(string) + + // Partial capture 400 of 1000 → ok. + _, status = adPostJSON(t, base+"/v68/payments/"+modPsp+"/captures", apiKey, map[string]any{ + "merchantAccount": "TestMerchant", + "amount": map[string]any{"value": 400, "currency": "USD"}, + }) + if status != 200 { + t.Fatalf("partial capture -> %d, want 200", status) + } + + // Capture 700 > remaining 600 → 422 with the Adyen error envelope. + body, status = adPostJSON(t, base+"/v68/payments/"+modPsp+"/captures", apiKey, map[string]any{ + "merchantAccount": "TestMerchant", + "amount": map[string]any{"value": 700, "currency": "USD"}, + }) + if status != 422 { + t.Fatalf("excess capture -> %d, want 422; body %s", status, body) + } + var capErr map[string]any + if err := json.Unmarshal([]byte(body), &capErr); err != nil { + t.Fatalf("unmarshal capture error: %v (body %s)", err, body) + } + if capErr["errorType"] != "modification" { + t.Fatalf("excess capture errorType = %v, want modification", capErr["errorType"]) + } + + // Capture the exact remaining 600 → ok (fully captured). + _, status = adPostJSON(t, base+"/v68/payments/"+modPsp+"/captures", apiKey, map[string]any{ + "merchantAccount": "TestMerchant", + "amount": map[string]any{"value": 600, "currency": "USD"}, + }) + if status != 200 { + t.Fatalf("remaining capture -> %d, want 200", status) + } + + // Refund 500 of 1000 captured → ok; 600 > remaining 500 → 422. + _, status = adPostJSON(t, base+"/v68/payments/"+modPsp+"/refunds", apiKey, map[string]any{ + "merchantAccount": "TestMerchant", + "amount": map[string]any{"value": 500, "currency": "USD"}, + }) + if status != 200 { + t.Fatalf("partial refund -> %d, want 200", status) + } + body, status = adPostJSON(t, base+"/v68/payments/"+modPsp+"/refunds", apiKey, map[string]any{ + "merchantAccount": "TestMerchant", + "amount": map[string]any{"value": 600, "currency": "USD"}, + }) + if status != 422 { + t.Fatalf("excess refund -> %d, want 422; body %s", status, body) + } + + // Cancel a captured payment → 422 (only uncaptured can be cancelled). + body, status = adPostJSON(t, base+"/v68/payments/"+modPsp+"/cancels", apiKey, map[string]any{ + "merchantAccount": "TestMerchant", + }) + if status != 422 { + t.Fatalf("cancel captured -> %d, want 422; body %s", status, body) + } + var cancelErr map[string]any + if err := json.Unmarshal([]byte(body), &cancelErr); err != nil { + t.Fatalf("unmarshal cancel error: %v (body %s)", err, body) + } + if cancelErr["errorType"] != "modification" { + t.Fatalf("cancel errorType = %v, want modification", cancelErr["errorType"]) + } + + // ===== cancel + reversal happy paths on fresh payments ===== + + body, status = adPostJSON(t, base+"/v68/payments", apiKey, payBody("4111111111111111", "ref-mod-2", nil)) + if status != 200 { + t.Fatalf("cancel payment -> %d, want 200", status) + } + var cancelPay map[string]any + if err := json.Unmarshal([]byte(body), &cancelPay); err != nil { + t.Fatalf("unmarshal cancel payment: %v (body %s)", err, body) + } + cancelPsp, _ := cancelPay["pspReference"].(string) + body, status = adPostJSON(t, base+"/v68/payments/"+cancelPsp+"/cancels", apiKey, map[string]any{ + "merchantAccount": "TestMerchant", + }) + if status != 200 { + t.Fatalf("cancel -> %d, want 200; body %s", status, body) + } + + body, status = adPostJSON(t, base+"/v68/payments", apiKey, payBody("4111111111111111", "ref-mod-3", nil)) + if status != 200 { + t.Fatalf("reversal payment -> %d, want 200", status) + } + var revPay map[string]any + if err := json.Unmarshal([]byte(body), &revPay); err != nil { + t.Fatalf("unmarshal reversal payment: %v (body %s)", err, body) + } + revPsp, _ := revPay["pspReference"].(string) + _, status = adPostJSON(t, base+"/v68/payments/"+revPsp+"/captures", apiKey, map[string]any{ + "merchantAccount": "TestMerchant", + "amount": map[string]any{"value": 1000, "currency": "USD"}, + }) + if status != 200 { + t.Fatalf("reversal capture -> %d, want 200", status) + } + body, status = adPostJSON(t, base+"/v68/payments/"+revPsp+"/reversals", apiKey, map[string]any{ + "merchantAccount": "TestMerchant", + }) + if status != 200 { + t.Fatalf("reversal -> %d, want 200; body %s", status, body) + } + + // ===== paymentMethods ===== + + body, status = adPostJSON(t, base+"/v68/paymentMethods", apiKey, map[string]any{ + "merchantAccount": "TestMerchant", + }) + if status != 200 { + t.Fatalf("paymentMethods -> %d, want 200; body %s", status, body) + } + var pmResp map[string]any + if err := json.Unmarshal([]byte(body), &pmResp); err != nil { + t.Fatalf("unmarshal paymentMethods: %v (body %s)", err, body) + } + pmList, ok := pmResp["paymentMethods"].([]any) + if !ok || len(pmList) == 0 { + t.Fatalf("paymentMethods = %v, want non-empty", pmResp["paymentMethods"]) + } + + // paymentMethods failure path: missing merchantAccount → 422. + _, status = adPostJSON(t, base+"/v68/paymentMethods", apiKey, map[string]any{}) + if status != 422 { + t.Fatalf("paymentMethods no merchantAccount -> %d, want 422", status) + } + + // ===== paymentLinks: create → active, pay → completed (derive-on-read) ===== + + body, status = adPostJSON(t, base+"/v68/paymentLinks", apiKey, map[string]any{ + "reference": "ref-link-1", + "amount": map[string]any{"value": 2500, "currency": "USD"}, + "merchantAccount": "TestMerchant", + "countryCode": "US", + }) + if status != 200 { + t.Fatalf("create paymentLink -> %d, want 200; body %s", status, body) + } + var link map[string]any + if err := json.Unmarshal([]byte(body), &link); err != nil { + t.Fatalf("unmarshal paymentLink: %v (body %s)", err, body) + } + if link["status"] != "active" { + t.Fatalf("paymentLink status = %v, want active", link["status"]) + } + linkURL, _ := link["url"].(string) + if linkURL == "" { + t.Fatalf("paymentLink url missing: %v", link) + } + linkID, _ := link["id"].(string) + if linkID == "" { + t.Fatalf("paymentLink id missing: %v", link) + } + + // A payment authorised with the link's reference completes the link. + _, status = adPostJSON(t, base+"/v68/payments", apiKey, payBody("4111111111111111", "ref-link-1", nil)) + if status != 200 { + t.Fatalf("link payment -> %d, want 200", status) + } + + body, status = adGetJSON(t, base+"/v68/paymentLinks/"+linkID, apiKey) + if status != 200 { + t.Fatalf("get paymentLink -> %d, want 200; body %s", status, body) + } + var doneLink map[string]any + if err := json.Unmarshal([]byte(body), &doneLink); err != nil { + t.Fatalf("unmarshal done paymentLink: %v (body %s)", err, body) + } + if doneLink["status"] != "completed" { + t.Fatalf("paid paymentLink status = %v, want completed", doneLink["status"]) + } + + // ===== paymentLinks: expiry derived on read ===== + + body, status = adPostJSON(t, base+"/v68/paymentLinks", apiKey, map[string]any{ + "reference": "ref-link-2", + "amount": map[string]any{"value": 1500, "currency": "USD"}, + "merchantAccount": "TestMerchant", + "expiresAt": "2020-01-01T00:00:00Z", // already past + }) + if status != 200 { + t.Fatalf("create expired paymentLink -> %d, want 200; body %s", status, body) + } + var oldLink map[string]any + if err := json.Unmarshal([]byte(body), &oldLink); err != nil { + t.Fatalf("unmarshal old paymentLink: %v (body %s)", err, body) + } + oldID, _ := oldLink["id"].(string) + + body, status = adGetJSON(t, base+"/v68/paymentLinks/"+oldID, apiKey) + if status != 200 { + t.Fatalf("get old paymentLink -> %d, want 200; body %s", status, body) + } + var expLink map[string]any + if err := json.Unmarshal([]byte(body), &expLink); err != nil { + t.Fatalf("unmarshal expired paymentLink: %v (body %s)", err, body) + } + if expLink["status"] != "expired" { + t.Fatalf("old paymentLink status = %v, want expired", expLink["status"]) + } + + // ===== paymentLinks failure paths ===== + + body, status = adGetJSON(t, base+"/v68/paymentLinks/PLNOTEXIST", apiKey) + if status != 404 { + t.Fatalf("get unknown paymentLink -> %d, want 404; body %s", status, body) + } + _, status = adPostJSON(t, base+"/v68/paymentLinks", apiKey, map[string]any{ + "amount": map[string]any{"value": 1000, "currency": "USD"}, + }) + if status != 422 { + t.Fatalf("paymentLink without reference -> %d, want 422", status) + } +} + // === Adyen test helpers === +func adGetJSON(t *testing.T, rawurl, apiKey string) (string, int) { + t.Helper() + req, err := http.NewRequest("GET", rawurl, nil) + if err != nil { + t.Fatal(err) + } + if apiKey != "" { + req.Header.Set("X-API-Key", apiKey) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + func adPostJSON(t *testing.T, rawurl, apiKey string, payload map[string]any) (string, int) { t.Helper() data, _ := json.Marshal(payload) diff --git a/internal/engine/apple_searchads_style_test.go b/internal/engine/apple_searchads_style_test.go index f11c264e..ffd2467f 100644 --- a/internal/engine/apple_searchads_style_test.go +++ b/internal/engine/apple_searchads_style_test.go @@ -3,10 +3,14 @@ package engine import ( "bytes" "context" + "encoding/base64" "encoding/json" + "fmt" "io" "net/http" + "net/url" "path/filepath" + "strings" "testing" "time" @@ -209,13 +213,212 @@ func TestAppleSearchadsStyleAdapter(t *testing.T) { t.Fatalf("keywords data = %v, want non-empty array", kwResp["data"]) } firstKw := kwData[0].(map[string]any) - if firstKw["keyword"] == nil { - t.Fatalf("keyword = %v", firstKw["keyword"]) + if firstKw["text"] == nil || firstKw["text"] == "" { + t.Fatalf("text = %v, want non-empty", firstKw["text"]) + } + if firstKw["id"] == nil { + t.Fatalf("id = %v, want non-nil", firstKw["id"]) } if firstKw["bidAmount"] == nil { t.Fatalf("bidAmount = %v", firstKw["bidAmount"]) } + // ===== Keywords CRUD ===== + + // Create a keyword. + body, status = searchadsPost(t, base+"/api/v4/campaigns/"+itoa(int(campaignID))+"/keywords/targeting", token, map[string]any{ + "text": "best photo app", + "matchType": "EXACT", + "bidAmount": map[string]any{"amount": "3.75", "currency": "USD"}, + }) + if status != 200 { + t.Fatalf("create keyword -> status %d, want 200; body %s", status, body) + } + var kwCreateResp map[string]any + if err := json.Unmarshal([]byte(body), &kwCreateResp); err != nil { + t.Fatalf("unmarshal create keyword: %v (body %s)", err, body) + } + kwCreated, ok := kwCreateResp["data"].(map[string]any) + if !ok { + t.Fatalf("keyword create data = %v, want object", kwCreateResp["data"]) + } + if kwCreated["text"] != "best photo app" { + t.Fatalf("text = %v, want best photo app", kwCreated["text"]) + } + kwID := kwCreated["id"].(float64) + kwIDStr := itoa(int(kwID)) + + // Update it (bid + pause). + body, status = searchadsPut(t, base+"/api/v4/campaigns/"+itoa(int(campaignID))+"/keywords/targeting/"+kwIDStr, token, map[string]any{ + "bidAmount": map[string]any{"amount": "4.25"}, + "status": "PAUSED", + }) + if status != 200 { + t.Fatalf("update keyword -> status %d, want 200; body %s", status, body) + } + var kwUpdResp map[string]any + if err := json.Unmarshal([]byte(body), &kwUpdResp); err != nil { + t.Fatalf("unmarshal update keyword: %v (body %s)", err, body) + } + kwUpd, ok := kwUpdResp["data"].(map[string]any) + if !ok { + t.Fatalf("keyword update data = %v, want object", kwUpdResp["data"]) + } + if kwUpd["status"] != "PAUSED" { + t.Fatalf("status = %v, want PAUSED", kwUpd["status"]) + } + bid := kwUpd["bidAmount"].(map[string]any) + if bid["amount"] != "4.25" { + t.Fatalf("bidAmount.amount = %v, want 4.25", bid["amount"]) + } + + // Bulk update (the real API's bulk verb). + body, status = searchadsPut(t, base+"/api/v4/campaigns/"+itoa(int(campaignID))+"/keywords/targeting/bulk", token, + []map[string]any{{"id": kwID, "bidAmount": map[string]any{"amount": "5.00"}}}) + if status != 200 { + t.Fatalf("bulk update keywords -> status %d, want 200; body %s", status, body) + } + var kwBulkResp map[string]any + if err := json.Unmarshal([]byte(body), &kwBulkResp); err != nil { + t.Fatalf("unmarshal bulk update: %v (body %s)", err, body) + } + bulkData, ok := kwBulkResp["data"].([]any) + if !ok || len(bulkData) != 1 { + t.Fatalf("bulk data = %v, want 1 row", kwBulkResp["data"]) + } + + // Unknown keyword id -> 404. + _, status = searchadsPut(t, base+"/api/v4/campaigns/"+itoa(int(campaignID))+"/keywords/targeting/999999", token, map[string]any{ + "status": "PAUSED", + }) + if status != 404 { + t.Fatalf("update unknown keyword -> status %d, want 404", status) + } + + // Delete the keyword -> 204, then find no longer returns it. + _, status = searchadsDelete(t, base+"/api/v4/campaigns/"+itoa(int(campaignID))+"/keywords/targeting/"+kwIDStr, token) + if status != 204 { + t.Fatalf("delete keyword -> status %d, want 204", status) + } + body, status = searchadsPost(t, base+"/api/v4/campaigns/"+itoa(int(campaignID))+"/keywords/targeting/find", token, map[string]any{ + "conditions": []map[string]any{{"field": "id", "operator": "EQUALS", "values": []any{kwID}}}, + }) + if status != 200 { + t.Fatalf("find after delete -> status %d, want 200", status) + } + var afterDel map[string]any + if err := json.Unmarshal([]byte(body), &afterDel); err != nil { + t.Fatalf("unmarshal find after delete: %v", err) + } + afterData := afterDel["data"].([]any) + if len(afterData) != 0 { + t.Fatalf("data after delete = %v, want empty", afterData) + } + + // ===== Update campaign (PUT) ===== + + body, status = searchadsPut(t, base+"/api/v4/campaigns/"+itoa(int(campaignID)), token, map[string]any{ + "name": "Renamed Campaign", + "budgetAmount": map[string]any{"amount": "20000", "currency": "USD"}, + "status": "PAUSED", + }) + if status != 200 { + t.Fatalf("update campaign -> status %d, want 200; body %s", status, body) + } + var updResp map[string]any + if err := json.Unmarshal([]byte(body), &updResp); err != nil { + t.Fatalf("unmarshal update campaign: %v (body %s)", err, body) + } + updCamp, ok := updResp["data"].(map[string]any) + if !ok { + t.Fatalf("update data = %v, want object", updResp["data"]) + } + if updCamp["name"] != "Renamed Campaign" { + t.Fatalf("name = %v, want Renamed Campaign", updCamp["name"]) + } + if updCamp["servingStatus"] != "PAUSED" { + t.Fatalf("servingStatus = %v, want PAUSED", updCamp["servingStatus"]) + } + updBudget := updCamp["budgetAmount"].(map[string]any) + if updBudget["amount"] != "20000" { + t.Fatalf("budgetAmount.amount = %v, want 20000", updBudget["amount"]) + } + + // The update persists (GET reflects it) and a bad status 400s. + body, _ = searchadsGet(t, base+"/api/v4/campaigns/"+itoa(int(campaignID)), token) + var getAfterUpd map[string]any + if err := json.Unmarshal([]byte(body), &getAfterUpd); err != nil { + t.Fatalf("unmarshal get after update: %v", err) + } + if getAfterUpd["data"].(map[string]any)["name"] != "Renamed Campaign" { + t.Fatalf("persisted name = %v, want Renamed Campaign", getAfterUpd["data"].(map[string]any)["name"]) + } + _, status = searchadsPut(t, base+"/api/v4/campaigns/"+itoa(int(campaignID)), token, map[string]any{"status": "WAT"}) + if status != 400 { + t.Fatalf("update campaign bad status -> status %d, want 400", status) + } + _, status = searchadsPut(t, base+"/api/v4/campaigns/424242424242", token, map[string]any{"name": "x"}) + if status != 404 { + t.Fatalf("update unknown campaign -> status %d, want 404", status) + } + + // ===== OAuth2 token exchange ===== + + // Happy path: structurally valid ES256 client-secret JWT -> bearer that + // authorizes API calls. + jwtSecret := searchadsTestJWT(t, "TEAM123", "ORG456", "KEY789") + body, status = searchadsPostForm(t, base+"/api/oauth2/token", map[string]string{ + "grant_type": "client_credentials", + "client_id": "ORG456", + "client_secret": jwtSecret, + }) + if status != 200 { + t.Fatalf("oauth token -> status %d, want 200; body %s", status, body) + } + var tokResp map[string]any + if err := json.Unmarshal([]byte(body), &tokResp); err != nil { + t.Fatalf("unmarshal oauth token: %v (body %s)", err, body) + } + accessToken, ok := tokResp["access_token"].(string) + if !ok || accessToken == "" { + t.Fatalf("access_token = %v, want non-empty string", tokResp["access_token"]) + } + if tokResp["token_type"] != "Bearer" { + t.Fatalf("token_type = %v, want Bearer", tokResp["token_type"]) + } + if tokResp["expires_in"] == nil { + t.Fatalf("expires_in = %v, want non-nil", tokResp["expires_in"]) + } + // The minted bearer actually works against a protected endpoint. + _, status = searchadsPost(t, base+"/api/v4/campaigns/find", accessToken, map[string]any{}) + if status != 200 { + t.Fatalf("find with minted token -> status %d, want 200", status) + } + + // Failure path: malformed client secret (2 segments) -> 400 invalid_client. + body, status = searchadsPostForm(t, base+"/api/oauth2/token", map[string]string{ + "grant_type": "client_credentials", + "client_id": "ORG456", + "client_secret": "not.a-jwt", + }) + if status != 400 { + t.Fatalf("oauth bad secret -> status %d, want 400; body %s", status, body) + } + if !strings.Contains(body, "invalid_client") { + t.Fatalf("oauth bad secret body = %s, want invalid_client", body) + } + + // Wrong grant type -> 400 unsupported_grant_type. + body, status = searchadsPostForm(t, base+"/api/oauth2/token", map[string]string{ + "grant_type": "password", + }) + if status != 400 { + t.Fatalf("oauth wrong grant -> status %d, want 400; body %s", status, body) + } + if !strings.Contains(body, "unsupported_grant_type") { + t.Fatalf("oauth wrong grant body = %s, want unsupported_grant_type", body) + } + // ===== Create ad ===== body, status = searchadsPost(t, base+"/api/v4/campaigns/"+itoa(int(campaignID))+"/ads", token, map[string]any{ @@ -279,6 +482,64 @@ func searchadsNoAuth(t *testing.T, rawurl string) (string, int) { return string(b), resp.StatusCode } +// searchadsPut sends a JSON PUT with a bearer token. +func searchadsPut(t *testing.T, rawurl, token string, body any) (string, int) { + t.Helper() + data, _ := json.Marshal(body) + req, _ := http.NewRequest("PUT", rawurl, bytes.NewReader(data)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + +// searchadsDelete sends a DELETE with a bearer token. +func searchadsDelete(t *testing.T, rawurl, token string) (string, int) { + t.Helper() + req, _ := http.NewRequest("DELETE", rawurl, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + +// searchadsPostForm posts a url-encoded form body (the OAuth2 token +// endpoint's content type) without a bearer token. +func searchadsPostForm(t *testing.T, rawurl string, form map[string]string) (string, int) { + t.Helper() + vals := url.Values{} + for k, v := range form { + vals.Set(k, v) + } + resp, err := http.Post(rawurl, "application/x-www-form-urlencoded", strings.NewReader(vals.Encode())) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + +// searchadsTestJWT builds a structurally valid ES256 client-secret JWT +// (header carries alg + kid; payload carries sub) with a placeholder +// signature — exactly what the simulator's structural validation accepts. +func searchadsTestJWT(t *testing.T, teamID, clientID, keyID string) string { + t.Helper() + b64 := base64.RawURLEncoding.EncodeToString + header := fmt.Sprintf(`{"alg":"ES256","kid":%q,"typ":"JWT"}`, keyID) + payload := fmt.Sprintf(`{"sub":%q,"iss":%q,"aud":"https://appleid.apple.com/oauth/token"}`, clientID, teamID) + return b64([]byte(header)) + "." + b64([]byte(payload)) + "." + b64([]byte("synthetic-signature")) +} + // itoa converts an int to a string. func itoa(n int) string { if n == 0 { diff --git a/internal/engine/azure_devops_style_test.go b/internal/engine/azure_devops_style_test.go index 301ab77b..6dfe8f94 100644 --- a/internal/engine/azure_devops_style_test.go +++ b/internal/engine/azure_devops_style_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "io" "net/http" + "net/url" "path/filepath" "strconv" "testing" @@ -113,13 +114,13 @@ func TestAzureDevOpsStyleAdapter(t *testing.T) { t.Fatalf("repos count = %d, want >= 1", len(value)) } - // ===== Create work item (PATCH-style body) ===== + // ===== Create work item (typed route + PATCH-style body) ===== createBody := []map[string]any{ {"op": "add", "path": "/fields/System.Title", "value": "New task from API"}, {"op": "add", "path": "/fields/System.Description", "value": "Created via stunt test"}, } - body, status = adoPostJSON(t, base+"/myorg/MyFirstProject/_apis/wit/workitems", patBasic, createBody) + body, status = adoPostJSON(t, base+"/myorg/MyFirstProject/_apis/wit/workitems/Task", patBasic, createBody) if status != 200 { t.Fatalf("create work item -> status %d, want 200; body %s", status, body) } @@ -137,6 +138,9 @@ func TestAzureDevOpsStyleAdapter(t *testing.T) { if fields["System.Title"] != "New task from API" { t.Fatalf("System.Title = %v, want 'New task from API'", fields["System.Title"]) } + if fields["System.WorkItemType"] != "Task" { + t.Fatalf("System.WorkItemType = %v, want Task (from the typed route)", fields["System.WorkItemType"]) + } // ===== Get work item by id (seeded) ===== @@ -164,6 +168,300 @@ func TestAzureDevOpsStyleAdapter(t *testing.T) { t.Fatalf("created wi id = %v, want %v", resp["id"], newID) } + // ===== Update work item via PATCH (patch-document semantics) ===== + + patchBody := []map[string]any{ + {"op": "replace", "path": "/fields/System.State", "value": "Resolved"}, + {"op": "add", "path": "/fields/System.Tags", "value": "stunt"}, + } + body, status = adoPatchJSON(t, base+"/myorg/MyFirstProject/_apis/wit/workitems/"+strconv.Itoa(int(newID)), patBasic, patchBody) + if status != 200 { + t.Fatalf("patch work item -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal patched wi: %v (body %s)", err, body) + } + if resp["rev"] != float64(2) { + t.Fatalf("patched wi rev = %v, want 2", resp["rev"]) + } + patchedFields := resp["fields"].(map[string]any) + if patchedFields["System.State"] != "Resolved" { + t.Fatalf("patched System.State = %v, want Resolved", patchedFields["System.State"]) + } + + // ===== PATCH failure path: unsupported op -> 400 ===== + + body, status = adoPatchJSON(t, base+"/myorg/MyFirstProject/_apis/wit/workitems/1", patBasic, + []map[string]any{{"op": "bogus", "path": "/fields/System.Title", "value": "x"}}) + if status != 400 { + t.Fatalf("patch with bogus op -> status %d, want 400; body %s", status, body) + } + + // ===== List work items by ids ===== + + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/wit/workitems?ids=1,"+strconv.Itoa(int(newID)), patBasic) + if status != 200 { + t.Fatalf("list workitems by ids -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal workitems list: %v (body %s)", err, body) + } + value = resp["value"].([]any) + if len(value) != 2 { + t.Fatalf("workitems by ids count = %d, want 2 (body %s)", len(value), body) + } + if resp["count"] != float64(2) { + t.Fatalf("workitems count = %v, want 2", resp["count"]) + } + + // ===== WIQL subset: WHERE on System.State ===== + + wiqlBody := map[string]any{"query": "SELECT [System.Id] FROM WorkItems WHERE [System.State] = 'Active'"} + body, status = adoPostJSON(t, base+"/myorg/MyFirstProject/_apis/wit/wiql", patBasic, wiqlBody) + if status != 200 { + t.Fatalf("wiql -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal wiql: %v (body %s)", err, body) + } + workItems, ok := resp["workItems"].([]any) + if !ok || len(workItems) != 1 { + t.Fatalf("wiql workItems = %v, want exactly the seeded Active bug", resp["workItems"]) + } + if workItems[0].(map[string]any)["id"] != float64(1) { + t.Fatalf("wiql matched id = %v, want 1", workItems[0].(map[string]any)["id"]) + } + + // WIQL numeric equality on the System.Id pseudo-field. + body, status = adoPostJSON(t, base+"/myorg/MyFirstProject/_apis/wit/wiql", patBasic, + map[string]any{"query": "SELECT [System.Id] FROM WorkItems WHERE [System.Id] = 1"}) + if status != 200 { + t.Fatalf("wiql System.Id -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal wiql System.Id: %v (body %s)", err, body) + } + workItems, _ = resp["workItems"].([]any) + if len(workItems) != 1 || workItems[0].(map[string]any)["id"] != float64(1) { + t.Fatalf("wiql System.Id matches = %v, want exactly id 1", resp["workItems"]) + } + + // ===== WIQL failure path: non-WorkItems FROM -> 400 ===== + + body, status = adoPostJSON(t, base+"/myorg/MyFirstProject/_apis/wit/wiql", patBasic, + map[string]any{"query": "SELECT [System.Id] FROM Builds"}) + if status != 400 { + t.Fatalf("wiql bad FROM -> status %d, want 400; body %s", status, body) + } + + // ===== Git push stores commits; items serve the pushed content ===== + + pushBody := map[string]any{ + "refUpdates": []map[string]any{{"name": "refs/heads/main", "oldObjectId": "0000000000000000000000000000000000000000"}}, + "commits": []map[string]any{{ + "comment": "Add docs", + "changes": []map[string]any{{ + "changeType": "add", + "item": map[string]any{"path": "/docs.md"}, + "newContent": map[string]any{"content": "hello docs v1", "contentType": "rawtext"}, + }}, + }}, + } + body, status = adoPostJSON(t, base+"/myorg/MyFirstProject/_apis/git/repositories/11111111-0000-0000-0000-000000000001/pushes", patBasic, pushBody) + if status != 200 { + t.Fatalf("push -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal push: %v (body %s)", err, body) + } + pushCommits := resp["commits"].([]any) + if len(pushCommits) != 1 { + t.Fatalf("push commits = %d, want 1", len(pushCommits)) + } + firstCommit := pushCommits[0].(map[string]any)["commitId"].(string) + if len(firstCommit) != 40 { + t.Fatalf("commitId = %q, want 40-char object id", firstCommit) + } + refUpdates := resp["refUpdates"].([]any) + if refUpdates[0].(map[string]any)["newObjectId"].(string) != firstCommit { + t.Fatalf("newObjectId = %v, want %v", refUpdates[0].(map[string]any)["newObjectId"], firstCommit) + } + + // Items now serve the pushed content at the pushed path. + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/git/repositories/11111111-0000-0000-0000-000000000001/items?path=/docs.md", patBasic) + if status != 200 { + t.Fatalf("items docs.md -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal item: %v (body %s)", err, body) + } + if resp["content"] != "hello docs v1" { + t.Fatalf("item content = %v, want 'hello docs v1'", resp["content"]) + } + if resp["commitId"] != firstCommit { + t.Fatalf("item commitId = %v, want %v", resp["commitId"], firstCommit) + } + + // Items 404 for unknown paths. + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/git/repositories/11111111-0000-0000-0000-000000000001/items?path=/nope.md", patBasic) + if status != 404 { + t.Fatalf("items unknown path -> status %d, want 404; body %s", status, body) + } + + // A second push edits the file; versionDescriptor pins the first version. + pushBody2 := map[string]any{ + "refUpdates": []map[string]any{{"name": "refs/heads/main", "oldObjectId": firstCommit}}, + "commits": []map[string]any{{ + "comment": "Edit docs", + "changes": []map[string]any{{ + "changeType": "edit", + "item": map[string]any{"path": "/docs.md"}, + "newContent": map[string]any{"content": "hello docs v2", "contentType": "rawtext"}, + }}, + }}, + } + body, status = adoPostJSON(t, base+"/myorg/MyFirstProject/_apis/git/repositories/11111111-0000-0000-0000-000000000001/pushes", patBasic, pushBody2) + if status != 200 { + t.Fatalf("push 2 -> status %d, want 200; body %s", status, body) + } + vd := url.QueryEscape(`{"versionType":"commit","version":"` + firstCommit + `"}`) + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/git/repositories/11111111-0000-0000-0000-000000000001/items?path=/docs.md&versionDescriptor="+vd, patBasic) + if status != 200 { + t.Fatalf("items at pinned commit -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal pinned item: %v (body %s)", err, body) + } + if resp["content"] != "hello docs v1" { + t.Fatalf("pinned item content = %v, want 'hello docs v1'", resp["content"]) + } + + // Commits list reflects both pushes. + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/git/repositories/11111111-0000-0000-0000-000000000001/commits", patBasic) + if status != 200 { + t.Fatalf("commits -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal commits: %v (body %s)", err, body) + } + value = resp["value"].([]any) + if len(value) < 3 { + t.Fatalf("commits count = %d, want >= 3 (seed + 2 pushes)", len(value)) + } + + // ===== Pipelines: list + detail + 404 ===== + + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/pipelines", patBasic) + if status != 200 { + t.Fatalf("pipelines -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal pipelines: %v (body %s)", err, body) + } + value = resp["value"].([]any) + if len(value) < 1 { + t.Fatalf("pipelines count = %d, want >= 1", len(value)) + } + if value[0].(map[string]any)["id"] != float64(1) { + t.Fatalf("first pipeline id = %v, want 1", value[0].(map[string]any)["id"]) + } + + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/pipelines/1", patBasic) + if status != 200 { + t.Fatalf("pipeline 1 -> status %d, want 200; body %s", status, body) + } + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/pipelines/999", patBasic) + if status != 404 { + t.Fatalf("pipeline 999 -> status %d, want 404; body %s", status, body) + } + + // ===== Queue runs; derive-on-read lifecycle queued -> inProgress -> completed ===== + + body, status = adoPostJSON(t, base+"/myorg/MyFirstProject/_apis/pipelines/1/runs", patBasic, map[string]any{}) + if status != 200 { + t.Fatalf("queue run -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal queued run: %v (body %s)", err, body) + } + runID, ok := resp["id"].(float64) + if !ok { + t.Fatalf("queued run id = %v, want number", resp["id"]) + } + if resp["state"] != "queued" { + t.Fatalf("freshly queued run state = %v, want queued", resp["state"]) + } + if resp["pipeline"].(map[string]any)["id"] != float64(1) { + t.Fatalf("run pipeline id = %v, want 1", resp["pipeline"].(map[string]any)["id"]) + } + + // Failure-injected run (templateParameters.simulate_fail). + body, status = adoPostJSON(t, base+"/myorg/MyFirstProject/_apis/pipelines/1/runs", patBasic, + map[string]any{"templateParameters": map[string]any{"simulate_fail": "true"}}) + if status != 200 { + t.Fatalf("queue failing run -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal failing run: %v (body %s)", err, body) + } + failRunID := resp["id"].(float64) + + // Queue a run on an unknown pipeline -> 404. + body, status = adoPostJSON(t, base+"/myorg/MyFirstProject/_apis/pipelines/999/runs", patBasic, map[string]any{}) + if status != 404 { + t.Fatalf("queue run on unknown pipeline -> status %d, want 404; body %s", status, body) + } + + // Advance the clock past the simulated pipeline duration. + time.Sleep(3500 * time.Millisecond) + + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/pipelines/1/runs/"+strconv.Itoa(int(runID)), patBasic) + if status != 200 { + t.Fatalf("get run -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal run: %v (body %s)", err, body) + } + if resp["state"] != "completed" { + t.Fatalf("run state after advance = %v, want completed", resp["state"]) + } + if resp["result"] != "succeeded" { + t.Fatalf("run result = %v, want succeeded", resp["result"]) + } + if resp["finishedDate"] == nil { + t.Fatalf("finishedDate = nil, want a timestamp once completed") + } + + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/pipelines/1/runs/"+strconv.Itoa(int(failRunID)), patBasic) + if status != 200 { + t.Fatalf("get failing run -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal failing run: %v (body %s)", err, body) + } + if resp["state"] != "completed" || resp["result"] != "failed" { + t.Fatalf("failing run state/result = %v/%v, want completed/failed", resp["state"], resp["result"]) + } + + // Runs list agrees with single-run polls. + body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/pipelines/1/runs", patBasic) + if status != 200 { + t.Fatalf("list runs -> status %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal runs list: %v (body %s)", err, body) + } + value = resp["value"].([]any) + if len(value) != 2 { + t.Fatalf("runs count = %d, want 2", len(value)) + } + for _, v := range value { + r := v.(map[string]any) + if r["state"] != "completed" { + t.Fatalf("listed run state = %v, want completed (lists agree with polls)", r["state"]) + } + } + // ===== Iterations ===== body, status = adoGet(t, base+"/myorg/MyFirstProject/_apis/work/teamsettings/iterations", patBasic) @@ -225,3 +523,23 @@ func adoPostJSON(t *testing.T, rawurl, auth string, body any) (string, int) { b, _ := io.ReadAll(resp.Body) return string(b), resp.StatusCode } + +func adoPatchJSON(t *testing.T, rawurl, auth string, body any) (string, int) { + t.Helper() + data, _ := json.Marshal(body) + req, err := http.NewRequest("PATCH", rawurl, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json-patch+json") + if auth != "" { + req.Header.Set("Authorization", auth) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} diff --git a/internal/engine/braintree_style_test.go b/internal/engine/braintree_style_test.go index 30b83cc6..b7940a92 100644 --- a/internal/engine/braintree_style_test.go +++ b/internal/engine/braintree_style_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "path/filepath" @@ -15,11 +16,17 @@ import ( // TestBraintreeStyleAdapter exercises the Braintree GraphQL + REST API: // - GraphQL createCustomer → customer id -// - GraphQL chargePaymentMethod → submitted_for_settlement -// - GraphQL refundTransaction -// - REST create transaction → authorized -// - REST get transaction -// - REST refund +// - GraphQL chargePaymentMethod → submitted_for_settlement → settled +// - GraphQL refundTransaction (settled only) +// - REST create transaction → authorized (with/without submit_for_settlement) +// - REST transaction lifecycle: authorized → settle → settled (derive-on-read) +// - REST void (authorized only), void-again → 422 +// - authorization_expired (simulated expiry window) +// - REST refund: full, over-refund guard, partial, from non-settled → 422, +// nonexistent transaction → 404 +// - REST advanced_search (status criteria via query_select) +// - plans + subscriptions: create, cancel (Active → Canceled), +// billing-cycle expiry (Active → Expired), cancel non-Active → 422 // - client_token // - webhook bt_signature + bt_payload // - 401 without auth @@ -57,6 +64,7 @@ func TestBraintreeStyleAdapter(t *testing.T) { base := addrs["braintree"] const merchantID = "merchant123" + txns := base + "/merchants/" + merchantID + "/transactions" // ===== 401 without auth ===== @@ -97,7 +105,91 @@ func TestBraintreeStyleAdapter(t *testing.T) { t.Fatalf("customer id empty") } - // ===== GraphQL chargePaymentMethod → submitted_for_settlement ===== + // ===== REST create with a non-positive amount → 422 ===== + + body, status = btPostJSON(t, txns, "Bearer bt-token", map[string]any{ + "amount": "-5.00", + "type": "sale", + }) + if status != 422 { + t.Fatalf("create negative amount -> %d, want 422; body %s", status, body) + } + + // ===== REST create with submit_for_settlement → authorized (txnAuto) ===== + + txnAuto := btCreateTxn(t, txns, map[string]any{ + "amount": "100.00", + "type": "sale", + "options": map[string]any{"submit_for_settlement": true}, + }) + if txnAuto["status"] != "authorized" { + t.Fatalf("txnAuto status = %v, want authorized", txnAuto["status"]) + } + + // ===== REST create manual → authorized, then settle → submitted (txnManual) ===== + + txnManual := btCreateTxn(t, txns, map[string]any{ + "amount": "80.00", + "type": "sale", + }) + if txnManual["status"] != "authorized" { + t.Fatalf("txnManual status = %v, want authorized", txnManual["status"]) + } + + // Refund from authorized must fail (settled only). + body, status = btPostJSON(t, txns+"/"+txnManual["id"].(string)+"/refund", "Bearer bt-token", map[string]any{}) + if status != 422 { + t.Fatalf("refund from authorized -> %d, want 422; body %s", status, body) + } + + // Void from authorized works; voiding again fails. + txnVoid := btCreateTxn(t, txns, map[string]any{"amount": "20.00", "type": "sale"}) + body, status = btPostJSON(t, txns+"/"+txnVoid["id"].(string)+"/void", "Bearer bt-token", map[string]any{}) + if status != 200 { + t.Fatalf("void authorized -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &gqlResp); err != nil { + t.Fatalf("unmarshal void resp: %v (body %s)", err, body) + } + if s := gqlResp["transaction"].(map[string]any)["status"]; s != "voided" { + t.Fatalf("voided status = %v, want voided", s) + } + body, status = btPostJSON(t, txns+"/"+txnVoid["id"].(string)+"/void", "Bearer bt-token", map[string]any{}) + if status != 422 { + t.Fatalf("void already-voided -> %d, want 422; body %s", status, body) + } + + // Settle with a non-positive amount → 422. + body, status = btPostJSON(t, txns+"/"+txnManual["id"].(string)+"/settle", "Bearer bt-token", map[string]any{"amount": "0.00"}) + if status != 422 { + t.Fatalf("settle zero amount -> %d, want 422; body %s", status, body) + } + + // Partial capture: settle 50.00 of the 80.00 authorization. + body, status = btPostJSON(t, txns+"/"+txnManual["id"].(string)+"/settle", "Bearer bt-token", map[string]any{"amount": "50.00"}) + if status != 200 { + t.Fatalf("settle partial -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &gqlResp); err != nil { + t.Fatalf("unmarshal settle resp: %v (body %s)", err, body) + } + st := gqlResp["transaction"].(map[string]any) + if st["status"] != "submitted_for_settlement" { + t.Fatalf("settle status = %v, want submitted_for_settlement", st["status"]) + } + if st["amount"] != "50.00" { + t.Fatalf("settle amount = %v, want 50.00 (partial capture)", st["amount"]) + } + + // ===== REST create with simulated authorization expiry (txnExpiry) ===== + + txnExpiry := btCreateTxn(t, txns, map[string]any{ + "amount": "30.00", + "type": "sale", + "simulate_authorization_expiry": true, + }) + + // ===== GraphQL chargePaymentMethod → submitted_for_settlement (txnGQL) ===== body, status = btPostJSON(t, base+"/graphql", "Bearer bt-token", map[string]any{ "query": "mutation($input: ChargePaymentMethodInput!) { chargePaymentMethod(input: $input) { transaction { id status amount } } }", @@ -118,19 +210,111 @@ func TestBraintreeStyleAdapter(t *testing.T) { if !ok { t.Fatalf("transaction = %v, want object", charge["transaction"]) } - txnID, _ := txn["id"].(string) - if txnID == "" { + txnGQLID, _ := txn["id"].(string) + if txnGQLID == "" { t.Fatalf("transaction id empty") } if txn["status"] != "submitted_for_settlement" { t.Fatalf("status = %v, want submitted_for_settlement", txn["status"]) } - // ===== GraphQL refundTransaction ===== + // ===== Plans + subscriptions ===== + + body, status = btPostJSON(t, base+"/merchants/"+merchantID+"/plans", "Bearer bt-token", map[string]any{ + "id": "starter-plan", + "name": "Starter", + "price": "15.00", + "number_of_billing_cycles": 3, + }) + if status != 200 { + t.Fatalf("create plan -> %d, want 200; body %s", status, body) + } + var restResp map[string]any + if err := json.Unmarshal([]byte(body), &restResp); err != nil { + t.Fatalf("unmarshal plan resp: %v (body %s)", err, body) + } + if _, ok := restResp["plan"].(map[string]any); !ok { + t.Fatalf("plan = %v, want object", restResp["plan"]) + } + + // Subscription on a nonexistent plan → 404. + body, status = btPostJSON(t, base+"/merchants/"+merchantID+"/subscriptions", "Bearer bt-token", map[string]any{ + "plan_id": "no-such-plan", + }) + if status != 404 { + t.Fatalf("subscription on missing plan -> %d, want 404; body %s", status, body) + } + + // Plan list contains the plan; single-plan fetch works; unknown → 404. + body, status = btGet(t, base+"/merchants/"+merchantID+"/plans", "Bearer bt-token") + if status != 200 { + t.Fatalf("list plans -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &restResp); err != nil { + t.Fatalf("unmarshal plans resp: %v (body %s)", err, body) + } + planList, _ := restResp["plans"].([]any) + if len(planList) != 1 || planList[0].(map[string]any)["id"] != "starter-plan" { + t.Fatalf("plans = %v, want [starter-plan]", planList) + } + body, status = btGet(t, base+"/merchants/"+merchantID+"/plans/starter-plan", "Bearer bt-token") + if status != 200 { + t.Fatalf("get plan -> %d, want 200; body %s", status, body) + } + body, status = btGet(t, base+"/merchants/"+merchantID+"/plans/nope", "Bearer bt-token") + if status != 404 { + t.Fatalf("get missing plan -> %d, want 404; body %s", status, body) + } + + // Subscription 404 for an unknown id. + body, status = btGet(t, base+"/merchants/"+merchantID+"/subscriptions/sub_nope", "Bearer bt-token") + if status != 404 { + t.Fatalf("get missing subscription -> %d, want 404; body %s", status, body) + } + + // Subscription that will run one billing cycle (2s) then Expire. + sub1 := btCreateSub(t, base, merchantID, "starter-plan", 1) + if sub1["status"] != "Active" { + t.Fatalf("sub1 status = %v, want Active", sub1["status"]) + } + // Subscription that stays Active (3 cycles) and gets canceled. + sub2 := btCreateSub(t, base, merchantID, "starter-plan", 3) + sub2ID, _ := sub2["id"].(string) + + // ===== Let the compressed clocks run: +1s submit, +3s settle, 1s + // authorization expiry, 2s billing cycle. ===== + + time.Sleep(3600 * time.Millisecond) + + // ===== Derived states after the sleep ===== + + // txnAuto: authorized → submitted_for_settlement (+1s) → settled (+3s). + auto := btGetTxn(t, txns, txnAuto["id"].(string)) + if auto["status"] != "settled" { + t.Fatalf("txnAuto status = %v, want settled", auto["status"]) + } + + // txnManual: settled after the explicit settle. + manual := btGetTxn(t, txns, txnManual["id"].(string)) + if manual["status"] != "settled" { + t.Fatalf("txnManual status = %v, want settled", manual["status"]) + } + + // txnExpiry: authorization_expired, and no longer voidable. + expiry := btGetTxn(t, txns, txnExpiry["id"].(string)) + if expiry["status"] != "authorization_expired" { + t.Fatalf("txnExpiry status = %v, want authorization_expired", expiry["status"]) + } + body, status = btPostJSON(t, txns+"/"+txnExpiry["id"].(string)+"/void", "Bearer bt-token", map[string]any{}) + if status != 422 { + t.Fatalf("void authorization_expired -> %d, want 422; body %s", status, body) + } + + // ===== GraphQL refundTransaction of the now-settled charge ===== body, status = btPostJSON(t, base+"/graphql", "Bearer bt-token", map[string]any{ "query": "mutation($input: RefundTransactionInput!) { refundTransaction(input: $input) { refund { id status } } }", - "variables": map[string]any{"input": map[string]any{"transactionId": txnID}}, + "variables": map[string]any{"input": map[string]any{"transactionId": txnGQLID}}, }) if status != 200 { t.Fatalf("refundTransaction -> %d, want 200; body %s", status, body) @@ -147,49 +331,108 @@ func TestBraintreeStyleAdapter(t *testing.T) { if !ok { t.Fatalf("refund = %v, want object", refund["refund"]) } - refundID, _ := r["id"].(string) - if refundID == "" { + if r["id"] == "" { t.Fatalf("refund id empty") } - // ===== REST create transaction ===== + // ===== REST refunds on txnManual (50.00 settled) ===== + + // Partial refund of 10.00 succeeds. + body, status = btPostJSON(t, txns+"/"+txnManual["id"].(string)+"/refund", "Bearer bt-token", map[string]any{"amount": "10.00"}) + if status != 200 { + t.Fatalf("partial refund -> %d, want 200; body %s", status, body) + } + // Refunding more than the unrefunded balance (40.00) fails. + body, status = btPostJSON(t, txns+"/"+txnManual["id"].(string)+"/refund", "Bearer bt-token", map[string]any{"amount": "45.00"}) + if status != 422 { + t.Fatalf("over-refund -> %d, want 422; body %s", status, body) + } + // Full remaining refund succeeds. + body, status = btPostJSON(t, txns+"/"+txnManual["id"].(string)+"/refund", "Bearer bt-token", map[string]any{}) + if status != 200 { + t.Fatalf("full remaining refund -> %d, want 200; body %s", status, body) + } + // Everything is refunded now — one more cent fails. + body, status = btPostJSON(t, txns+"/"+txnManual["id"].(string)+"/refund", "Bearer bt-token", map[string]any{"amount": "0.01"}) + if status != 422 { + t.Fatalf("refund past zero -> %d, want 422; body %s", status, body) + } + + // ===== Refund of a nonexistent transaction → 404 ===== - body, status = btPostJSON(t, base+"/merchants/"+merchantID+"/transactions", "Bearer bt-token", map[string]any{ - "amount": "100.00", - "type": "sale", - "paymentMethodNonce": "fake-nonce-ok", + body, status = btPostJSON(t, txns+"/tnosuch/refund", "Bearer bt-token", map[string]any{}) + if status != 404 { + t.Fatalf("refund nonexistent -> %d, want 404; body %s", status, body) + } + + // ===== Advanced search (status criteria) ===== + + body, status = btPostJSON(t, txns+"/advanced_search", "Bearer bt-token", map[string]any{ + "search": map[string]any{"status": map[string]any{"in": []string{"settled"}}}, }) if status != 200 { - t.Fatalf("REST create transaction -> %d, want 200; body %s", status, body) + t.Fatalf("advanced_search -> %d, want 200; body %s", status, body) } - var restResp map[string]any if err := json.Unmarshal([]byte(body), &restResp); err != nil { - t.Fatalf("unmarshal REST resp: %v (body %s)", err, body) + t.Fatalf("unmarshal search resp: %v (body %s)", err, body) } - restTxn, ok := restResp["transaction"].(map[string]any) - if !ok { - t.Fatalf("transaction = %v, want object", restResp["transaction"]) + found := map[string]bool{} + results, _ := restResp["transactions"].([]any) + for _, it := range results { + found[it.(map[string]any)["id"].(string)] = true } - restTxnID, _ := restTxn["id"].(string) - if restTxnID == "" { - t.Fatalf("REST transaction id empty") + if !found[txnAuto["id"].(string)] || !found[txnManual["id"].(string)] { + t.Fatalf("settled search missing txns; got %v", found) } - if restTxn["status"] != "authorized" { - t.Fatalf("REST status = %v, want authorized", restTxn["status"]) + if found[txnExpiry["id"].(string)] || found[txnVoid["id"].(string)] { + t.Fatalf("settled search matched non-settled txns; got %v", found) } - // ===== REST get transaction ===== - - body, status = btGet(t, base+"/merchants/"+merchantID+"/transactions/"+restTxnID, "Bearer bt-token") + // Amount range criteria. + body, status = btPostJSON(t, txns+"/advanced_search", "Bearer bt-token", map[string]any{ + "search": map[string]any{"amount": map[string]any{"min": "90.00", "max": "110.00"}}, + }) if status != 200 { - t.Fatalf("REST get transaction -> %d, want 200; body %s", status, body) + t.Fatalf("advanced_search amount -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &restResp); err != nil { + t.Fatalf("unmarshal search resp: %v (body %s)", err, body) + } + results, _ = restResp["transactions"].([]any) + if len(results) != 1 || results[0].(map[string]any)["id"] != txnAuto["id"] { + t.Fatalf("amount search = %v, want only txnAuto", results) } - // ===== REST refund ===== + // ===== Subscriptions after the sleep ===== + + // sub1 (1 cycle of 2s) → Expired. + body, status = btGet(t, base+"/merchants/"+merchantID+"/subscriptions/"+sub1["id"].(string), "Bearer bt-token") + if status != 200 { + t.Fatalf("get sub1 -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &restResp); err != nil { + t.Fatalf("unmarshal sub1 resp: %v (body %s)", err, body) + } + sub1View := restResp["subscription"].(map[string]any) + if sub1View["status"] != "Expired" { + t.Fatalf("sub1 status = %v, want Expired", sub1View["status"]) + } + // Canceling an Expired subscription fails. + body, status = btPostJSON(t, base+"/merchants/"+merchantID+"/subscriptions/"+sub1["id"].(string)+"/cancel", "Bearer bt-token", map[string]any{}) + if status != 422 { + t.Fatalf("cancel Expired sub -> %d, want 422; body %s", status, body) + } - body, status = btPostJSON(t, base+"/merchants/"+merchantID+"/transactions/"+restTxnID+"/refund", "Bearer bt-token", map[string]any{}) + // sub2 (3 cycles) is still Active (only ~1.8 cycles elapsed) → Canceled. + body, status = btPostJSON(t, base+"/merchants/"+merchantID+"/subscriptions/"+sub2ID+"/cancel", "Bearer bt-token", map[string]any{}) if status != 200 { - t.Fatalf("REST refund -> %d, want 200; body %s", status, body) + t.Fatalf("cancel sub2 -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &restResp); err != nil { + t.Fatalf("unmarshal cancel resp: %v (body %s)", err, body) + } + if s := restResp["subscription"].(map[string]any)["status"]; s != "Canceled" { + t.Fatalf("sub2 status = %v, want Canceled", s) } // ===== client_token ===== @@ -226,6 +469,64 @@ func TestBraintreeStyleAdapter(t *testing.T) { // === Braintree test helpers === +// btCreateTxn creates a REST transaction and returns its public view. +func btCreateTxn(t *testing.T, txns string, payload map[string]any) map[string]any { + t.Helper() + body, status := btPostJSON(t, txns, "Bearer bt-token", payload) + if status != 200 { + t.Fatalf("create transaction -> %d, want 200; body %s", status, body) + } + var resp map[string]any + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal create txn resp: %v (body %s)", err, body) + } + txn, ok := resp["transaction"].(map[string]any) + if !ok { + t.Fatalf("transaction = %v, want object (%s)", resp["transaction"], body) + } + return txn +} + +// btGetTxn fetches a REST transaction and returns its public view. +func btGetTxn(t *testing.T, txns, id string) map[string]any { + t.Helper() + body, status := btGet(t, txns+"/"+id, "Bearer bt-token") + if status != 200 { + t.Fatalf("get transaction %s -> %d, want 200; body %s", id, status, body) + } + var resp map[string]any + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal get txn resp: %v (body %s)", err, body) + } + txn, ok := resp["transaction"].(map[string]any) + if !ok { + t.Fatalf("transaction = %v, want object (%s)", resp["transaction"], body) + } + return txn +} + +// btCreateSub creates a subscription and returns its public view. +func btCreateSub(t *testing.T, base, merchantID, planID string, cycles int) map[string]any { + t.Helper() + body, status := btPostJSON(t, fmt.Sprintf("%s/merchants/%s/subscriptions", base, merchantID), "Bearer bt-token", map[string]any{ + "plan_id": planID, + "payment_method_token": "pm-token", + "number_of_billing_cycles": cycles, + }) + if status != 200 { + t.Fatalf("create subscription -> %d, want 200; body %s", status, body) + } + var resp map[string]any + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal create sub resp: %v (body %s)", err, body) + } + sub, ok := resp["subscription"].(map[string]any) + if !ok { + t.Fatalf("subscription = %v, want object (%s)", resp["subscription"], body) + } + return sub +} + func btPostJSON(t *testing.T, rawurl, auth string, payload map[string]any) (string, int) { t.Helper() data, _ := json.Marshal(payload) diff --git a/internal/engine/cloudflare_style_test.go b/internal/engine/cloudflare_style_test.go index 074f90ad..f31a00fc 100644 --- a/internal/engine/cloudflare_style_test.go +++ b/internal/engine/cloudflare_style_test.go @@ -5,7 +5,9 @@ import ( "context" "encoding/json" "io" + "mime/multipart" "net/http" + "net/textproto" "path/filepath" "strings" "testing" @@ -176,27 +178,15 @@ func TestCloudflareStyleAdapter(t *testing.T) { t.Fatalf("create database: missing uuid in response; body %s", body) } - // Query the database + // Query the database: the D1 engine requires a real table model, so + // a SELECT against a missing table is a 400 D1_ERROR. queryBody, _ := json.Marshal(map[string]string{"sql": "SELECT * FROM users"}) body, status = cfPost(t, base+"/accounts/"+accountID+"/d1/database/"+dbUUID+"/query", bearer, queryBody) - if status != 200 { - t.Fatalf("query database -> status %d, want 200; body %s", status, body) - } - if !strings.Contains(body, "\"success\":true") { - t.Fatalf("query: missing success; body %s", body) - } - if !strings.Contains(body, "alice@stunt.dev") { - t.Fatalf("query: missing seeded row data; body %s", body) - } - if !strings.Contains(body, "\"changes\":0") { - t.Fatalf("query: missing meta.changes; body %s", body) + if status != 400 { + t.Fatalf("query missing table -> status %d, want 400; body %s", status, body) } - - // INSERT query returns changes - insertBody, _ := json.Marshal(map[string]string{"sql": "INSERT INTO users (email) VALUES ('new@stunt.dev')"}) - body, status = cfPost(t, base+"/accounts/"+accountID+"/d1/database/"+dbUUID+"/query", bearer, insertBody) - if !strings.Contains(body, "\"changes\":1") { - t.Fatalf("insert query: missing changes:1; body %s", body) + if !strings.Contains(body, "no such table: users") || !strings.Contains(body, "7501") { + t.Fatalf("query missing table: wrong error envelope; body %s", body) } // ===== Firewall rules + page rules ===== @@ -334,6 +324,452 @@ func cfPut(t *testing.T, rawurl, auth string, body []byte) (string, int) { return string(b), resp.StatusCode } +func cfPatch(t *testing.T, rawurl, auth string, body []byte) (string, int) { + t.Helper() + req, err := http.NewRequest("PATCH", rawurl, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + +func cfDelete(t *testing.T, rawurl, auth string) (string, int) { + t.Helper() + req, err := http.NewRequest("DELETE", rawurl, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", auth) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + +// cfServe spins up the cloudflare-style adapter and returns its base URL. +func cfServe(t *testing.T) string { + t.Helper() + adapterDir, err := filepath.Abs(filepath.Join("..", "..", "adapters", "cloudflare-style")) + if err != nil { + t.Fatal(err) + } + stateDir := t.TempDir() + m := &manifest.Manifest{ + Path: filepath.Join(stateDir, "stunt.yaml"), + Version: 1, + Network: manifest.Network{Mode: "port", BasePort: 0}, + Services: map[string]manifest.Service{ + "cf": {Adapter: adapterDir}, + }, + } + e, err := New(m) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + t.Cleanup(func() { e.Close() }) + addrs, cancel, err := e.ServeForTest(context.Background()) + if err != nil { + t.Fatalf("ServeForTest: %v", err) + } + t.Cleanup(cancel) + time.Sleep(50 * time.Millisecond) + return addrs["cf"] +} + +// cfJSON decodes a Cloudflare envelope and returns result. +func cfResult(t *testing.T, body string) any { + t.Helper() + var resp map[string]any + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("bad JSON: %v; body %s", err, body) + } + return resp["result"] +} + +const cfZoneID = "023e105f4ecef8ad9ca31a8372d0c353" + +// TestCloudflareStyleDNSRecordLifecycle exercises the full DNS record +// lifecycle: create -> list/get -> PUT -> PATCH -> DELETE, plus the +// validation failure paths (unknown type, out-of-range ttl). +func TestCloudflareStyleDNSRecordLifecycle(t *testing.T) { + base := cfServe(t) + const bearer = "Bearer stunt-api-token-123" + zone := base + "/zones/" + cfZoneID + "/dns_records" + + // ===== Failure: unknown record type -> 400 ===== + bad, _ := json.Marshal(map[string]any{"type": "BOGUS", "name": "stunt.dev", "content": "192.0.2.9"}) + body, status := cfPost(t, zone, bearer, bad) + if status != 400 { + t.Fatalf("create bogus type -> status %d, want 400; body %s", status, body) + } + if !strings.Contains(body, "unknown record type") { + t.Fatalf("create bogus type: missing message; body %s", body) + } + + // ===== Failure: ttl below 60 (and not 1) -> 400 ===== + badTTL, _ := json.Marshal(map[string]any{"type": "A", "name": "stunt.dev", "content": "192.0.2.9", "ttl": 30}) + body, status = cfPost(t, zone, bearer, badTTL) + if status != 400 { + t.Fatalf("create bad ttl -> status %d, want 400; body %s", status, body) + } + if !strings.Contains(body, "'ttl' must be 1 (auto)") { + t.Fatalf("create bad ttl: missing message; body %s", body) + } + + // ===== Create -> appears in list ===== + good, _ := json.Marshal(map[string]any{"type": "TXT", "name": "_verify.stunt.dev", "content": "stunt-token", "ttl": 3600}) + body, status = cfPost(t, zone, bearer, good) + if status != 200 { + t.Fatalf("create record -> status %d, want 200; body %s", status, body) + } + res, _ := cfResult(t, body).(map[string]any) + recordID, _ := res["id"].(string) + if recordID == "" || res["type"] != "TXT" || res["ttl"].(float64) != 3600 { + t.Fatalf("create record: wrong envelope; body %s", body) + } + if _, ok := res["meta"].(map[string]any); !ok { + t.Fatalf("create record: missing meta object; body %s", body) + } + + body, status = cfGet(t, zone+"?type=TXT", bearer) + if status != 200 || !strings.Contains(body, "_verify.stunt.dev") { + t.Fatalf("list filtered by type: record missing; status %d body %s", status, body) + } + + // ===== Get ===== + body, status = cfGet(t, zone+"/"+recordID, bearer) + if status != 200 || !strings.Contains(body, "stunt-token") { + t.Fatalf("get record: status %d body %s", status, body) + } + + // ===== PATCH content ===== + patch, _ := json.Marshal(map[string]any{"content": "stunt-token-2"}) + body, status = cfPatch(t, zone+"/"+recordID, bearer, patch) + if status != 200 || !strings.Contains(body, "stunt-token-2") { + t.Fatalf("patch record: status %d body %s", status, body) + } + + // ===== PUT full replace (proxied forces ttl 1) ===== + put, _ := json.Marshal(map[string]any{"type": "A", "name": "api.stunt.dev", "content": "192.0.2.9", "proxied": true, "ttl": 3600}) + body, status = cfPut(t, zone+"/"+recordID, bearer, put) + if status != 200 { + t.Fatalf("put record: status %d body %s", status, body) + } + res, _ = cfResult(t, body).(map[string]any) + if res["ttl"].(float64) != 1 || res["proxied"] != true { + t.Fatalf("put record: proxied must force ttl 1; body %s", body) + } + + // ===== DELETE -> gone ===== + body, status = cfDelete(t, zone+"/"+recordID, bearer) + if status != 200 || !strings.Contains(body, recordID) { + t.Fatalf("delete record: status %d body %s", status, body) + } + body, status = cfGet(t, zone+"/"+recordID, bearer) + if status != 404 { + t.Fatalf("get deleted record -> status %d, want 404; body %s", status, body) + } +} + +// TestCloudflareStyleFirewallAndPageRules exercises the store-backed CRUD +// lifecycle for zone firewall rules and page rules. +func TestCloudflareStyleFirewallAndPageRules(t *testing.T) { + base := cfServe(t) + const bearer = "Bearer stunt-api-token-123" + + // ===== Firewall rules ===== + fw := base + "/zones/" + cfZoneID + "/firewall/rules" + + // Seeded canned rule still lists. + body, status := cfGet(t, fw, bearer) + if status != 200 || !strings.Contains(body, "block") { + t.Fatalf("list firewall rules: status %d body %s", status, body) + } + + // Failure: unknown action -> 400. + bad, _ := json.Marshal(map[string]any{"action": "zap", "filter": map[string]string{"expression": "(ip.src eq 192.0.2.0/24)"}}) + body, status = cfPost(t, fw, bearer, bad) + if status != 400 || !strings.Contains(body, "unknown action") { + t.Fatalf("create bad firewall rule: status %d body %s", status, body) + } + + // Create (single-object form of the real array body). + good, _ := json.Marshal(map[string]any{ + "action": "managed_challenge", + "description": "challenge bad bots", + "filter": map[string]string{"expression": "(http.user_agent contains \"bot\")"}, + }) + body, status = cfPost(t, fw, bearer, good) + if status != 200 { + t.Fatalf("create firewall rule: status %d body %s", status, body) + } + created, _ := cfResult(t, body).([]any) + if len(created) != 1 { + t.Fatalf("create firewall rule: want array result; body %s", body) + } + rule := created[0].(map[string]any) + ruleID, _ := rule["id"].(string) + if ruleID == "" || rule["action"] != "managed_challenge" { + t.Fatalf("create firewall rule: wrong envelope; body %s", body) + } + + // Get / PATCH / DELETE. + body, status = cfGet(t, fw+"/"+ruleID, bearer) + if status != 200 || !strings.Contains(body, "challenge bad bots") { + t.Fatalf("get firewall rule: status %d body %s", status, body) + } + patch, _ := json.Marshal(map[string]any{"description": "challenge worse bots"}) + body, status = cfPatch(t, fw+"/"+ruleID, bearer, patch) + if status != 200 || !strings.Contains(body, "challenge worse bots") { + t.Fatalf("patch firewall rule: status %d body %s", status, body) + } + body, status = cfDelete(t, fw+"/"+ruleID, bearer) + if status != 200 { + t.Fatalf("delete firewall rule: status %d body %s", status, body) + } + body, status = cfGet(t, fw+"/"+ruleID, bearer) + if status != 404 { + t.Fatalf("get deleted firewall rule -> status %d, want 404; body %s", status, body) + } + + // ===== Page rules ===== + pr := base + "/zones/" + cfZoneID + "/page_rules" + + body, status = cfGet(t, pr+"?status=active", bearer) + if status != 200 || !strings.Contains(body, "browser_cache_ttl") { + t.Fatalf("list page rules: status %d body %s", status, body) + } + + prBody, _ := json.Marshal(map[string]any{ + "targets": []any{map[string]any{"target": "url", "constraint": map[string]any{"operator": "matches", "value": "stunt.dev/cache/*"}}}, + "actions": []any{map[string]any{"id": "cache_level", "value": "bypass"}}, + "status": "active", + }) + body, status = cfPost(t, pr, bearer, prBody) + if status != 200 { + t.Fatalf("create page rule: status %d body %s", status, body) + } + pRes, _ := cfResult(t, body).(map[string]any) + prID, _ := pRes["id"].(string) + if prID == "" || pRes["status"] != "active" || pRes["priority"].(float64) != 2 { + t.Fatalf("create page rule: wrong envelope; body %s", body) + } + + patch, _ = json.Marshal(map[string]any{"status": "paused"}) + body, status = cfPatch(t, pr+"/"+prID, bearer, patch) + if status != 200 || !strings.Contains(body, "paused") { + t.Fatalf("patch page rule: status %d body %s", status, body) + } + body, status = cfDelete(t, pr+"/"+prID, bearer) + if status != 200 { + t.Fatalf("delete page rule: status %d body %s", status, body) + } + body, status = cfGet(t, pr+"/"+prID, bearer) + if status != 404 { + t.Fatalf("get deleted page rule -> status %d, want 404; body %s", status, body) + } +} + +// TestCloudflareStyleD1QueryEngine proves the stored table model: CREATE +// TABLE registers the schema, INSERT/UPDATE with bound parameters mutate +// persisted rows, SELECT maps WHERE/ORDER BY/LIMIT onto query_select, and +// unsupported SQL returns 400 with the Cloudflare error envelope. +func TestCloudflareStyleD1QueryEngine(t *testing.T) { + base := cfServe(t) + const bearer = "Bearer stunt-api-token-123" + const accountID = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" + + dbBody, _ := json.Marshal(map[string]string{"name": "sqldb"}) + body, status := cfPost(t, base+"/accounts/"+accountID+"/d1/database", bearer, dbBody) + if status != 200 { + t.Fatalf("create database -> %d; body %s", status, body) + } + dbUUID, _ := cfResult(t, body).(map[string]any)["uuid"].(string) + q := base + "/accounts/" + accountID + "/d1/database/" + dbUUID + "/query" + + exec := func(sql string, params []any) (string, int) { + t.Helper() + b, _ := json.Marshal(map[string]any{"sql": sql, "params": params}) + return cfPost(t, q, bearer, b) + } + + // CREATE TABLE (IF NOT EXISTS registers the schema; a second plain + // CREATE errors like real D1). + body, status = exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT, name TEXT, age INTEGER)", nil) + if status != 200 || !strings.Contains(body, "\"success\":true") { + t.Fatalf("create table: status %d body %s", status, body) + } + body, status = exec("CREATE TABLE users (id INTEGER)", nil) + if status != 400 || !strings.Contains(body, "already exists") { + t.Fatalf("recreate table: status %d body %s", status, body) + } + + // INSERT with bound parameters (three rows). + for i, name := range []string{"alice", "bob", "carol"} { + body, status = exec("INSERT INTO users (id, email, name, age) VALUES (?, ?, ?, ?)", []any{i + 1, name + "@stunt.dev", name, 20 + i*10}) + if status != 200 || !strings.Contains(body, "\"changes\":1") { + t.Fatalf("insert %s: status %d body %s", name, status, body) + } + } + + // SELECT * with WHERE + ORDER BY + LIMIT. + body, status = exec("SELECT * FROM users WHERE age > ? ORDER BY age DESC LIMIT 2", []any{15}) + if status != 200 { + t.Fatalf("select: status %d body %s", status, body) + } + results, _ := cfResult(t, body).([]any) + stmt, _ := results[0].(map[string]any) + rows, _ := stmt["results"].([]any) + if len(rows) != 2 { + t.Fatalf("select: want 2 rows, got %v; body %s", stmt["results"], body) + } + first := rows[0].(map[string]any) + if first["name"] != "carol" || first["email"] != "carol@stunt.dev" { + t.Fatalf("select: wrong ordering/values; body %s", body) + } + if _, ok := first["_rid"]; ok { + t.Fatalf("select: internal _rid leaked into results; body %s", body) + } + + // Projection: SELECT id, name. + body, status = exec("SELECT id, name FROM users ORDER BY id ASC", nil) + if status != 200 { + t.Fatalf("projected select: status %d body %s", status, body) + } + results, _ = cfResult(t, body).([]any) + stmt, _ = results[0].(map[string]any) + rows, _ = stmt["results"].([]any) + proj := rows[0].(map[string]any) + if len(proj) != 2 { + t.Fatalf("projected select: want 2 keys, got %v; body %s", proj, body) + } + + // UPDATE with parameters. + body, status = exec("UPDATE users SET name = ? WHERE id = ?", []any{"carolyn", 3}) + if status != 200 || !strings.Contains(body, "\"changes\":1") { + t.Fatalf("update: status %d body %s", status, body) + } + body, status = exec("SELECT name FROM users WHERE id = 3", nil) + if status != 200 || !strings.Contains(body, "carolyn") { + t.Fatalf("select after update: status %d body %s", status, body) + } + + // DELETE. + body, status = exec("DELETE FROM users WHERE id = 2", nil) + if status != 200 || !strings.Contains(body, "\"changes\":1") { + t.Fatalf("delete: status %d body %s", status, body) + } + body, status = exec("SELECT id FROM users", nil) + if status != 200 || strings.Contains(body, "bob@stunt.dev") { + t.Fatalf("select after delete: row still present; body %s", body) + } + + // Unknown SQL -> 400 with the CF error envelope. + body, status = exec("DROP TABLE users", nil) + if status != 400 || !strings.Contains(body, "unsupported SQL statement") || !strings.Contains(body, "7501") { + t.Fatalf("drop table: status %d body %s", status, body) + } + + // Not enough bound parameters -> 400. + body, status = exec("INSERT INTO users (id, email, name, age) VALUES (?, ?, ?, ?)", []any{9}) + if status != 400 || !strings.Contains(body, "not enough parameters") { + t.Fatalf("insert missing params: status %d body %s", status, body) + } +} + +// TestCloudflareStyleWorkerMultipartDeploy deploys a Worker via a real +// multipart/form-data body (metadata + module parts) and verifies the +// script lands in the scripts list with a stable id. +func TestCloudflareStyleWorkerMultipartDeploy(t *testing.T) { + base := cfServe(t) + const bearer = "Bearer stunt-api-token-123" + const accountID = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" + + metadata := `{"main_module":"worker.js","bindings":[]}` + script := "export default { fetch() { return new Response('hi'); } }" + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + mw, _ := w.CreatePart(textproto.MIMEHeader{"Content-Disposition": []string{"form-data; name=\"metadata\""}, "Content-Type": []string{"application/json"}}) + mw.Write([]byte(metadata)) + fw, _ := w.CreatePart(textproto.MIMEHeader{ + "Content-Disposition": []string{"form-data; name=\"worker.js\"; filename=\"worker.js\""}, + "Content-Type": []string{"application/javascript+module"}, + }) + fw.Write([]byte(script)) + w.Close() + + url := base + "/accounts/" + accountID + "/workers/scripts/mp-worker" + req, err := http.NewRequest("PUT", url, bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", bearer) + req.Header.Set("Content-Type", w.FormDataContentType()) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + body := string(b) + if resp.StatusCode != 200 { + t.Fatalf("multipart deploy -> status %d, want 200; body %s", resp.StatusCode, body) + } + res, _ := cfResult(t, body).(map[string]any) + workerID, _ := res["id"].(string) + if workerID == "" { + t.Fatalf("multipart deploy: missing id; body %s", body) + } + + // Redeploy keeps the same worker id. + body, status := cfPutMultipart(t, url, bearer, w.FormDataContentType(), buf.Bytes()) + if status != 200 { + t.Fatalf("multipart redeploy -> status %d; body %s", status, body) + } + if res2, _ := cfResult(t, body).(map[string]any); res2["id"] != workerID { + t.Fatalf("redeploy changed worker id: %v vs %v", res2["id"], workerID) + } + + // The script shows up in the list and by name. + body, status = cfGet(t, base+"/accounts/"+accountID+"/workers/scripts", bearer) + if status != 200 || !strings.Contains(body, "mp-worker") { + t.Fatalf("list scripts after multipart deploy: status %d body %s", status, body) + } + body, status = cfGet(t, url, bearer) + if status != 200 || !strings.Contains(body, workerID) { + t.Fatalf("get script after multipart deploy: status %d body %s", status, body) + } +} + +func cfPutMultipart(t *testing.T, rawurl, auth, contentType string, body []byte) (string, int) { + t.Helper() + req, err := http.NewRequest("PUT", rawurl, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", contentType) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + // TestCloudflareStyleAsyncLifecycles proves the derive-on-read state // machines: a created zone goes pending -> initializing -> active, and a // Worker deployment goes active -> in_progress -> deployed (failed with the @@ -423,3 +859,165 @@ func TestCloudflareStyleAsyncLifecycles(t *testing.T) { t.Fatalf("fail deployment = %v, want one failed", depResp["result"]) } } + +func TestCloudflareStyleExtraPaths(t *testing.T) { + base := cfServe(t) + const bearer = "Bearer stunt-api-token-123" + const accountID = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" + + check := func(name, body string, status int, want int) { + t.Helper() + if status != want { + t.Fatalf("%s -> %d, want %d; body %s", name, status, want, body) + } + if strings.Contains(body, "500") && status >= 500 { + t.Fatalf("%s -> 500; body %s", name, body) + } + } + + // Firewall batch POST (array body, the real shape). + batch, _ := json.Marshal([]any{ + map[string]any{"action": "allow", "filter": map[string]string{"expression": "(ip.src eq 10.0.0.1)"}}, + map[string]any{"action": "log", "filter": map[string]string{"expression": "(ip.src eq 10.0.0.2)"}}, + }) + body, status := cfPost(t, base+"/zones/"+cfZoneID+"/firewall/rules", bearer, batch) + check("fw batch", body, status, 200) + var fwResp map[string]any + json.Unmarshal([]byte(body), &fwResp) + two, _ := fwResp["result"].([]any) + if len(two) != 2 { + t.Fatalf("fw batch: want 2 results, got %s", body) + } + fwID := two[0].(map[string]any)["id"].(string) + + // Firewall PUT (full). + put, _ := json.Marshal(map[string]any{"action": "skip", "description": "skip logging", "filter": map[string]string{"expression": "(ip.src eq 10.0.0.3)"}}) + body, status = cfPut(t, base+"/zones/"+cfZoneID+"/firewall/rules/"+fwID, bearer, put) + check("fw put", body, status, 200) + + // Firewall PUT without action -> 400. + putBad, _ := json.Marshal(map[string]any{"filter": map[string]string{"expression": "(ip.src eq 10.0.0.3)"}}) + body, status = cfPut(t, base+"/zones/"+cfZoneID+"/firewall/rules/"+fwID, bearer, putBad) + check("fw put bad", body, status, 400) + + // Page rule PUT + bad status. + body, status = cfGet(t, base+"/zones/"+cfZoneID+"/page_rules", bearer) + check("pr list", body, status, 200) + var prResp map[string]any + json.Unmarshal([]byte(body), &prResp) + seed := prResp["result"].([]any)[0].(map[string]any) + prID := seed["id"].(string) + prPut, _ := json.Marshal(map[string]any{ + "targets": []any{map[string]any{"target": "url", "constraint": map[string]any{"operator": "matches", "value": "x/*"}}}, + "actions": []any{map[string]any{"id": "browser_cache_ttl", "value": 60}}, + }) + body, status = cfPut(t, base+"/zones/"+cfZoneID+"/page_rules/"+prID, bearer, prPut) + check("pr put", body, status, 200) + prBad, _ := json.Marshal(map[string]any{"status": "bogus"}) + body, status = cfPatch(t, base+"/zones/"+cfZoneID+"/page_rules/"+prID, bearer, prBad) + check("pr patch bad status", body, status, 400) + + // DNS PUT missing name -> 400; PATCH keeps others. + body, status = cfGet(t, base+"/zones/"+cfZoneID+"/dns_records", bearer) + check("dns list", body, status, 200) + var dnResp map[string]any + json.Unmarshal([]byte(body), &dnResp) + rec := dnResp["result"].([]any)[0].(map[string]any) + recID := rec["id"].(string) + putNoName, _ := json.Marshal(map[string]any{"type": "A", "content": "192.0.2.7"}) + body, status = cfPut(t, base+"/zones/"+cfZoneID+"/dns_records/"+recID, bearer, putNoName) + check("dns put missing name", body, status, 400) + patchOnly, _ := json.Marshal(map[string]any{"content": "192.0.2.8"}) + body, status = cfPatch(t, base+"/zones/"+cfZoneID+"/dns_records/"+recID, bearer, patchOnly) + check("dns patch", body, status, 200) + if !strings.Contains(body, "192.0.2.8") { + t.Fatalf("dns patch: content not applied; %s", body) + } + + // Zone delete cascades nothing else; also covers DELETE /zones/{id}. + zb, _ := json.Marshal(map[string]string{"name": "scratch.dev"}) + body, status = cfPost(t, base+"/zones", bearer, zb) + check("zone create", body, status, 200) + var zr map[string]any + json.Unmarshal([]byte(body), &zr) + zid := zr["result"].(map[string]any)["id"].(string) + body, status = cfDelete(t, base+"/zones/"+zid, bearer) + check("zone delete", body, status, 200) + + // D1: multi-statement, INSERT without column list, DELETE without WHERE, + // INSERT with a missing param, unknown column. + dbb, _ := json.Marshal(map[string]string{"name": "scratch-db"}) + body, status = cfPost(t, base+"/accounts/"+accountID+"/d1/database", bearer, dbb) + check("db create", body, status, 200) + var dc map[string]any + json.Unmarshal([]byte(body), &dc) + dbUUID := dc["result"].(map[string]any)["uuid"].(string) + q := base + "/accounts/" + accountID + "/d1/database/" + dbUUID + "/query" + + exec := func(sql string, params []any) (string, int) { + b, _ := json.Marshal(map[string]any{"sql": sql, "params": params}) + return cfPost(t, q, bearer, b) + } + + multi := "CREATE TABLE t (a INTEGER, b TEXT); INSERT INTO t VALUES (?, ?); INSERT INTO t VALUES (?, ?)" + body, status = exec(multi, []any{1, "x", 2, "y"}) + check("d1 multi", body, status, 200) + if !strings.Contains(body, "\"changes\":1") { + t.Fatalf("d1 multi: missing changes; %s", body) + } + if !strings.Contains(body, "\"changes\":0") { + t.Fatalf("d1 multi: missing create-table changes 0; %s", body) + } + + body, status = exec("SELECT b FROM t WHERE a >= 1", nil) + check("d1 select", body, status, 200) + if !strings.Contains(body, "\"x\"") || !strings.Contains(body, "\"y\"") { + t.Fatalf("d1 select: rows missing; %s", body) + } + + body, status = exec("DELETE FROM t", nil) + check("d1 delete all", body, status, 200) + if !strings.Contains(body, "\"changes\":2") { + t.Fatalf("d1 delete all: want 2 changes; %s", body) + } + + body, status = exec("SELECT a FROM t WHERE a = ?", []any{}) + check("d1 params underflow", body, status, 400) + + body, status = exec("SELECT nope FROM t", nil) + check("d1 unknown column", body, status, 400) + + body, status = exec("SELECT * FROM t LIMIT 1 OFFSET 1", nil) + check("d1 limit offset empty", body, status, 200) + + // Database delete cascades tables. + body, status = cfDelete(t, base+"/accounts/"+accountID+"/d1/database/"+dbUUID, bearer) + check("db delete", body, status, 200) + + // Worker raw (non-JSON, non-multipart) body. + body, status = cfPutRaw(t, base+"/accounts/"+accountID+"/workers/scripts/raw-worker", bearer, "text/plain", []byte("addEventListener('fetch', () => {})")) + check("worker raw", body, status, 200) + body, status = cfGet(t, base+"/accounts/"+accountID+"/workers/scripts/raw-worker", bearer) + check("worker raw get", body, status, 200) + + // Malformed multipart -> 400 not 500. + body, status = cfPutRaw(t, base+"/accounts/"+accountID+"/workers/scripts/bad-mp", bearer, "multipart/form-data; boundary=nomatch", []byte("garbage")) + check("worker bad multipart", body, status, 400) +} + +func cfPutRaw(t *testing.T, rawurl, auth, contentType string, body []byte) (string, int) { + t.Helper() + req, err := http.NewRequest("PUT", rawurl, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", contentType) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} diff --git a/internal/engine/helius_style_test.go b/internal/engine/helius_style_test.go index 0f954492..afdd09bf 100644 --- a/internal/engine/helius_style_test.go +++ b/internal/engine/helius_style_test.go @@ -154,6 +154,26 @@ func TestHeliusStyleAdapter(t *testing.T) { } failSig, _ := failSendResp["result"].(string) + // SWAP transaction, attributed to testAddr via the simulate_address + // config flag — it must land in the address's parsed history below. + body, status = heliusPost(t, rpcURL, map[string]any{ + "jsonrpc": "2.0", + "id": 8, + "method": "sendTransaction", + "params": []any{"base64encodedtxdata-swap", map[string]any{"simulate_type": "SWAP", "simulate_address": testAddr}}, + }) + if status != 200 { + t.Fatalf("sendTransaction (swap) -> status %d, want 200; body %s", status, body) + } + var swapSendResp map[string]any + if err := json.Unmarshal([]byte(body), &swapSendResp); err != nil { + t.Fatalf("unmarshal sendTransaction (swap): %v (body %s)", err, body) + } + swapSig, _ := swapSendResp["result"].(string) + if swapSig == "" { + t.Fatalf("swap signature = %v, want non-empty", swapSendResp["result"]) + } + // Immediately after submission the signature has no status yet (null). body, status = heliusPost(t, rpcURL, map[string]any{ "jsonrpc": "2.0", @@ -225,6 +245,300 @@ func TestHeliusStyleAdapter(t *testing.T) { t.Fatalf("failed tx err = nil, want InstructionError") } + // ===== Enhanced Transactions API: GET /v0/addresses/{addr}/transactions ===== + // + // Parsed transaction history for the address: a deterministic seeded + // history PLUS every transaction submitted via sendTransaction that has + // landed, newest first. + + body, status = heliusGet(t, base+"/v0/addresses/"+testAddr+"/transactions?api-key="+apiKey) + if status != 200 { + t.Fatalf("get transactions -> status %d, want 200; body %s", status, body) + } + var txList []any + if err := json.Unmarshal([]byte(body), &txList); err != nil { + t.Fatalf("unmarshal transactions: %v (body %s)", err, body) + } + if len(txList) < 9 { // 8 seeded + the landed SWAP + t.Fatalf("len(transactions) = %d, want >= 9", len(txList)) + } + totalSwaps := 0 + lastTs := int64(1) << 62 + for _, raw := range txList { + tx, ok := raw.(map[string]any) + if !ok { + t.Fatalf("transaction = %v, want object", raw) + } + if tx["signature"] == nil || tx["signature"] == "" { + t.Fatalf("transaction signature = %v, want non-empty", tx["signature"]) + } + txType, _ := tx["type"].(string) + if txType != "TRANSFER" && txType != "SWAP" { + t.Fatalf("transaction type = %q, want TRANSFER or SWAP", txType) + } + if tx["feePayer"] == nil || tx["feePayer"] == "" { + t.Fatalf("transaction feePayer = %v, want non-empty", tx["feePayer"]) + } + fee, ok := tx["fee"].(float64) + if !ok || fee <= 0 { + t.Fatalf("transaction fee = %v, want > 0", tx["fee"]) + } + if tx["events"] == nil { + t.Fatalf("transaction events = %v, want non-nil", tx["events"]) + } + if txType == "SWAP" { + totalSwaps++ + } + // Newest first. + ts, _ := tx["timestamp"].(float64) + if int64(ts) > lastTs { + t.Fatalf("transactions not newest-first: %d after %d", int64(ts), lastTs) + } + lastTs = int64(ts) + } + if totalSwaps < 3 { // 2 seeded + the sent SWAP + t.Fatalf("SWAP transactions = %d, want >= 3", totalSwaps) + } + // The sent SWAP must be present with its events.swap payload. + var swapTx map[string]any + for _, raw := range txList { + tx := raw.(map[string]any) + if tx["signature"] == swapSig { + swapTx = tx + } + } + if swapTx == nil { + t.Fatalf("sent SWAP %s missing from address history", swapSig) + } + if swapTx["type"] != "SWAP" || swapTx["source"] != "JUPITER" { + t.Fatalf("sent tx type/source = %v/%v, want SWAP/JUPITER", swapTx["type"], swapTx["source"]) + } + events, _ := swapTx["events"].(map[string]any) + swapEvent, _ := events["swap"].(map[string]any) + if swapEvent == nil { + t.Fatalf("events.swap = %v, want object", events["swap"]) + } + tokOut, _ := swapEvent["tokenOutput"].(map[string]any) + if tokOut == nil { + t.Fatalf("events.swap.tokenOutput = %v, want object", swapEvent["tokenOutput"]) + } + tokAmt, _ := tokOut["tokenAmount"].(map[string]any) + if tokAmt == nil || tokAmt["amount"] == nil || tokAmt["decimals"] == nil { + t.Fatalf("tokenOutput.tokenAmount = %v, want {amount, decimals}", tokOut["tokenAmount"]) + } + + // type filter: only SWAPs, same count as the unfiltered list. + body, status = heliusGet(t, base+"/v0/addresses/"+testAddr+"/transactions?api-key="+apiKey+"&type=SWAP") + if status != 200 { + t.Fatalf("get transactions (type=SWAP) -> status %d, want 200; body %s", status, body) + } + var swapOnly []map[string]any + if err := json.Unmarshal([]byte(body), &swapOnly); err != nil { + t.Fatalf("unmarshal swap-only transactions: %v (body %s)", err, body) + } + if len(swapOnly) != totalSwaps { + t.Fatalf("type=SWAP returned %d, want %d", len(swapOnly), totalSwaps) + } + for _, tx := range swapOnly { + if tx["type"] != "SWAP" { + t.Fatalf("type=SWAP returned type %v", tx["type"]) + } + } + + // before-cursor pagination: page 2 starts strictly after page 1 and the + // pages are disjoint. + body, status = heliusGet(t, base+"/v0/addresses/"+testAddr+"/transactions?api-key="+apiKey+"&limit=3") + if status != 200 { + t.Fatalf("get transactions (limit=3) -> status %d, want 200; body %s", status, body) + } + var page1 []map[string]any + if err := json.Unmarshal([]byte(body), &page1); err != nil { + t.Fatalf("unmarshal page1: %v (body %s)", err, body) + } + if len(page1) != 3 { + t.Fatalf("len(page1) = %d, want 3", len(page1)) + } + page1Sigs := map[string]bool{} + for _, tx := range page1 { + page1Sigs[tx["signature"].(string)] = true + } + body, status = heliusGet(t, base+"/v0/addresses/"+testAddr+"/transactions?api-key="+apiKey+"&limit=3&before="+page1[2]["signature"].(string)) + if status != 200 { + t.Fatalf("get transactions (page 2) -> status %d, want 200; body %s", status, body) + } + var page2 []map[string]any + if err := json.Unmarshal([]byte(body), &page2); err != nil { + t.Fatalf("unmarshal page2: %v (body %s)", err, body) + } + if len(page2) == 0 { + t.Fatalf("len(page2) = 0, want > 0") + } + for _, tx := range page2 { + if page1Sigs[tx["signature"].(string)] { + t.Fatalf("page 2 repeats page-1 signature %v", tx["signature"]) + } + } + + // ===== JSON-RPC getTransaction ===== + + body, status = heliusPost(t, rpcURL, map[string]any{ + "jsonrpc": "2.0", + "id": 9, + "method": "getTransaction", + "params": []string{page1[0]["signature"].(string)}, + }) + if status != 200 { + t.Fatalf("getTransaction -> status %d, want 200; body %s", status, body) + } + var gtxResp map[string]any + if err := json.Unmarshal([]byte(body), >xResp); err != nil { + t.Fatalf("unmarshal getTransaction: %v (body %s)", err, body) + } + gtx, ok := gtxResp["result"].(map[string]any) + if !ok { + t.Fatalf("getTransaction result = %v, want object", gtxResp["result"]) + } + if gtx["blockTime"] == nil || gtx["slot"] == nil { + t.Fatalf("getTransaction blockTime/slot = %v/%v, want non-nil", gtx["blockTime"], gtx["slot"]) + } + meta, _ := gtx["meta"].(map[string]any) + if meta == nil { + t.Fatalf("getTransaction meta = %v, want object", gtx["meta"]) + } + preBal, _ := meta["preBalances"].([]any) + postBal, _ := meta["postBalances"].([]any) + if len(preBal) == 0 || len(postBal) == 0 || len(preBal) != len(postBal) { + t.Fatalf("meta balances: pre=%v post=%v, want equal non-empty arrays", preBal, postBal) + } + gtxTx, _ := gtx["transaction"].(map[string]any) + if gtxTx == nil { + t.Fatalf("getTransaction transaction = %v, want object", gtx["transaction"]) + } + sigs, _ := gtxTx["signatures"].([]any) + if len(sigs) != 1 || sigs[0] != page1[0]["signature"] { + t.Fatalf("transaction.signatures = %v, want [%v]", sigs, page1[0]["signature"]) + } + + // Unknown signature: result must be null (real RPC behavior). + body, status = heliusPost(t, rpcURL, map[string]any{ + "jsonrpc": "2.0", + "id": 10, + "method": "getTransaction", + "params": []string{"unknownsignature"}, + }) + if status != 200 { + t.Fatalf("getTransaction (unknown) -> status %d, want 200; body %s", status, body) + } + var gtxNull map[string]any + if err := json.Unmarshal([]byte(body), >xNull); err != nil { + t.Fatalf("unmarshal getTransaction (unknown): %v (body %s)", err, body) + } + if gtxNull["result"] != nil { + t.Fatalf("getTransaction (unknown) result = %v, want null", gtxNull["result"]) + } + + // ===== JSON-RPC getTokenAccountsByOwner ===== + + body, status = heliusPost(t, rpcURL, map[string]any{ + "jsonrpc": "2.0", + "id": 11, + "method": "getTokenAccountsByOwner", + "params": []any{testAddr, map[string]any{"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9S996qYXbpM4Vvk"}, map[string]any{"encoding": "jsonParsed"}}, + }) + if status != 200 { + t.Fatalf("getTokenAccountsByOwner -> status %d, want 200; body %s", status, body) + } + var tacctResp map[string]any + if err := json.Unmarshal([]byte(body), &tacctResp); err != nil { + t.Fatalf("unmarshal getTokenAccountsByOwner: %v (body %s)", err, body) + } + tacct, ok := tacctResp["result"].(map[string]any) + if !ok { + t.Fatalf("getTokenAccountsByOwner result = %v, want object", tacctResp["result"]) + } + taccounts, ok := tacct["value"].([]any) + if !ok || len(taccounts) != 2 { + t.Fatalf("value = %v, want 2 token accounts", tacct["value"]) + } + firstAcct := taccounts[0].(map[string]any) + if firstAcct["address"] == nil || firstAcct["mint"] == nil || firstAcct["lamports"] == nil { + t.Fatalf("token account = %v, want address/mint/lamports", firstAcct) + } + firstMint, _ := firstAcct["mint"].(string) + data, _ := firstAcct["data"].(map[string]any) + parsed, _ := data["parsed"].(map[string]any) + info, _ := parsed["info"].(map[string]any) + if info == nil { + t.Fatalf("data.parsed.info = %v, want object (jsonParsed shape)", data) + } + tokAmount, _ := info["tokenAmount"].(map[string]any) + if tokAmount == nil || tokAmount["amount"] == nil || tokAmount["decimals"] == nil { + t.Fatalf("tokenAmount = %v, want {amount, decimals}", info["tokenAmount"]) + } + + // Mint filter narrows to the one matching account. + body, status = heliusPost(t, rpcURL, map[string]any{ + "jsonrpc": "2.0", + "id": 12, + "method": "getTokenAccountsByOwner", + "params": []any{testAddr, map[string]any{"mint": firstMint}, map[string]any{"encoding": "jsonParsed"}}, + }) + if status != 200 { + t.Fatalf("getTokenAccountsByOwner (mint) -> status %d, want 200; body %s", status, body) + } + var tacctMintResp map[string]any + if err := json.Unmarshal([]byte(body), &tacctMintResp); err != nil { + t.Fatalf("unmarshal getTokenAccountsByOwner (mint): %v (body %s)", err, body) + } + tacctMint := tacctMintResp["result"].(map[string]any) + mintAccounts, _ := tacctMint["value"].([]any) + if len(mintAccounts) != 1 { + t.Fatalf("mint-filtered value = %v, want 1 account", tacctMint["value"]) + } + if mintAccounts[0].(map[string]any)["mint"] != firstMint { + t.Fatalf("mint-filtered account mint = %v, want %v", mintAccounts[0].(map[string]any)["mint"], firstMint) + } + + // ===== POST /v0/transactions (parse) ===== + + body, status = heliusPost(t, base+"/v0/transactions?api-key="+apiKey, map[string]any{ + "transactions": []string{"base64encodedrawtxdata0000000000000000000000000000000000000000"}, + }) + if status != 200 { + t.Fatalf("parse transactions -> status %d, want 200; body %s", status, body) + } + var parsedList []map[string]any + if err := json.Unmarshal([]byte(body), &parsedList); err != nil { + t.Fatalf("unmarshal parsed transactions: %v (body %s)", err, body) + } + if len(parsedList) != 1 { + t.Fatalf("len(parsed) = %d, want 1", len(parsedList)) + } + if parsedList[0]["type"] != "TRANSFER" || parsedList[0]["feePayer"] == nil { + t.Fatalf("parsed tx = %v, want TRANSFER with feePayer", parsedList[0]) + } + + // Validation failure path: empty transactions array -> 400. + body, status = heliusPost(t, base+"/v0/transactions?api-key="+apiKey, map[string]any{ + "transactions": []string{}, + }) + if status != 400 { + t.Fatalf("parse transactions (empty) -> status %d, want 400; body %s", status, body) + } + var parseErr map[string]any + if err := json.Unmarshal([]byte(body), &parseErr); err != nil { + t.Fatalf("unmarshal parse error: %v (body %s)", err, body) + } + if parseErr["error"] == nil { + t.Fatalf("parse error body = %v, want error field", parseErr) + } + + // GET transactions without api-key: 401. + _, status = heliusGet(t, base+"/v0/addresses/"+testAddr+"/transactions") + if status != 401 { + t.Fatalf("get transactions without api-key -> status %d, want 401", status) + } + // ===== 401 without api-key ===== _, status = heliusPost(t, base+"/", map[string]any{ diff --git a/internal/engine/zuora_style_test.go b/internal/engine/zuora_style_test.go index 2b88473f..ccc3aecb 100644 --- a/internal/engine/zuora_style_test.go +++ b/internal/engine/zuora_style_test.go @@ -293,6 +293,469 @@ func TestZuoraStyleAdapter(t *testing.T) { } } +// TestZuoraStyleBillingSemantics exercises the billing-fidelity surface: +// +// - subscription create generates the first invoice from the rate plan +// charges (chargeOverrides pricing, 10% tax, Net-30 due date, Posted) +// - unknown productRatePlanId -> 400 +// - subscription update: add/remove rate plans, term recompute, deep-merged +// custom fields +// - billing preview computed from the rate plans (not hardcoded) +// - payments: partial application (Processed-Partially, invoice balance +// decremented), over-application rejected, full application (Processed), +// unapply restoring balances +// - cancel: Immediate (Canceled + prorated credit memo) and EndOfTerm +// (stays Active; derive-on-read flip to Canceled once the term ends) +func TestZuoraStyleBillingSemantics(t *testing.T) { + adapterDir := filepath.Join("..", "..", "adapters", "zuora-style") + absAdapterDir, err := filepath.Abs(adapterDir) + if err != nil { + t.Fatal(err) + } + + stateDir := t.TempDir() + manifestPath := filepath.Join(stateDir, "stunt.yaml") + + m := &manifest.Manifest{ + Path: manifestPath, + Version: 1, + Network: manifest.Network{Mode: "port", BasePort: 0}, + Services: map[string]manifest.Service{ + "zuora": {Adapter: absAdapterDir}, + }, + } + + e, err := New(m) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + defer e.Close() + + addrs, cancel, err := e.ServeForTest(context.Background()) + if err != nil { + t.Fatalf("ServeForTest: %v", err) + } + defer cancel() + time.Sleep(50 * time.Millisecond) + + base := addrs["zuora"] + bearerToken := "Bearer zuora-bearer-token" + + // ===== unknown product rate plan -> 400 ===== + + _, status := zuoraAuthPostJSON(t, base+"/v1/subscriptions", bearerToken, map[string]any{ + "accountKey": "ACC-A", + "subscribeToRatePlans": []map[string]any{ + {"productRatePlanId": "rateplan-bogus"}, + }, + }) + if status != 400 { + t.Fatalf("create subscription with bogus plan -> %d, want 400", status) + } + + // ===== create subscription: rate plan + chargeOverrides -> first invoice ===== + // price 50 * qty 2 = 100 charge, 10 tax -> invoice amount 110. + + body, status := zuoraAuthPostJSON(t, base+"/v1/subscriptions", bearerToken, map[string]any{ + "accountKey": "ACC-A", + "termType": "TERMED", + "initialTerm": 12, + "contractEffectiveDate": "2024-02-01", + "subscribeToRatePlans": []map[string]any{ + { + "productRatePlanId": "rateplan-standard", + "productRatePlanName": "Standard Plan", + "chargeOverrides": []map[string]any{ + {"pricing": []map[string]any{{"currency": "USD", "price": 50}}, "quantity": 2}, + }, + }, + }, + }) + if status != 200 { + t.Fatalf("create subscription -> %d, want 200; body %s", status, body) + } + var subResp map[string]any + if err := json.Unmarshal([]byte(body), &subResp); err != nil { + t.Fatalf("unmarshal subscription: %v (body %s)", err, body) + } + subID, _ := subResp["subscriptionId"].(string) + if subID == "" { + t.Fatalf("subscriptionId = %v, want non-empty", subResp["subscriptionId"]) + } + invoiceNumber, _ := subResp["invoiceNumber"].(string) + if invoiceNumber == "" { + t.Fatalf("invoiceNumber = %v, want non-empty (first invoice generated)", subResp["invoiceNumber"]) + } + if got := subResp["invoiceAmount"].(float64); !zuoraAlmostEq(got, 110) { + t.Fatalf("invoiceAmount = %v, want 110 (50x2 + 10%% tax)", subResp["invoiceAmount"]) + } + + // GET the generated invoice: Posted, balance == amount, Net-30 due date. + + body, status = zuoraAuthGet(t, base+"/v1/invoices/"+invoiceNumber, bearerToken) + if status != 200 { + t.Fatalf("get invoice -> %d, want 200; body %s", status, body) + } + var invResp map[string]any + if err := json.Unmarshal([]byte(body), &invResp); err != nil { + t.Fatalf("unmarshal invoice: %v (body %s)", err, body) + } + if invResp["status"] != "Posted" { + t.Fatalf("invoice status = %v, want Posted", invResp["status"]) + } + if got := invResp["balance"].(float64); !zuoraAlmostEq(got, 110) { + t.Fatalf("invoice balance = %v, want 110", invResp["balance"]) + } + if invResp["dueDate"] != "2024-03-02" { + t.Fatalf("invoice dueDate = %v, want 2024-03-02 (invoice date + Net-30)", invResp["dueDate"]) + } + items, ok := invResp["invoiceItems"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("invoiceItems = %v, want exactly 1", invResp["invoiceItems"]) + } + item0 := items[0].(map[string]any) + if got := item0["chargeAmount"].(float64); !zuoraAlmostEq(got, 100) { + t.Fatalf("invoiceItem chargeAmount = %v, want 100", item0["chargeAmount"]) + } + if got := item0["taxAmount"].(float64); !zuoraAlmostEq(got, 10) { + t.Fatalf("invoiceItem taxAmount = %v, want 10", item0["taxAmount"]) + } + + // ===== subscription update: swap rate plans, recompute term, deep-merge ===== + + body, status = zuoraAuthPutJSON(t, base+"/v1/subscriptions/"+subID, bearerToken, map[string]any{ + "removeRatePlans": []string{"rateplan-standard"}, + "addRatePlans": []map[string]any{ + {"productRatePlanId": "rateplan-growth"}, + }, + "initialTerm": 24, + "customFields": map[string]any{"crm": map[string]any{"id": "acct-42"}}, + }) + if status != 200 { + t.Fatalf("update subscription -> %d, want 200; body %s", status, body) + } + var updResp map[string]any + if err := json.Unmarshal([]byte(body), &updResp); err != nil { + t.Fatalf("unmarshal update: %v (body %s)", err, body) + } + if added, ok := updResp["addedRatePlans"].([]any); !ok || len(added) != 1 { + t.Fatalf("addedRatePlans = %v, want exactly 1", updResp["addedRatePlans"]) + } + + body, status = zuoraAuthGet(t, base+"/v1/subscriptions/"+subID, bearerToken) + if status != 200 { + t.Fatalf("get updated subscription -> %d, want 200; body %s", status, body) + } + var getSub map[string]any + if err := json.Unmarshal([]byte(body), &getSub); err != nil { + t.Fatalf("unmarshal get subscription: %v (body %s)", err, body) + } + plans, ok := getSub["subscriptionPlans"].([]any) + if !ok || len(plans) != 1 { + t.Fatalf("subscriptionPlans = %v, want exactly 1 after swap", getSub["subscriptionPlans"]) + } + plan0 := plans[0].(map[string]any) + if plan0["productRatePlanId"] != "rateplan-growth" { + t.Fatalf("plan productRatePlanId = %v, want rateplan-growth", plan0["productRatePlanId"]) + } + if plan0["productRatePlanName"] != "Growth Plan" { + t.Fatalf("plan productRatePlanName = %v, want Growth Plan (from catalog)", plan0["productRatePlanName"]) + } + wantEnd := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, 24*30).Format("2006-01-02") + if getSub["endDate"] != wantEnd { + t.Fatalf("endDate = %v, want %s (initialTerm 24 recomputed)", getSub["endDate"], wantEnd) + } + custom, ok := getSub["customFields"].(map[string]any) + if !ok { + t.Fatalf("customFields = %v, want object", getSub["customFields"]) + } + crm, ok := custom["crm"].(map[string]any) + if !ok || crm["id"] != "acct-42" { + t.Fatalf("customFields.crm = %v, want deep-merged {id: acct-42}", custom["crm"]) + } + + // ===== billing preview computed from the rate plans ===== + // Seeded SUB-B (ACC-B) uses rateplan-enterprise: 499 + 10% tax = 548.9. + + body, status = zuoraAuthPostJSON(t, base+"/v1/transactions/billing/preview", bearerToken, map[string]any{ + "accountKey": "ACC-B", + "subscriptionKey": "SUB-B", + }) + if status != 200 { + t.Fatalf("billing preview -> %d, want 200; body %s", status, body) + } + var preview map[string]any + if err := json.Unmarshal([]byte(body), &preview); err != nil { + t.Fatalf("unmarshal preview: %v (body %s)", err, body) + } + if preview["currency"] != "EUR" { + t.Fatalf("preview currency = %v, want EUR (account currency)", preview["currency"]) + } + inv, ok := preview["invoice"].(map[string]any) + if !ok { + t.Fatalf("preview invoice = %v, want object", preview["invoice"]) + } + if got := inv["amount"].(float64); !zuoraAlmostEq(got, 548.9) { + t.Fatalf("preview amount = %v, want 548.9 (499 + 10%% tax)", inv["amount"]) + } + pItems, ok := inv["invoiceItems"].([]any) + if !ok || len(pItems) != 1 { + t.Fatalf("preview invoiceItems = %v, want exactly 1", inv["invoiceItems"]) + } + if got := pItems[0].(map[string]any)["chargeAmount"].(float64); !zuoraAlmostEq(got, 499) { + t.Fatalf("preview chargeAmount = %v, want 499 (catalog price)", pItems[0]) + } + + // ===== partial payment: Processed-Partially, invoice balance decremented ===== + // Seeded INV-B (ACC-B): amount 890.50, balance 890.50. + + body, status = zuoraAuthPostJSON(t, base+"/v1/payments", bearerToken, map[string]any{ + "accountKey": "ACC-B", + "amount": 400, + "type": "External", + "appliedTo": []map[string]any{ + {"invoiceId": "INV-B", "appliedAmount": 250}, + }, + }) + if status != 200 { + t.Fatalf("create partial payment -> %d, want 200; body %s", status, body) + } + var payResp map[string]any + if err := json.Unmarshal([]byte(body), &payResp); err != nil { + t.Fatalf("unmarshal payment: %v (body %s)", err, body) + } + if payResp["status"] != "Processed-Partially" { + t.Fatalf("payment status = %v, want Processed-Partially", payResp["status"]) + } + if got := payResp["appliedAmount"].(float64); !zuoraAlmostEq(got, 250) { + t.Fatalf("payment appliedAmount = %v, want 250", payResp["appliedAmount"]) + } + if got := payResp["unappliedAmount"].(float64); !zuoraAlmostEq(got, 150) { + t.Fatalf("payment unappliedAmount = %v, want 150", payResp["unappliedAmount"]) + } + paymentID, _ := payResp["paymentId"].(string) + if paymentID == "" { + t.Fatalf("paymentId = %v, want non-empty", payResp["paymentId"]) + } + + body, status = zuoraAuthGet(t, base+"/v1/invoices/INV-B", bearerToken) + if status != 200 { + t.Fatalf("get invoice after partial payment -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &invResp); err != nil { + t.Fatalf("unmarshal invoice after payment: %v (body %s)", err, body) + } + if got := invResp["balance"].(float64); !zuoraAlmostEq(got, 640.5) { + t.Fatalf("invoice balance after partial payment = %v, want 640.50", invResp["balance"]) + } + appliedPayments, ok := invResp["appliedPayments"].([]any) + if !ok || len(appliedPayments) != 1 { + t.Fatalf("appliedPayments = %v, want exactly 1", invResp["appliedPayments"]) + } + + // ===== over-application -> 400 (failure path) ===== + + body, status = zuoraAuthPostJSON(t, base+"/v1/payments", bearerToken, map[string]any{ + "accountKey": "ACC-B", + "amount": 10000, + "appliedTo": []map[string]any{ + {"invoiceId": "INV-B", "appliedAmount": 10000}, + }, + }) + if status != 400 { + t.Fatalf("over-applied payment -> %d, want 400; body %s", status, body) + } + + // ===== full payment: Processed, invoice balance 0 ===== + + body, status = zuoraAuthPostJSON(t, base+"/v1/payments", bearerToken, map[string]any{ + "accountKey": "ACC-B", + "amount": 640.5, + "appliedTo": []map[string]any{ + {"invoiceId": "INV-B", "appliedAmount": 640.5}, + }, + }) + if status != 200 { + t.Fatalf("create full payment -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &payResp); err != nil { + t.Fatalf("unmarshal full payment: %v (body %s)", err, body) + } + if payResp["status"] != "Processed" { + t.Fatalf("full payment status = %v, want Processed", payResp["status"]) + } + + body, status = zuoraAuthGet(t, base+"/v1/invoices/INV-B", bearerToken) + if status != 200 { + t.Fatalf("get invoice after full payment -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &invResp); err != nil { + t.Fatalf("unmarshal invoice after full payment: %v (body %s)", err, body) + } + if got := invResp["balance"].(float64); !zuoraAlmostEq(got, 0) { + t.Fatalf("invoice balance after full payment = %v, want 0", invResp["balance"]) + } + + // ===== unapply: restores invoice + payment applied amount ===== + + body, status = zuoraAuthPostJSON(t, base+"/v1/payments/"+paymentID+"/unapply", bearerToken, map[string]any{ + "amount": 100, + }) + if status != 200 { + t.Fatalf("unapply payment -> %d, want 200; body %s", status, body) + } + var unapplyResp map[string]any + if err := json.Unmarshal([]byte(body), &unapplyResp); err != nil { + t.Fatalf("unmarshal unapply: %v (body %s)", err, body) + } + if got := unapplyResp["appliedAmount"].(float64); !zuoraAlmostEq(got, 150) { + t.Fatalf("unapplied payment appliedAmount = %v, want 150", unapplyResp["appliedAmount"]) + } + + body, status = zuoraAuthGet(t, base+"/v1/invoices/INV-B", bearerToken) + if status != 200 { + t.Fatalf("get invoice after unapply -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &invResp); err != nil { + t.Fatalf("unmarshal invoice after unapply: %v (body %s)", err, body) + } + if got := invResp["balance"].(float64); !zuoraAlmostEq(got, 100) { + t.Fatalf("invoice balance after unapply = %v, want 100", invResp["balance"]) + } + + // ===== cancel Immediate: Canceled now + prorated credit memo ===== + // Fresh TERMED subscription effective today: the credit memo covers the + // full charge total (99 + 9.9 tax = 108.9 invoice, credit 99 pre-tax). + + body, status = zuoraAuthPostJSON(t, base+"/v1/subscriptions", bearerToken, map[string]any{ + "accountKey": "ACC-A", + "termType": "TERMED", + "initialTerm": 12, + "contractEffectiveDate": "2024-01-01", + "subscribeToRatePlans": []map[string]any{ + {"productRatePlanId": "rateplan-standard"}, + }, + }) + if status != 200 { + t.Fatalf("create subscription for cancel -> %d, want 200; body %s", status, body) + } + var sub2 map[string]any + if err := json.Unmarshal([]byte(body), &sub2); err != nil { + t.Fatalf("unmarshal subscription 2: %v (body %s)", err, body) + } + sub2ID, _ := sub2["subscriptionId"].(string) + + body, status = zuoraAuthPostJSON(t, base+"/v1/subscriptions/"+sub2ID+"/cancel", bearerToken, map[string]any{ + "cancellationPolicy": "Immediate", + }) + if status != 200 { + t.Fatalf("cancel immediate -> %d, want 200; body %s", status, body) + } + var cancelResp map[string]any + if err := json.Unmarshal([]byte(body), &cancelResp); err != nil { + t.Fatalf("unmarshal cancel: %v (body %s)", err, body) + } + if cancelResp["status"] != "Canceled" { + t.Fatalf("immediate cancel status = %v, want Canceled", cancelResp["status"]) + } + creditNumber, _ := cancelResp["creditMemoNumber"].(string) + if creditNumber == "" { + t.Fatalf("creditMemoNumber = %v, want non-empty (prorated credit issued)", cancelResp["creditMemoNumber"]) + } + if got := cancelResp["creditMemoAmount"].(float64); !zuoraAlmostEq(got, 99) { + t.Fatalf("creditMemoAmount = %v, want 99 (full-term credit)", cancelResp["creditMemoAmount"]) + } + + body, status = zuoraAuthGet(t, base+"/v1/invoices/"+creditNumber, bearerToken) + if status != 200 { + t.Fatalf("get credit memo -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &invResp); err != nil { + t.Fatalf("unmarshal credit memo: %v (body %s)", err, body) + } + if got := invResp["amount"].(float64); !zuoraAlmostEq(got, -99) { + t.Fatalf("credit memo amount = %v, want -99", invResp["amount"]) + } + + // ===== cancel EndOfTerm: stays Active, flips to Canceled on read ===== + // TERMED subscription whose term ends today: the pending cancellation is + // derived on the next GET. + + body, status = zuoraAuthPostJSON(t, base+"/v1/subscriptions", bearerToken, map[string]any{ + "accountKey": "ACC-A", + "termType": "TERMED", + "contractEffectiveDate": "2024-01-01", + "termEndDate": "2024-01-01", + "subscribeToRatePlans": []map[string]any{ + {"productRatePlanId": "rateplan-standard"}, + }, + }) + if status != 200 { + t.Fatalf("create subscription for eot cancel -> %d, want 200; body %s", status, body) + } + var sub3 map[string]any + if err := json.Unmarshal([]byte(body), &sub3); err != nil { + t.Fatalf("unmarshal subscription 3: %v (body %s)", err, body) + } + sub3ID, _ := sub3["subscriptionId"].(string) + + body, status = zuoraAuthPostJSON(t, base+"/v1/subscriptions/"+sub3ID+"/cancel", bearerToken, map[string]any{ + "cancellationPolicy": "EndOfTerm", + }) + if status != 200 { + t.Fatalf("cancel end-of-term -> %d, want 200; body %s", status, body) + } + var eotResp map[string]any + if err := json.Unmarshal([]byte(body), &eotResp); err != nil { + t.Fatalf("unmarshal eot cancel: %v (body %s)", err, body) + } + if eotResp["status"] != "Active" { + t.Fatalf("end-of-term cancel status = %v, want Active until term end", eotResp["status"]) + } + if eotResp["cancellationEffectiveDate"] != "2024-01-01" { + t.Fatalf("cancellationEffectiveDate = %v, want 2024-01-01 (term end)", eotResp["cancellationEffectiveDate"]) + } + + body, status = zuoraAuthGet(t, base+"/v1/subscriptions/"+sub3ID, bearerToken) + if status != 200 { + t.Fatalf("get eot-cancelled subscription -> %d, want 200; body %s", status, body) + } + if err := json.Unmarshal([]byte(body), &getSub); err != nil { + t.Fatalf("unmarshal eot get: %v (body %s)", err, body) + } + if getSub["status"] != "Canceled" { + t.Fatalf("eot subscription status on read = %v, want Canceled (term ended on the synthetic calendar)", getSub["status"]) + } +} + +// zuoraAlmostEq compares money amounts at cent precision. +func zuoraAlmostEq(a, b float64) bool { + d := a - b + if d < 0 { + d = -d + } + return d < 0.005 +} + +// zuoraAuthPutJSON sends an authenticated JSON PUT. +func zuoraAuthPutJSON(t *testing.T, rawurl, authHeader string, payload map[string]any) (string, int) { + t.Helper() + data, _ := json.Marshal(payload) + req, err := http.NewRequest("PUT", rawurl, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", authHeader) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode +} + // === Zuora test helpers === func zuoraAuthGet(t *testing.T, rawurl, authHeader string) (string, int) { From 32551aecaa8a5a7057dff69916233e81eeec0036 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Sat, 15 Aug 2026 07:01:54 +0300 Subject: [PATCH 3/3] fix(review): sev3 fidelity findings across the seven P1 adapters - zuora: config.webhook_url sinks receive signed callouts again (were dead behind the REST-hook gate); partial unapply decrements the invoice's appliedPayments entry (invoice/payment views now agree) - cloudflare: SELECT FROM t (empty non-star list) 400s instead of leaking the internal _rid; malformed worker metadata part 400s - azure-devops: patch remove on Title/State/WorkItemType 400s; single- object patch bodies accepted (were silent no-ops); non-array patch docs 400; malformed versionDescriptor and non-string WIQL 400 - apple-searchads: grandTotals spend integer-formatted; ad create 404s on unknown campaigns - helius: confirmation webhook timestamps the delivery copy only (the stored doc keeps send time, matching getTransaction blockTime) - braintree test pins error codes 91507/91506; concurrency notes added to braintree/azure-devops/helius READMEs (list-vs-keyed and GraphQL body-id windows) --- .../apple-searchads-style/scripts/campaigns.star | 4 ++++ .../apple-searchads-style/scripts/reports.star | 2 +- adapters/azure-devops-style/README.md | 9 +++++++++ adapters/azure-devops-style/scripts/git.star | 4 ++++ .../azure-devops-style/scripts/workitems.star | 16 ++++++++++++++-- adapters/braintree-style/README.md | 9 +++++++++ adapters/cloudflare-style/scripts/d1.star | 4 ++++ adapters/cloudflare-style/scripts/workers.star | 2 ++ adapters/helius-style/README.md | 9 +++++++++ adapters/helius-style/scripts/lib.star | 8 ++++++-- adapters/zuora-style/scripts/billing.star | 6 +++++- adapters/zuora-style/scripts/lib.star | 4 ++++ internal/engine/braintree_style_test.go | 14 +++++++++++++- 13 files changed, 84 insertions(+), 7 deletions(-) diff --git a/adapters/apple-searchads-style/scripts/campaigns.star b/adapters/apple-searchads-style/scripts/campaigns.star index 45c3c5a4..9549687d 100644 --- a/adapters/apple-searchads-style/scripts/campaigns.star +++ b/adapters/apple-searchads-style/scripts/campaigns.star @@ -141,6 +141,10 @@ def on_create_ad(req): return respond(401, _err("Missing or invalid authorization")) campaign_id = req["params"]["campaign_id"] + _seed_campaigns() + cc = store_collection("campaigns") + if _asa_find_campaign(cc, campaign_id) == None: + return respond(404, _err("Campaign not found")) body = req["body"] if body == None: body = {} diff --git a/adapters/apple-searchads-style/scripts/reports.star b/adapters/apple-searchads-style/scripts/reports.star index 461c62d5..c6e91d96 100644 --- a/adapters/apple-searchads-style/scripts/reports.star +++ b/adapters/apple-searchads-style/scripts/reports.star @@ -92,7 +92,7 @@ def on_report_campaigns(req): "impressions": tot_impressions, "taps": tot_taps, "installs": tot_installs, - "spend": {"amount": str(tot_spend), "currency": "USD"}, + "spend": {"amount": _asa_num_str(tot_spend), "currency": "USD"}, }, }, }, diff --git a/adapters/azure-devops-style/README.md b/adapters/azure-devops-style/README.md index 2de295a4..eb4e32c9 100644 --- a/adapters/azure-devops-style/README.md +++ b/adapters/azure-devops-style/README.md @@ -138,3 +138,12 @@ authentication is configured on the subscription (basic auth, bearer token, or custom headers on the real consumer inputs). stunt does not invent a signature — deliveries carry the real envelope with no signature headers. Secure your receiver with a secret path segment or an auth check of your own. + +## Concurrency note + +Transitions persist before webhook emission, and id-scoped routes carry +`concurrency_key`. Two remaining narrow windows exist by design: list/bulk +surfaces (advanced_search, runs list, RPC) can race a keyed single-resource +read on the same record and in principle double-emit the transition webhook; +and GraphQL mutations (braintree) key on a body id the engine cannot lock — +the REST surface is the concurrency-safe one. diff --git a/adapters/azure-devops-style/scripts/git.star b/adapters/azure-devops-style/scripts/git.star index 5c2c69ab..f73a29c6 100644 --- a/adapters/azure-devops-style/scripts/git.star +++ b/adapters/azure-devops-style/scripts/git.star @@ -313,6 +313,10 @@ def _resolve_commit_id(req, repo): version = repo.get("defaultBranch", "refs/heads/main") vd = _get_query(req, "versionDescriptor", "") if vd != None and vd != "" and vd[:1] == "{": + # json.decode raises on malformed input (surfacing as a 500); reject + # unlikely-shaped descriptors up front instead. + if vd.find('"') < 0 or vd[0:1] != "{" or vd[-1:] != "}": + return _git_fault(400, "Invalid versionDescriptor", "InvalidArgument", 0) parsed = json.decode(vd) vt = parsed.get("versionType", "branch") version = parsed.get("version", "") diff --git a/adapters/azure-devops-style/scripts/workitems.star b/adapters/azure-devops-style/scripts/workitems.star index 171e988c..3d00a2b8 100644 --- a/adapters/azure-devops-style/scripts/workitems.star +++ b/adapters/azure-devops-style/scripts/workitems.star @@ -111,6 +111,8 @@ def on_create_workitem(req): # Process the patch-document operations (JSON array bodies arrive # wrapped as {_batch: [...]} by the engine). ops = _body_ops(body) + if ops == None: + return _wit_fault(400, "The request body must be a JSON patch document (array of operations).", "InvalidArgument") for op in ops: if op == None: continue @@ -169,7 +171,10 @@ def on_update_workitem(req): relations = [] changed = 0 - for op in _body_ops(body): + ops2 = _body_ops(body) + if ops2 == None: + return _wit_fault(400, "The request body must be a JSON patch document (array of operations).", "InvalidArgument") + for op in ops2: if op == None: continue o = op.get("op", "") @@ -185,6 +190,8 @@ def on_update_workitem(req): fields[field] = value changed = changed + 1 elif o == "remove": + if field in ("System.Title", "System.State", "System.WorkItemType"): + return _wit_fault(400, "The field '" + field + "' cannot be removed.", "PatchOperationFailedException") fields = _dict_delete(fields, field) changed = changed + 1 elif o == "test": @@ -242,6 +249,8 @@ def on_wiql(req): if body == None: body = {} query = body.get("query", "") + if type(query) != "string": + return _wit_fault(400, "The query must be a WIQL string.", "InvalidArgument") if query == None: query = "" @@ -429,8 +438,11 @@ def _body_ops(body): batch = body.get("_batch", None) if batch != None: ops = batch + if type(ops) == "dict": + # A single-operation object is legal for many clients; wrap it. + return [ops] if type(ops) != "list": - return [] + return None return ops # _norm_work_item_type normalizes a work item type route param: accepts diff --git a/adapters/braintree-style/README.md b/adapters/braintree-style/README.md index 2699c060..9473535b 100644 --- a/adapters/braintree-style/README.md +++ b/adapters/braintree-style/README.md @@ -271,3 +271,12 @@ guard, simulated authorization expiry, full/partial/over refunds, the nonexistent-transaction 404, `advanced_search` (status `in` + amount range), plan/subscription create-list-get, `Active -> Canceled`, `Active -> Expired`, and the cancel-non-Active failure path. + +## Concurrency note + +Transitions persist before webhook emission, and id-scoped routes carry +`concurrency_key`. Two remaining narrow windows exist by design: list/bulk +surfaces (advanced_search, runs list, RPC) can race a keyed single-resource +read on the same record and in principle double-emit the transition webhook; +and GraphQL mutations (braintree) key on a body id the engine cannot lock — +the REST surface is the concurrency-safe one. diff --git a/adapters/cloudflare-style/scripts/d1.star b/adapters/cloudflare-style/scripts/d1.star index 0c467478..c9f82d6b 100644 --- a/adapters/cloudflare-style/scripts/d1.star +++ b/adapters/cloudflare-style/scripts/d1.star @@ -404,6 +404,10 @@ def _exec_select(db, toks, params, cur): i += 1 if i >= len(toks): return None, "D1_ERROR: expected FROM" + if not star and len(cols) == 0: + # Real SQLite rejects "SELECT FROM t"; without this the unprojected + # rows would leak the internal _rid. + return None, "D1_ERROR: expected column list" i += 1 if i >= len(toks) or toks[i]["t"] != "word": return None, "D1_ERROR: missing table name" diff --git a/adapters/cloudflare-style/scripts/workers.star b/adapters/cloudflare-style/scripts/workers.star index 86351c4f..7a0499de 100644 --- a/adapters/cloudflare-style/scripts/workers.star +++ b/adapters/cloudflare-style/scripts/workers.star @@ -74,6 +74,8 @@ def on_deploy_script(req): script_content = p["data"] main_module = p["name"] elif p["name"] == "metadata": + if p.get("data", "").find('"') < 0: + return _cf_err(400, 10001, "metadata part must be a JSON object") meta = json.decode(p["data"]) if meta != None: mm = meta.get("main_module", None) diff --git a/adapters/helius-style/README.md b/adapters/helius-style/README.md index dba86229..8dd0cd60 100644 --- a/adapters/helius-style/README.md +++ b/adapters/helius-style/README.md @@ -163,3 +163,12 @@ Balances, NFTs, and token holdings are deterministic based on the address hash. --- *Synthetic. No real Helius data. See [DISCLAIMER](DISCLAIMER).* + +## Concurrency note + +Transitions persist before webhook emission, and id-scoped routes carry +`concurrency_key`. Two remaining narrow windows exist by design: list/bulk +surfaces (advanced_search, runs list, RPC) can race a keyed single-resource +read on the same record and in principle double-emit the transition webhook; +and GraphQL mutations (braintree) key on a body id the engine cannot lock — +the REST surface is the concurrency-safe one. diff --git a/adapters/helius-style/scripts/lib.star b/adapters/helius-style/scripts/lib.star index 0a425ccb..fc0eed19 100644 --- a/adapters/helius-style/scripts/lib.star +++ b/adapters/helius-style/scripts/lib.star @@ -380,8 +380,12 @@ def _signature_status(txc, doc): doc["state"] = state txc.update(doc["id"], doc) if state == "confirmed": - # Real Helius webhooks deliver when the transaction confirms. - tx = doc.get("tx", {}) + # Real Helius webhooks deliver when the transaction confirms; + # stamp the delivery copy only — the stored doc keeps the send + # time so later reads agree with getTransaction's blockTime. + tx = {} + for k in doc.get("tx", {}): + tx[k] = doc["tx"][k] tx["timestamp"] = clock.now_unix() _webhook_emit(tx) diff --git a/adapters/zuora-style/scripts/billing.star b/adapters/zuora-style/scripts/billing.star index 4c4bc144..97652f96 100644 --- a/adapters/zuora-style/scripts/billing.star +++ b/adapters/zuora-style/scripts/billing.star @@ -332,7 +332,11 @@ def on_unapply_payment(req): new_list = [] for j in range(len(ap_list)): ap_entry = ap_list[j] - if ap_entry.get("paymentId", "") == doc.get("paymentId", "") and _money_eq(ap_entry.get("appliedAmount", 0), take): + if ap_entry.get("paymentId", "") == doc.get("paymentId", ""): + remain_ap = _round2(ap_entry.get("appliedAmount", 0) - take) + if remain_ap > 0 and not _money_eq(remain_ap, 0): + ap_entry["appliedAmount"] = remain_ap + new_list.append(ap_entry) continue new_list.append(ap_entry) inv["appliedPayments"] = new_list diff --git a/adapters/zuora-style/scripts/lib.star b/adapters/zuora-style/scripts/lib.star index b129eae4..b9b9df89 100644 --- a/adapters/zuora-style/scripts/lib.star +++ b/adapters/zuora-style/scripts/lib.star @@ -922,6 +922,10 @@ def _emit_if_subscribed(event_type, payload): hc = store_collection("webhooks") hooks = hc.list() if len(hooks) == 0: + # No REST-registered hook: a config.webhook_url sink still gets the + # callout, signed with the shared mock secret (README contract). + if events_target() != None: + _signed_emit(event_type, payload, "") return # events_register re-points delivery to the LATEST hook; sign with that # hook's secret, not the oldest matching one. diff --git a/internal/engine/braintree_style_test.go b/internal/engine/braintree_style_test.go index b7940a92..0e3be7f6 100644 --- a/internal/engine/braintree_style_test.go +++ b/internal/engine/braintree_style_test.go @@ -136,11 +136,17 @@ func TestBraintreeStyleAdapter(t *testing.T) { t.Fatalf("txnManual status = %v, want authorized", txnManual["status"]) } - // Refund from authorized must fail (settled only). + // Refund from authorized must fail (settled only), with Braintree's code. body, status = btPostJSON(t, txns+"/"+txnManual["id"].(string)+"/refund", "Bearer bt-token", map[string]any{}) if status != 422 { t.Fatalf("refund from authorized -> %d, want 422; body %s", status, body) } + var refundErr map[string]any + if err := json.Unmarshal([]byte(body), &refundErr); err == nil { + if code, _ := refundErr["error"].(map[string]any)["code"].(string); code != "91507" { + t.Fatalf("refund-from-authorized code = %v, want 91507", refundErr["error"]) + } + } // Void from authorized works; voiding again fails. txnVoid := btCreateTxn(t, txns, map[string]any{"amount": "20.00", "type": "sale"}) @@ -158,6 +164,12 @@ func TestBraintreeStyleAdapter(t *testing.T) { if status != 422 { t.Fatalf("void already-voided -> %d, want 422; body %s", status, body) } + var voidErr map[string]any + if err := json.Unmarshal([]byte(body), &voidErr); err == nil { + if code, _ := voidErr["error"].(map[string]any)["code"].(string); code != "91506" { + t.Fatalf("void-not-authorized code = %v, want 91506", voidErr["error"]) + } + } // Settle with a non-positive amount → 422. body, status = btPostJSON(t, txns+"/"+txnManual["id"].(string)+"/settle", "Bearer bt-token", map[string]any{"amount": "0.00"})