Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,58 @@ All notable changes to **stunt** are documented here. The format is based on

## [Unreleased]

## [0.48.0] — 2026-08-17

### Testing

- **SDK conformance wave 2 — three more official/standard SDKs, 17 new
checks (36 across six SDK families).**
- **twilio-go** — message create, the `queued → sent → delivered`
lifecycle driven by SDK Fetch polling, the `+15005550001` magic
invalid-number → `failed` trigger, list filters, and status-callback
webhooks verified against Twilio's documented HMAC-SHA1 formula.
- **go-shopify** (bold-commerce, the standard Go client) — webhook
registration, order creates, `page_info` cursor pagination through
the SDK's `NextPageOptions` Link-header walking, and deliveries
verified by the SDK's own `VerifyWebhookRequest` HMAC validator.
- **google-api-go-client / x/oauth2** — the full authorization-code
exchange, refresh with rotation, userinfo, and the marquee:
**`idtoken.Validate` — Google's own RS256+JWKS verifier — accepting
the adapter-minted id_token against the adapter-served
`/oauth2/v3/certs`.**

### Adapters

- Wave-2 findings, all fixed:
- **twilio-style's mock auth token contained underscores** — real
Twilio tokens are 32-char alphanumeric, and official SDKs validate
that client-side, rejecting the credentials before any request. The
documented token is now `feed0000face1111beef2222cafe3333` (update
any hardcoded credential).
- **twilio-style versioned its API `/2010-06-01/`** — the real API
(and every SDK) uses `/2010-04-01/`. All routes renamed.
- **twilio-style status callbacks delivered the stunt envelope, not
Twilio's callback shape.** Real Twilio POSTs the message resource as
form parameters signed with
`base64(HMAC-SHA1(token, url + sorted key/value pairs))`; the
adapter now delivers exactly that (via `events_emit_raw`), so real
receivers — and Twilio's documented validation — verify out of the
box. The lifecycle engine test now verifies every callback's
signature.
- **shopify-style rendered webhook ids, embedded customer ids,
fulfillment/transaction ids, and variant ids as JSON strings** —
Shopify ids are numeric; typed SDKs (`go-shopify`) reject the
response outright. The id coercion is total over the shapes an id
can take (stored string, JSON int, JSON float) — the first cut
crashed on numeric customer ids, 500ing the most common Shopify
create pattern and poisoning later order lists (caught in review,
pinned by the embedded-customer conformance check).
- **google-style's token endpoint rejected HTTP Basic client
credentials** (RFC 6749 §2.3.1) — the default style of
`golang.org/x/oauth2` and the Google SDKs. The first attempt also
burned the single-use code, so the library's retry could never
succeed. Both grant types now accept Basic or form credentials.

## [0.47.0] — 2026-08-17

### Testing
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,10 @@ stunt catalog search stripe # browse the adapter registry
Square, Adyen, AWS S3, Google/Microsoft/Apple families, blockchain RPCs, …; all unofficial,
synthetic-data-only, with a DISCLAIMER). Browse them with `stunt catalog search`. Every one
passes an adversarial input-safety sweep (garbage params, null/malformed bodies, ~30 tampered
cursor/limit param names — never a 5xx) plus coverage-guided fuzzing of the engine's parsers
and dispatch (`just fuzz` for longer rounds). Highlights:
cursor/limit param names — never a 5xx), coverage-guided fuzzing of the engine's parsers and
dispatch (`just fuzz`), and conformance suites that drive **real provider SDKs** — stripe-go,
aws-sdk-go-v2, go-github, twilio-go, go-shopify, google-api-go-client — end-to-end against
the adapters (`just conformance`). Highlights:

| Adapter | Simulates | Backing |
|---|---|---|
Expand Down
4 changes: 2 additions & 2 deletions adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,15 +320,15 @@ events_emit("push", payload, {"X-Hub-Signature-256": "sha256=" + sig, "X-GitHub-
| shopify-style | yes | `X-Shopify-Hmac-SHA256` | `shpss_stunt_mock_api_client_secret` | base64 |
| whatsapp-style | yes | `X-Hub-Signature-256` (Meta) | `whatsapp_stunt_mock_app_secret_2026` | hex |
| square-style | yes | `X-Square-HmacSha256-Signature` (URL+body) | `sq0sip_stunt_mock_signature_key_2026` | base64 |
| twilio-style | yes | `X-Twilio-Signature` (SHA-1, URL+body) | `twilio_auth_token` | base64 |
| twilio-style | yes | `X-Twilio-Signature` (SHA-1, URL+sorted form params) | `feed0000face1111beef2222cafe3333` | base64 |
| discord-style | yes | `X-Signature-Ed25519` + `X-Signature-Timestamp` (Ed25519 over ts+body) | Ed25519 keypair in adapter (`_ED25519_PUBLIC_KEY`); verify deliveries/interactions against it | hex |
| adyen-style | deferred | — | — | — |
| braintree-style | yes | body/header `bt_signature` (`public_key\|hex(HMAC-SHA1(private_key, bt_payload))`) + `bt-hash` | `stunt_mock_public_key_2026` / `stunt_mock_private_key_2026` (SHA-1 over the base64 `bt_payload`, not the outer JSON body) | hex |
| zuora-style | yes | `X-Zuora-Signature` | `zuora_stunt_mock_webhook_secret_2026` (per-hook `secret` at registration wins) | hex |

Unsigned-by-design emitters (real provider has no receiver-computable HMAC): paypal-style (cert-based signature verified via the `POST /v1/notifications/verify-webhook-signature` API, which the adapter also serves) and revenuecat-style (v1 webhooks are unsigned; validate the `app_user_id` via `GET /v1/subscribers/{id}`).

Deferred providers need schemes the current primitives don't cover yet: Adyen signs an in-body `hmacSignature` over a derived field-concatenation. (Twilio was deferred for HMAC-SHA-1 over the sink URL + body — now shipped, via `crypto.hmac_sha1` + the new `events_target()` builtin for the delivery URL.) Square likewise MACs the notification URL + body. Braintree was deferred for raw-byte HMAC keys + form-encoded delivery — now shipped, simplified to the SHA-1-over-base64-payload MAC delivered as JSON body + headers.
Deferred providers need schemes the current primitives don't cover yet: Adyen signs an in-body `hmacSignature` over a derived field-concatenation. (Twilio was deferred for HMAC-SHA-1 over the sink URL + the callback's sorted form params — now shipped, via `crypto.hmac_sha1` + the new `events_target()` builtin for the delivery URL.) Square likewise MACs the notification URL + body. Braintree was deferred for raw-byte HMAC keys + form-encoded delivery — now shipped, simplified to the SHA-1-over-base64-payload MAC delivered as JSON body + headers.

To receive events, set `config.webhook_url` in your `stunt.yaml`:

Expand Down
23 changes: 23 additions & 0 deletions adapters/google-style/scripts/oauth.star
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,18 @@ def on_token(req):
body = {}
grant_type = body.get("grant_type", "")

# OAuth2 (RFC 6749 §2.3.1) client credentials may arrive as HTTP Basic
# — the DEFAULT style of golang.org/x/oauth2 and most Google SDKs.
basic_cid, basic_secret = _basic_client(req)

if grant_type == "refresh_token":
presented = body.get("refresh_token", "")
client_id = body.get("client_id") or ""
client_secret = body.get("client_secret") or ""

if client_id == "" or client_secret == "":
client_id = basic_cid
client_secret = basic_secret
if client_id == "" or client_secret == "":
return respond(400, {"error": "invalid_client", "error_description": "missing client creds"})

Expand All @@ -135,6 +142,9 @@ def on_token(req):
client_id = body.get("client_id", "")
client_secret = body.get("client_secret", "")
redirect_uri = body.get("redirect_uri", "")
if client_id == "" or client_secret == "":
client_id = basic_cid
client_secret = basic_secret

cc = store_collection("codes")
code_doc = cc.get(code)
Expand All @@ -151,6 +161,19 @@ def on_token(req):
scope = code_doc.get("scope", "openid email profile")
return respond(200, _issue_tokens(_mint_user(), scope, client_id))

# _basic_client extracts RFC 6749 §2.3.1 HTTP Basic client credentials.
def _basic_client(req):
h = req["headers"].get("Authorization", "")
if h == None or h[:6] != "Basic ":
return "", ""
dec = crypto.base64_decode(h[6:])
if dec == None:
return "", ""
i = dec.find(":")
if i < 0:
return "", ""
return dec[:i], dec[i + 1:]

# on_certs serves the JWKS at Google's real discovery path
# (/oauth2/v3/certs). The key is REAL: derived from the fixed synthetic
# RSA keypair whose private half signs the id_tokens minted when the
Expand Down
45 changes: 39 additions & 6 deletions adapters/shopify-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,30 @@ def _next_id(kind):
n = store_kv_incr("shopify", kind + "_seq")
return str(_BASE_ID + n)

# _num_id converts a stored string id back to an int for JSON responses
# (Shopify returns numeric ids in REST/GraphQL responses).
def _num_id(s):
return _to_int(s)
# _num_id converts a stored or inbound id to an int for JSON responses
# (Shopify returns numeric ids in REST/GraphQL responses). TOTAL over the
# shapes an id can take: stored string, JSON int, JSON float (the engine
# decodes numbers to float when a client sends an id as a number) — a
# plain string parser raised on the latter two and 500'd the response.
def _num_id(v):
if v == None:
return 0
if type(v) == "int":
return v
if type(v) == "float":
return int(v)
return _to_int(str(v))

# _customer_id_numeric coerces an embedded customer object's id (typed SDKs
# unmarshal order.customer.id as int64).
def _customer_id_numeric(c):
if c == None:
return None
if c.get("id", None) == None:
return c
out = dict(c)
out["id"] = _num_id(c["id"])
return out

# _seed populates default products, orders, and customers on first access so
# that list endpoints return realistic data without prior setup.
Expand Down Expand Up @@ -365,7 +385,7 @@ def _order_view(o):
"total_price": o.get("total_price", "0.00"),
"currency": o.get("currency", "USD"),
"line_items": line_views,
"customer": o.get("customer", {}),
"customer": _customer_id_numeric(o.get("customer", {})),
"order_number": o.get("order_number", 0),
"name": o.get("name", ""),
"closed_at": o.get("closed_at", None),
Expand All @@ -387,9 +407,22 @@ def _product_view(p):
"tags": p.get("tags", ""),
"created_at": p.get("created_at", _now()),
"updated_at": p.get("updated_at", _now()),
"variants": p.get("variants", []),
"variants": _variants_numeric(p.get("variants", [])),
}

# _variants_numeric coerces variant id/product_id to numeric at render —
# seeded variants store string ids and typed SDKs unmarshal them as ints.
def _variants_numeric(vs):
out = []
for v in vs:
w = dict(v)
if w.get("id", None) != None:
w["id"] = _num_id(w["id"])
if w.get("product_id", None) != None:
w["product_id"] = _num_id(w["product_id"])
out.append(w)
return out


def _customer_view(c):
return {
Expand Down
34 changes: 31 additions & 3 deletions adapters/shopify-style/scripts/orders.star
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ def on_create_order(req):
customer = input_ord.get("customer", {})
if customer == None:
customer = {}
else:
# Shopify embeds numeric customer ids on orders.
if customer.get("id", None) != None:
customer = dict(customer)
customer["id"] = _num_id(customer["id"])
email = input_ord.get("email", "")
if email == None:
email = ""
Expand Down Expand Up @@ -301,9 +306,9 @@ def on_create_fulfillment(req):
oc.update(oid, order)

# Emit webhook event if subscribed.
_emit_if_subscribed("fulfillments/create", fulfillment)
_emit_if_subscribed("fulfillments/create", _fulfillment_view(fulfillment))

return respond(201, {"fulfillment": fulfillment})
return respond(201, {"fulfillment": _fulfillment_view(fulfillment)})

# on_create_transaction records a transaction (capture/sale/refund/void)
# against the order and re-derives the order's financial_status from ALL its
Expand Down Expand Up @@ -350,10 +355,33 @@ def on_create_transaction(req):
order["updated_at"] = _now()
oc.update(oid, order)

return respond(201, {"transaction": transaction})
return respond(201, {"transaction": _transaction_view(transaction)})

# --- helpers ---

# _fulfillment_view / _transaction_view render numeric ids (stored as
# strings; typed SDKs unmarshal Shopify ids as ints).
def _fulfillment_view(f):
out = dict(f)
out["id"] = _num_id(f["id"])
out["order_id"] = _num_id(f["order_id"])
if out.get("line_items", None) != None:
lines = []
for li in out["line_items"]:
w = dict(li)
if w.get("id", None) != None:
w["id"] = _num_id(w["id"])
lines.append(w)
out["line_items"] = lines
return out

def _transaction_view(t):
out = dict(t)
out["id"] = _num_id(t["id"])
out["order_id"] = _num_id(t["order_id"])
return out


# _order_view returns the public-facing order object. Internal keys (the
# per-line _fulfilled counters) are projected away by _line_item_view.
# Numeric ids are converted from stored strings back to ints.
Expand Down
2 changes: 1 addition & 1 deletion adapters/shopify-style/scripts/webhooks.star
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def on_delete_webhook(req):
# _webhook_view returns the public-facing webhook subscription object.
def _webhook_view(w):
return {
"id": w["id"],
"id": _num_id(w["id"]),
"topic": w.get("topic", ""),
"address": w.get("address", ""),
"format": w.get("format", "json"),
Expand Down
49 changes: 26 additions & 23 deletions adapters/twilio-style/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Twilio-style adapter

A stunt adapter for simulating a **Twilio REST API (2010-06-01)** locally.
A stunt adapter for simulating a **Twilio REST API (2010-04-01)** locally.
All data is synthetic — no real API data is included.

> **Unofficial / not affiliated.** This adapter is not affiliated with, endorsed
Expand All @@ -14,14 +14,14 @@ A faithful behavioral mock of Twilio's Programmable Messaging, Voice, and
Verify surfaces, designed for local integration testing without a real Twilio
account:

- **Send SMS/MMS:** `POST /2010-06-01/Accounts/{Sid}/Messages.json` (`{To, From, Body}`).
- **List messages:** `GET /2010-06-01/Accounts/{Sid}/Messages.json` (cursor-paginated
- **Send SMS/MMS:** `POST /2010-04-01/Accounts/{Sid}/Messages.json` (`{To, From, Body}`).
- **List messages:** `GET /2010-04-01/Accounts/{Sid}/Messages.json` (cursor-paginated
via `PageSize` + `PageToken`, with a Twilio-style `next_page_uri`; filters
`To`, `From`, `DateSent` (also `DateSent>`/`DateSent<` windows — queued
messages with a null `date_sent` are excluded by date filters, like the
real API)).
- **Retrieve message:** `GET .../Messages/{Sid}.json`.
- **Create call:** `POST /2010-06-01/Accounts/{Sid}/Calls.json` (`{To, From, Url}`).
- **Create call:** `POST /2010-04-01/Accounts/{Sid}/Calls.json` (`{To, From, Url}`).
- **Verify:** `POST /v2/Services/{ServiceSid}/Verification` → `{status:"pending"}`.
- **Verify check:** `POST /v2/Services/{ServiceSid}/VerificationCheck` (`{To, Code}`) → `{status:"approved"}` on correct code.

Expand All @@ -43,20 +43,20 @@ header:

```
AccountSid = AC0123456789abcdef0123456789abcdef
AuthToken = twilio_auth_token
AuthToken = feed0000face1111beef2222cafe3333
```

Base64 of `AC0123456789abcdef0123456789abcdef:twilio_auth_token`:
Base64 of `AC0123456789abcdef0123456789abcdef:feed0000face1111beef2222cafe3333`:

```
QUMwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZjp0d2lsaW9fYXV0aF90b2tlbg==
QUMwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZjpmZWVkMDAwMGZhY2UxMTExYmVlZjIyMjJjYWZlMzMzMw==
```

### Example

```bash
curl -u "AC0123456789abcdef0123456789abcdef:twilio_auth_token" \
http://localhost:PORT/2010-06-01/Accounts/AC0123456789abcdef0123456789abcdef/Messages.json \
curl -u "AC0123456789abcdef0123456789abcdef:feed0000face1111beef2222cafe3333" \
http://localhost:PORT/2010-04-01/Accounts/AC0123456789abcdef0123456789abcdef/Messages.json \
-d 'To=+15551234567' \
-d 'From=+15557654321' \
-d 'Body=Hello from stunt'
Expand All @@ -65,7 +65,7 @@ curl -u "AC0123456789abcdef0123456789abcdef:twilio_auth_token" \
### 401 without auth

```bash
curl http://localhost:PORT/2010-06-01/Accounts/AC.../Messages.json
curl http://localhost:PORT/2010-04-01/Accounts/AC.../Messages.json
# → 401 {"code":20003,"message":"Missing or invalid Basic Auth credentials",...}
```

Expand Down Expand Up @@ -120,22 +120,25 @@ registered webhook sink. See the stunt docs for webhook configuration

### Signed deliveries — `X-Twilio-Signature`

Webhook deliveries are signed exactly the way Twilio signs its webhook
requests — the header carries a base64 HMAC-SHA1 over the delivery URL plus
the raw request body:
Webhook deliveries use Twilio's real status-callback shape: the message
resource as **form parameters** (`AccountSid`, `ApiVersion`, `From`,
`MessageSid`, `MessageStatus`, `To`), signed with a base64 HMAC-SHA1 over
the delivery URL plus the parameters **sorted by key, each key immediately
followed by its (decoded) value**:

```
X-Twilio-Signature = base64(HMAC-SHA1(key=twilio_auth_token,
msg=events_target_url + raw_body))
X-Twilio-Signature = base64(HMAC-SHA1(key=feed0000face1111beef2222cafe3333,
msg=url + concat(sorted, key + value)))
```

The URL is the webhook destination configured as this service's
`events_target` (Twilio MACs the full request URL, so a receiver must validate
against the same URL stunt delivered to), and the body is the exact JSON
envelope on the wire. The signing key is the documented mock AuthToken:
`events_target` — Twilio MACs the full request URL, so a receiver must
validate against the same URL stunt delivered to. A receiver built from
Twilio's validation documentation verifies every delivery as-is. The
signing key is the documented mock AuthToken:

```
twilio_auth_token
feed0000face1111beef2222cafe3333
```

A receiver can therefore exercise real signature-verification code paths
Expand All @@ -148,10 +151,10 @@ their deliveries and their mock secrets.

| Method | Route | Handler | Description |
|--------|-------|---------|-------------|
| POST | `/2010-06-01/Accounts/{account_sid}/Messages.json` | `messages.star#on_send_message` | Send a message (→ `queued`) |
| GET | `/2010-06-01/Accounts/{account_sid}/Messages.json` | `messages.star#on_list_messages` | List messages (stateful, cursor-paginated) |
| GET | `/2010-06-01/Accounts/{account_sid}/Messages/{sid}.json` | `messages.star#on_get_message` | Retrieve a message |
| POST | `/2010-06-01/Accounts/{account_sid}/Calls.json` | `calls.star#on_create_call` | Create a call (→ `queued`) |
| POST | `/2010-04-01/Accounts/{account_sid}/Messages.json` | `messages.star#on_send_message` | Send a message (→ `queued`) |
| GET | `/2010-04-01/Accounts/{account_sid}/Messages.json` | `messages.star#on_list_messages` | List messages (stateful, cursor-paginated) |
| GET | `/2010-04-01/Accounts/{account_sid}/Messages/{sid}.json` | `messages.star#on_get_message` | Retrieve a message |
| POST | `/2010-04-01/Accounts/{account_sid}/Calls.json` | `calls.star#on_create_call` | Create a call (→ `queued`) |
| POST | `/v2/Services/{service_sid}/Verification` | `verify.star#on_create_verification` | Start a verification |
| POST | `/v2/Services/{service_sid}/VerificationCheck` | `verify.star#on_check_verification` | Check a verification code |

Expand Down
Loading
Loading