diff --git a/CONFORMANCE.md b/CONFORMANCE.md
index de12322d..aa69f853 100644
--- a/CONFORMANCE.md
+++ b/CONFORMANCE.md
@@ -21,7 +21,7 @@ Verification tiers:
the all-adapters-boot guard on every CI run; no SDK suite drives it yet.
- Every adapter additionally documents its behavior in depth in its README.
-**98 adapters** — 2 SDK+VM, 34 SDK-only, 38 VM-only, 24 boot-tier.
+**98 adapters** — 2 SDK+VM, 34 SDK-only, 59 VM-only, 3 boot-tier.
**45 adapters carry derived provider-surface coverage**: their real-API route totals come from the route tables embedded in the pinned official SDKs (Google Discovery docs inside `google-api-go-client`; generated tables inside the Node clients) or from official specs vendored under `conformance/surfaces/` (refreshed by `just surfaces-fetch`) — mechanical and network-free at generation time. For those rows the derived not-implemented list supplements the curated Missing column; adapters without one have no trustworthy machine-readable surface and stay fully curated.
@@ -31,13 +31,13 @@ Behavior columns come in two kinds: **verified** (an official SDK was driven aga
|---|---|---|---|---|---|---|---|
| [adyen-style](adapters/adyen-style/) | Adyen Checkout + Notification API `v68` | 14 | VM | — | — | [4](#adyen-style) | [8](#adyen-style) |
| [anaplan-style](adapters/anaplan-style/) | Anaplan API `2.0` | 18 | VM | — | — | [4](#anaplan-style) | [5](#anaplan-style) |
-| [apple-apns-style](adapters/apple-apns-style/) | Apple Push Notification service (APNs) `v2` | 2 | boot | — | — | [4](#apple-apns-style) | [2](#apple-apns-style) |
+| [apple-apns-style](adapters/apple-apns-style/) | Apple Push Notification service (APNs) `v2` | 2 | VM | — | — | [4](#apple-apns-style) | [3](#apple-apns-style) |
| [apple-appstoreconnect-style](adapters/apple-appstoreconnect-style/) | App Store Connect API `v3` | 15 | VM | — | — | [7](#apple-appstoreconnect-style) | [7](#apple-appstoreconnect-style) |
| [apple-music-style](adapters/apple-music-style/) | Apple Music API `1.0` | 18 | VM | — | — | [5](#apple-music-style) | [5](#apple-music-style) |
| [apple-searchads-style](adapters/apple-searchads-style/) | Apple Search Ads API `v4` | 12 | VM | — | — | [4](#apple-searchads-style) | [5](#apple-searchads-style) |
| [apps-script-style](adapters/apps-script-style/) | Google Apps Script API `v1` | 11 | SDK | google-api-go-client @ v0.293.0 | 6 | [2](#apps-script-style) | [3](#apps-script-style) |
| [auth0-style](adapters/auth0-style/) | Auth0 Authentication & Management API `v2` | 17 | VM | — | — | [3](#auth0-style) | [4](#auth0-style) |
-| [avalara-style](adapters/avalara-style/) | Avalara AvaTax REST API `2` | 8 | boot | — | — | [5](#avalara-style) | [1](#avalara-style) |
+| [avalara-style](adapters/avalara-style/) | Avalara AvaTax REST API `2` | 8 | VM | — | — | [5](#avalara-style) | [3](#avalara-style) |
| [aws-cognito-style](adapters/aws-cognito-style/) | Amazon Cognito Identity Provider API `2016-04-18` | 7 | VM | — | — | [6](#aws-cognito-style) | [3](#aws-cognito-style) |
| [aws-iam-sts-style](adapters/aws-iam-sts-style/) | AWS STS + IAM API `2011-06-15` | 2 | SDK | aws-sdk-go-v2 @ v1.43.7 | 2 | [3](#aws-iam-sts-style) | [3](#aws-iam-sts-style) |
| [aws-s3-style](adapters/aws-s3-style/) | Amazon S3 API `2006-03-01` | 8 | SDK | aws-sdk-go-v2 @ v1.43.7 | 6 | [5](#aws-s3-style) | [5](#aws-s3-style) |
@@ -50,19 +50,19 @@ Behavior columns come in two kinds: **verified** (an official SDK was driven aga
| [braze-style](adapters/braze-style/) | Braze REST API `2.0` | 12 | VM | — | — | [8](#braze-style) | [6](#braze-style) |
| [chainlink-style](adapters/chainlink-style/) | Chainlink Data Feeds + Functions + Automation `1.0` | 21 | VM | — | — | [6](#chainlink-style) | [7](#chainlink-style) |
| [cloudflare-style](adapters/cloudflare-style/) | Cloudflare API `4` | 35 | SDK | cloudflare-go @ v0.117.0 | 7 | [8](#cloudflare-style) | [6](#cloudflare-style) |
-| [cloudkit-style](adapters/cloudkit-style/) | CloudKit Web Services API `1` | 5 | boot | — | — | [6](#cloudkit-style) | [2](#cloudkit-style) |
+| [cloudkit-style](adapters/cloudkit-style/) | CloudKit Web Services API `1` | 5 | VM | — | — | [6](#cloudkit-style) | [2](#cloudkit-style) |
| [discord-style](adapters/discord-style/) | Discord API `v10` | 26 (+1 ws) | SDK | discord-node @ 2.6.3 (floor) | 5 | [8](#discord-style) | [5](#discord-style) |
| [drive-style](adapters/drive-style/) | Google Drive API `v3` | 13 | SDK | google-api-go-client @ v0.293.0 | 8 | [7](#drive-style) | [5](#drive-style) |
-| [dropbox-style](adapters/dropbox-style/) | Dropbox API `2` | 8 | boot | — | — | [8](#dropbox-style) | [4](#dropbox-style) |
-| [dune-style](adapters/dune-style/) | Dune Analytics API `v1` | 6 | boot | — | — | [5](#dune-style) | [3](#dune-style) |
+| [dropbox-style](adapters/dropbox-style/) | Dropbox API `2` | 8 | VM | — | — | [8](#dropbox-style) | [4](#dropbox-style) |
+| [dune-style](adapters/dune-style/) | Dune Analytics API `v1` | 6 | VM | — | — | [5](#dune-style) | [4](#dune-style) |
| [dynamodb-style](adapters/dynamodb-style/) | Amazon DynamoDB API `2012-08-10` | 1 | SDK + VM | aws-sdk-go-v2 @ v1.43.7 | 8 | [8](#dynamodb-style) | [5](#dynamodb-style) |
| [echo-style](adapters/echo-style/) | gRPC echo demo `1.0` | 0 (+1 ws) | boot | — | — | — | — |
| [emailoctopus-style](adapters/emailoctopus-style/) | EmailOctopus API `2.0.0` | 21 | VM | — | — | [2](#emailoctopus-style) | [3](#emailoctopus-style) |
| [entra-id-style](adapters/entra-id-style/) | Microsoft Graph / Entra ID `v1.0` | 9 | SDK | microsoft-graph-client @ 3.0.7 (floor) | 8 | [8](#entra-id-style) | [3](#entra-id-style) |
-| [erc4337-style](adapters/erc4337-style/) | ERC-4337 Bundler RPC `0.7` | 2 | boot | — | — | [5](#erc4337-style) | [4](#erc4337-style) |
+| [erc4337-style](adapters/erc4337-style/) | ERC-4337 Bundler RPC `0.7` | 2 | VM | — | — | [5](#erc4337-style) | [6](#erc4337-style) |
| [escrow-style](adapters/escrow-style/) | Escrow.com API `2017-09-01` | 9 | VM | — | — | [3](#escrow-style) | [4](#escrow-style) |
| [eth-jsonrpc-style](adapters/eth-jsonrpc-style/) | Ethereum JSON-RPC `1.0` | 1 | SDK | go-ethereum @ v1.17.5 | 5 | [5](#eth-jsonrpc-style) | [4](#eth-jsonrpc-style) |
-| [etherscan-style](adapters/etherscan-style/) | Etherscan API `1.0` | 1 | boot | — | — | [3](#etherscan-style) | [1](#etherscan-style) |
+| [etherscan-style](adapters/etherscan-style/) | Etherscan API `1.0` | 1 | VM | — | — | [3](#etherscan-style) | [2](#etherscan-style) |
| [fattureincloud-style](adapters/fattureincloud-style/) | Fatture in Cloud API v2 `2.0.29` | 42 | VM | — | — | [3](#fattureincloud-style) | [6](#fattureincloud-style) |
| [firebase-style](adapters/firebase-style/) | Firebase Auth + Firestore + Cloud Messaging API `v1` | 22 | VM | — | — | [4](#firebase-style) | [8](#firebase-style) |
| [ga4-style](adapters/ga4-style/) | Google Analytics Data API + Admin API `v1beta` | 7 | SDK | google-api-go-client @ v0.293.0 | 7 | [4](#ga4-style) | [3](#ga4-style) |
@@ -81,19 +81,19 @@ Behavior columns come in two kinds: **verified** (an official SDK was driven aga
| [hubspot-style](adapters/hubspot-style/) | HubSpot CRM API `v3` | 33 | SDK | hubspot-node @ 14.0.1 (floor) | 5 | [4](#hubspot-style) | — |
| [instagram-style](adapters/instagram-style/) | Instagram Graph API `v21.0` | 10 | VM | — | — | [4](#instagram-style) | [5](#instagram-style) |
| [jira-style](adapters/jira-style/) | Jira Cloud REST API `3` | 32 | SDK | jira-js @ 6.1.0 (floor) | 7 | [5](#jira-style) | [6](#jira-style) |
-| [jumio-style](adapters/jumio-style/) | Jumio API `v1` | 5 | boot | — | — | [3](#jumio-style) | [4](#jumio-style) |
-| [linkedin-style](adapters/linkedin-style/) | LinkedIn API `v2` | 8 | boot | — | — | [4](#linkedin-style) | [1](#linkedin-style) |
+| [jumio-style](adapters/jumio-style/) | Jumio API `v1` | 5 | VM | — | — | [3](#jumio-style) | [6](#jumio-style) |
+| [linkedin-style](adapters/linkedin-style/) | LinkedIn API `v2` | 8 | VM | — | — | [4](#linkedin-style) | [3](#linkedin-style) |
| [llm-style](adapters/llm-style/) | OpenAI API + Anthropic API `OpenAI v1 / Anthropic v1` | 3 | SDK | openai-node @ 7.5.0 (floor) | 2 | [4](#llm-style) | [3](#llm-style) |
| [marketo-style](adapters/marketo-style/) | Marketo Engage REST API `1.0` | 21 | VM | — | — | [5](#marketo-style) | [3](#marketo-style) |
| [microsoft-graph-style](adapters/microsoft-graph-style/) | Microsoft Graph API `v1.0` | 55 | SDK | microsoft-graph-client @ 3.0.7 (floor) | 10 | [8](#microsoft-graph-style) | [6](#microsoft-graph-style) |
| [netsuite-style](adapters/netsuite-style/) | NetSuite SuiteTalk REST API `1.0` | 9 | VM | — | — | [4](#netsuite-style) | [3](#netsuite-style) |
-| [oneinch-style](adapters/oneinch-style/) | 1inch Aggregation Protocol API `v6.0` | 5 | boot | — | — | [3](#oneinch-style) | [1](#oneinch-style) |
-| [onfido-style](adapters/onfido-style/) | Onfido API `v3.6` | 7 | boot | — | — | [5](#onfido-style) | [4](#onfido-style) |
-| [opensea-style](adapters/opensea-style/) | OpenSea API `2.0.0` | 7 | boot | — | — | [4](#opensea-style) | [1](#opensea-style) |
+| [oneinch-style](adapters/oneinch-style/) | 1inch Aggregation Protocol API `v6.0` | 5 | VM | — | — | [3](#oneinch-style) | [4](#oneinch-style) |
+| [onfido-style](adapters/onfido-style/) | Onfido API `v3.6` | 7 | VM | — | — | [5](#onfido-style) | [6](#onfido-style) |
+| [opensea-style](adapters/opensea-style/) | OpenSea API `2.0.0` | 7 | VM | — | — | [4](#opensea-style) | [4](#opensea-style) |
| [paypal-style](adapters/paypal-style/) | PayPal Orders API `v2` | 17 | VM | — | — | [6](#paypal-style) | [8](#paypal-style) |
-| [persona-style](adapters/persona-style/) | Persona Inquiry API `2023-01-05` | 5 | boot | — | — | [5](#persona-style) | [4](#persona-style) |
+| [persona-style](adapters/persona-style/) | Persona Inquiry API `2023-01-05` | 5 | VM | — | — | [5](#persona-style) | [6](#persona-style) |
| [photos-style](adapters/photos-style/) | Google Photos Library API `v1` | 12 | VM | — | — | [5](#photos-style) | [5](#photos-style) |
-| [pinata-style](adapters/pinata-style/) | Pinata API `1.0` | 6 | boot | — | — | [4](#pinata-style) | — |
+| [pinata-style](adapters/pinata-style/) | Pinata API `1.0` | 6 | VM | — | — | [4](#pinata-style) | [3](#pinata-style) |
| [plaid-style](adapters/plaid-style/) | Plaid API `2020-09-14` | 13 | SDK | plaid-node @ 32.0.0 (floor) | 4 | [5](#plaid-style) | [3](#plaid-style) |
| [powerplatform-style](adapters/powerplatform-style/) | Microsoft Power Platform API `2` | 9 | VM | — | — | [4](#powerplatform-style) | [6](#powerplatform-style) |
| [printful-style](adapters/printful-style/) | Printful API `v2` | 13 | VM | — | — | [5](#printful-style) | [3](#printful-style) |
@@ -101,25 +101,25 @@ Behavior columns come in two kinds: **verified** (an official SDK was driven aga
| [producthunt-style](adapters/producthunt-style/) | Product Hunt GraphQL API `2` | 0 +GQL | boot | — | — | [3](#producthunt-style) | [2](#producthunt-style) |
| [psd2-style](adapters/psd2-style/) | Open Banking / PSD2 (Berlin Group NextGenPSD2) `1.3.6` | 19 | VM | — | — | [6](#psd2-style) | [7](#psd2-style) |
| [qbo-style](adapters/qbo-style/) | QuickBooks Online API `v3` | 11 | VM | — | — | [6](#qbo-style) | [4](#qbo-style) |
-| [reddit-style](adapters/reddit-style/) | Reddit API `1.0` | 2 | boot | — | — | [5](#reddit-style) | [1](#reddit-style) |
+| [reddit-style](adapters/reddit-style/) | Reddit API `1.0` | 2 | VM | — | — | [5](#reddit-style) | [3](#reddit-style) |
| [resend-style](adapters/resend-style/) | Resend API `1.0.0` | 6 | SDK | resend-node @ 6.22.0 (floor) | 4 | [6](#resend-style) | [3](#resend-style) |
-| [revenuecat-style](adapters/revenuecat-style/) | RevenueCat API `v1` | 7 | boot | — | — | [5](#revenuecat-style) | [4](#revenuecat-style) |
+| [revenuecat-style](adapters/revenuecat-style/) | RevenueCat API `v1` | 7 | VM | — | — | [5](#revenuecat-style) | [5](#revenuecat-style) |
| [salesforce-style](adapters/salesforce-style/) | Salesforce REST API `v60.0` | 29 | SDK | jsforce @ 3.10.22 (floor) | 6 | [7](#salesforce-style) | [4](#salesforce-style) |
-| [sendgrid-style](adapters/sendgrid-style/) | Twilio SendGrid v3 API `v3` | 5 | boot | — | — | [7](#sendgrid-style) | [5](#sendgrid-style) |
+| [sendgrid-style](adapters/sendgrid-style/) | Twilio SendGrid v3 API `v3` | 5 | VM | — | — | [7](#sendgrid-style) | [6](#sendgrid-style) |
| [servicenow-style](adapters/servicenow-style/) | ServiceNow Table API `2` | 44 | VM | — | — | [5](#servicenow-style) | [1](#servicenow-style) |
| [shopify-style](adapters/shopify-style/) | Shopify Admin REST + GraphQL API `2024-10` | 21 +GQL | SDK | go-shopify/v4 @ v4.7.0 | 5 | [7](#shopify-style) | [4](#shopify-style) |
-| [signin-with-apple-style](adapters/signin-with-apple-style/) | Sign in with Apple `v2` | 3 | boot | — | — | [3](#signin-with-apple-style) | [2](#signin-with-apple-style) |
+| [signin-with-apple-style](adapters/signin-with-apple-style/) | Sign in with Apple `v2` | 3 | VM | — | — | [3](#signin-with-apple-style) | [2](#signin-with-apple-style) |
| [slack-style](adapters/slack-style/) | Slack Web API `1.0` | 7 | SDK | slack-node @ 7.19.0 (floor) | 4 | [6](#slack-style) | [2](#slack-style) |
| [smartbill-style](adapters/smartbill-style/) | SmartBill Cloud API `1.0` | 18 | VM | — | — | [3](#smartbill-style) | [4](#smartbill-style) |
| [sqs-style](adapters/sqs-style/) | Amazon SQS API `2012-11-05` | 2 | SDK + VM | aws-sdk-go-v2 @ v1.43.7 | 6 | [4](#sqs-style) | [5](#sqs-style) |
| [square-style](adapters/square-style/) | Square API `2024-08-21` | 18 | SDK | square-node @ 45.1.0 (floor) | 4 | [7](#square-style) | [2](#square-style) |
| [stripe-style](adapters/stripe-style/) | Stripe API `2025-01-27.acacia` | 158 | SDK | stripe-go/v86 @ v86.3.0
stripe-node @ 22.5.0 (floor) | 9 | [7](#stripe-style) | [5](#stripe-style) |
-| [tenderly-style](adapters/tenderly-style/) | Tenderly Simulation API `v1` | 5 | boot | — | — | [3](#tenderly-style) | [2](#tenderly-style) |
-| [thegraph-style](adapters/thegraph-style/) | The Graph (GraphQL over subgraphs) `1.0` | 1 +GQL | boot | — | — | [3](#thegraph-style) | [1](#thegraph-style) |
+| [tenderly-style](adapters/tenderly-style/) | Tenderly Simulation API `v1` | 5 | VM | — | — | [3](#tenderly-style) | [4](#tenderly-style) |
+| [thegraph-style](adapters/thegraph-style/) | The Graph (GraphQL over subgraphs) `1.0` | 1 +GQL | VM | — | — | [3](#thegraph-style) | [2](#thegraph-style) |
| [threads-style](adapters/threads-style/) | Threads API (Meta) `v1.0` | 8 | VM | — | — | [3](#threads-style) | [6](#threads-style) |
| [twilio-style](adapters/twilio-style/) | Twilio API `2010-04-01` | 6 | SDK | twilio-go @ v1.30.9
twilio-node @ 6.1.0 (floor) | 8 | [6](#twilio-style) | [4](#twilio-style) |
| [twitter-style](adapters/twitter-style/) | Twitter/X API `v2` | 9 | VM | — | — | [6](#twitter-style) | [5](#twitter-style) |
-| [walletconnect-style](adapters/walletconnect-style/) | WalletConnect (Relay Protocol v2) `2.0` | 7 | boot | — | — | [3](#walletconnect-style) | [2](#walletconnect-style) |
+| [walletconnect-style](adapters/walletconnect-style/) | WalletConnect (Relay Protocol v2) `2.0` | 7 | VM | — | — | [3](#walletconnect-style) | [5](#walletconnect-style) |
| [whatsapp-style](adapters/whatsapp-style/) | WhatsApp Business Cloud API (Meta) `v21.0` | 8 | VM | — | — | [5](#whatsapp-style) | [7](#whatsapp-style) |
| [workday-style](adapters/workday-style/) | Workday REST API `v40.0` | 8 | VM | — | — | [4](#workday-style) | [3](#workday-style) |
| [x-articles-style](adapters/x-articles-style/) | X (Twitter) Articles API `v2` | 8 | VM | — | — | [4](#x-articles-style) | [4](#x-articles-style) |
@@ -602,6 +602,14 @@ Named by their `// =====` section markers.
- catalog ids resolve only within their model
- task status is scoped to its workspace and model
+**apple-apns-style**
+
+- the provider token gate distinguishes missing invalid and expired tokens
+- a push to the known device returns 200 with a canonical uuid apns-id
+- unknown device tokens are 400 baddevicetoken
+- empty aps payloads are 400 payloadempty
+- sent notifications are retrievable per device
+
**apple-appstoreconnect-style**
- a rejected credential answers Apple's 401 errors array, not a bare status
@@ -707,6 +715,28 @@ Named by their `// =====` section markers.
- signup creates an unverified user the Management API can see
- duplicate, weak, and unknown-client signups are rejected
+**avalara-style**
+
+- every v2 endpoint demands a credential: a bare call is a 401 AuthenticationRequired envelope
+- any Bearer or any HTTP Basic credential opens the gate
+- a non-Basic/Non-Bearer scheme does not count as a credential
+- the effective rate keys off the address state (CA 0.095) with a State/County/City/Special breakdown
+- the summary aggregates the taxable base per jurisdiction
+- per-line tax rounds to cents: two lines aggregate, line 2 keeps its own tax
+- SDK decimal strings ("100.00") price identically to JSON numbers
+- the shipFrom/shipTo form keys off shipTo (NY 0.0875)
+- unknown or missing addresses fall back to the synthetic 0.0825 default
+- create prices the document, mints id/code/companyId and applies AvaTax defaults
+- an omitted date defaults to the clock's today, and advances with it
+- read round-trips by id; unknown ids are 404 NotFound
+- the list supports OData $filter and $orderBy with @recordsetCount
+- $top/$skip pages through an @odata.nextLink that round-trips
+- void flips status to Cancelled and the record reads back cancelled
+- re-void is idempotent
+- the companies catalog lists DEFAULT and STORE1 with default locations
+- nexus $filter literals are typed: id eq 1001 matches ints, hasNexus eq true matches bools
+- the taxcode catalog is filterable by taxCode
+
**aws-cognito-style**
- a reset code older than one hour answers ExpiredCodeException
@@ -833,6 +863,45 @@ Named by their `// =====` section markers.
- lane status echoes the requested pair with its ramp addresses
- the seeded test token dies after its ten-year virtual window
+**cloudkit-style**
+
+- the s2s signature gate rejects unsigned tampered stale and foreign-key requests
+- users current returns the s2s owner identity
+- zones list seeds defaults filters by prefix and pages
+- records lookup returns the seeded shape with inline NOT_FOUND
+- records query filters sorts and pages on a numeric resultsLimit
+- records modify creates updates and deletes round-trip
+
+**dropbox-style**
+
+- upload takes the JSON {path, content} convenience body (documented deviation)
+- the real RPC upload (Dropbox-API-Arg header + raw octet-stream body) lands identically
+- re-uploading an existing path answers the real mode:"add" conflict
+- mode overwrite replaces in place; autorename forks a suffixed path
+- list_folder returns the whole path-prefix subtree, not one level
+- the root listing spans everything; unknown and file paths carry distinct 409 tags
+- paging slices the filtered subtree by body cursor, ignoring query strings
+- download streams raw bytes by path and by id, with metadata alongside
+- folders and unknown paths decline under the 409 path envelope
+- get_temporary_link pairs the file's metadata with the synthetic link
+- a presented bearer must be registered: unknown and expired tokens get distinct 401 tags
+- an absent Authorization header stays open (documented deviation)
+- create_folder mints folder metadata and conflicts case-insensitively
+- get_current_account returns the synthetic /2/users snapshot
+- deleting a folder removes its entire subtree from every read path
+- trash tombstones audit the exact cascade batch
+- delete is permanent: a re-created path is a brand-new entry
+
+**dune-style**
+
+- the api-key gate rejects missing, empty and non-bearer authorization
+- an execution walks PENDING -> EXECUTING -> COMPLETED as the clock advances
+- a missing required parameter is the 400 envelope and both SDK parameter shapes resolve
+- simulate_fail terminates QUERY_STATE_FAILED and results carry the failure envelope
+- the inline-result route completes synchronously
+- results pages honor limit/offset with a followable next_uri
+- the CSV variant streams text/csv for the same page
+
**dynamodb-style**
- an unsigned request is 403; a real SigV4 signature passes
@@ -869,6 +938,16 @@ Named by their `// =====` section markers.
- get by contact id round-trips the email
- delete removes the contact; reads 404 after
+**erc4337-style**
+
+- supportedEntryPoints, chainId, and the JSON-RPC envelope
+- estimateUserOperationGas validates the full v0.7 field set
+- sendUserOperation answers a deterministic hash and defaults the entry point
+- the op walks mempool -> bundled -> included on the virtual clock
+- simulate_fail reverts on inclusion with the AA95 reason
+- the paymaster signs the op into paymasterAndData
+- missing or invalid userOps are 400s
+
**escrow-style**
- missing, Bearer, and malformed Authorization are 401s with the challenge header
@@ -903,6 +982,15 @@ Named by their `// =====` section markers.
- the list carries every registered webhook
- a missing or blank url is the can't-be-blank field error
+**etherscan-style**
+
+- the apikey query parameter gates every module call
+- unknown modules and actions answer the NOTOK envelope over HTTP 200
+- balance reads the seeded ledger; unknown addresses default to "0"
+- txlist scopes by address then applies block filters, sort and paging
+- contract verification: ABI, source, and the unverified fallback
+- stats and token holders keep every number a decimal string
+
**fattureincloud-style**
- auth failures answer the flat OAuth envelope; any non-empty bearer passes
@@ -1020,6 +1108,72 @@ Named by their `// =====` section markers.
- refresh mints a fresh 60-day token; the old one keeps working
- the refreshed token outlives the original's expiry
+**jumio-style**
+
+- a missing, bare or wrong-scheme token is a 401 in the Jumio error envelope
+- a scan create answers PENDING with a synthetic decimal scan reference
+- sequential creates advance the reference sequence
+- a create without merchantScanReference is a 400
+- PENDING holds through the processing window then flips to DONE
+- a FAILED scan carries a real reject reason and its description
+- unknown scans are 404 on every parameterized route
+- extracted data is None while the scan is PENDING
+- DONE exposes the synthetic document extraction
+- FAILED scans answer data with a 409 repeating the reason
+- delete removes the scan and later reads are 404s
+- deleting after the terminal window still advances the lifecycle
+- a correctly MACed webhook body is accepted
+- a tampered body, wrong MAC or missing header is a 401
+- the terminal transition emits exactly one signed scan.completed
+- failed scans emit scan.failed carrying the rejection reason
+- a delete-driven terminal transition also emits, then nothing more
+
+**linkedin-style**
+
+- authorize without redirect_uri, state or client_id is invalid_request
+- authorize redirects back with a fresh code and the state echoed
+- a redirect_uri that already carries a query is joined with &
+- the exchange demands grant_type=authorization_code
+- an unknown code is 400 invalid_grant
+- a good exchange mints a 60-day token pair for a fresh member
+- the code is single-use: a replay is invalid_grant
+- client mismatches are 400 invalid_client
+- a mismatched attempt must not burn the code
+- a second flow mints a distinct member
+- the refresh grant demands client creds
+- an unknown refresh token is invalid_grant
+- refresh rotates the pair and keeps the member
+- the presented refresh token is single-use
+- rotation chains: the new refresh token refreshes again
+- a missing bearer is 401 in the service error envelope
+- wrong schemes and unknown bearers answer the same 401
+- every API route enforces the same gate
+- a bearer dies at its clock-derived 60-day expiry
+- userinfo returns the OAuth member profile
+- publishing as anyone but the token's member is a 403
+- a good publish mints a ugcPost urn echoed in x-linkedin-id
+- the post resolves to a share urn carrying its own author
+- resolving an unknown urn is a 404
+- unconfigured, publishing is unthrottled
+- arming fail_after injects 429 REQUEST_LIMIT_EXCEEDED
+- the limit is per member
+- a throttled attempt creates no post
+- q must be author
+- reply resolves urn:li:person:me to the authenticated member
+- commenting as anyone but the caller is a 403
+- replying to an unknown object is a 404
+- ingest lists only the token member's comments
+- member B's comment resolved me and lists only under B
+- createdOn is clock-stamped and monotonic
+- count pages with a next link that round-trips the query
+- without count the whole list returns unpaged
+- a malformed start cursor is a 400
+- an unknown entity is a 404
+- each queryType totals base+3/5/7/11 split across two daily buckets
+- entity accepts both the parenthesized and bare urn forms
+- start past the data returns an empty page
+- an unknown queryType falls back to the base total (deviation, as-is)
+
**marketo-style**
- only client_credentials mints a token
@@ -1070,6 +1224,45 @@ Named by their `// =====` section markers.
- request-body fields override the mapped defaults
- impossible chains, unknown sources and dangling overrides use NetSuite's real codes
+**oneinch-style**
+
+- a quote returns token pairs, a decimal toAmount and a 100-point split
+- quotes are deterministic and address matching is case-insensitive
+- the toAmount scales linearly with the input amount
+- a same-token quote scales the amount by the pseudo-rate (as-is)
+- missing params and unknown tokens are 400 error envelopes
+- a swap returns router-addressed calldata with gas and gasPrice
+- the swap toAmount matches the quote for the same input
+- slippage is optional and ignored (as-is)
+- missing params and unknown tokens are 400s
+- the spender is the router contract address
+- approve calldata targets the token with the max allowance
+- a missing or unknown token is a 400
+- the token list is an address-keyed map of six tokens
+- every token in the list is quotable as a source
+
+**onfido-style**
+
+- a missing or non-Token Authorization header is 401 authorization_error
+- applicant create flags exactly the blank names and reads back by id
+- document and live photo uploads bind to a real applicant and default side
+- check create demands report_names and a known applicant
+- the check completes from the clock and emits check.completed exactly once
+- simulate_fail completes with consider and consider breakdowns
+- the webhook receiver MACs the exact raw bytes
+
+**opensea-style**
+
+- the X-API-KEY gate 401s every surface with the V1ErrorWrapper envelope
+- the asset list seeds five mock-punks NFTs and filters by collection_slug
+- single-asset reads match the address case-insensitively and 404 unknown shapes
+- collections read back contracts and string-typed stats; unknown slugs 404
+- limit/next cursor pagination walks the pages and 400s a malformed cursor
+- events filter by collection_slug and event_type
+- listings carry the Seaport ask shape: the NFT in offer, payment in consideration
+- offers invert the shape: payment in offer, the NFT in consideration
+- created offers are stateful, deterministic, and defaulted
+
**paypal-style**
- client_credentials over Basic auth mints a distinct Bearer with PayPal's token envelope
@@ -1110,6 +1303,27 @@ Named by their `// =====` section markers.
- signature verification answers SUCCESS only for known webhook ids
- deletion is 204, and repeats are 404
+**persona-style**
+
+- a missing, bare or wrong-scheme token is a 401 in the JSON:API error envelope
+- a create mints a zero-padded inq_ id in the JSON:API envelope
+- sequential creates advance the id sequence
+- any Bearer is accepted: the gate checks presence, not a store (as-is)
+- a create missing template_id or reference_id is a 400 invalid_request
+- the status derives from the clock created to pending to completed
+- resume restarts the clock at pending without duplicating verifications
+- simulate_fail declines at the terminal transition and seeds nothing
+- unknown inquiries are JSON:API 404s on every parameterized route
+- verifications are empty until the terminal transition fires
+- completion seeds the government-id and selfie verifications
+- a fresh correctly-signed webhook is accepted
+- a tampered body or wrong MAC is a 401 invalid_signature
+- a stale or far-future t is a 401 invalid_timestamp
+- a missing header or unparseable signature is a 401
+- polling through pending still emits exactly one inquiry.completed
+- re-reads and post-resume re-completions do not re-emit
+- a declined inquiry emits inquiry.declined signed the same way
+
**photos-style**
- authorize without redirect_uri, state or client_id is 400 invalid_request
@@ -1163,6 +1377,19 @@ Named by their `// =====` section markers.
- albums are private to their user
- delete removes the album but leaves its media
+**pinata-style**
+
+- missing or half-present credentials are 401 with the error envelope
+- the API key pair and a Bearer JWT both open testAuthentication
+- pinJSONToIPFS pins content to a real CIDv0
+- re-pinning identical content is isDuplicate, not a new pin
+- pinFileToIPFS sizes and names the pin from the multipart parts
+- pinList filters by hash, size, status, and metadata name
+- pinStart/pinEnd bound the date-pinned window
+- pinByHash requires hash and matches the CID exactly
+- pinList pages at the real default of 10 rows
+- unpin removes the CID; a second unpin is 403 FORBIDDEN
+
**powerplatform-style**
- every route sits behind the entra bearer gate: a missing, non-bearer, or empty token answers 401 in the microsoft error envelope
@@ -1276,6 +1503,38 @@ Named by their `// =====` section markers.
- an unknown entity answers an empty QueryResponse; a statement without one is a 400 fault
- STARTPOSITION pages with MAXRESULTS: row 2 of the sorted set
+**reddit-style**
+
+- a missing or generic User-Agent is 429 on both routes
+- the token endpoint requires HTTP Basic client credentials
+- a permanent authorization_code mints access and refresh together
+- a refresh grant returns a fresh access token and no new refresh
+- submit requires a bearer the adapter itself minted
+- a valid submit returns the t3_ thing envelope
+- missing sr or title stay HTTP 200 with Reddit error triples
+- an access token dies after its one-hour window
+
+**revenuecat-style**
+
+- a missing or unknown key is a 401 {code, message} envelope
+- the public pk_ SDK key passes subscriber reads and receipt posts but is 401 on restricted writes
+- GET subscriber is get-or-create and answers in the v1 CustomerInfo envelope
+- receipt validation mirrors the real 400 order: app_user_id, platform, fetch_token, bad token
+- an ios receipt grants the pro entitlement with real trial math, and renewals stack
+- a google-play dict receipt feeds the product and lands in non_subscriptions
+- revoke lapses a live subscription; delete and the 404 envelopes
+- expiry is derived on read: a lapsed trial drops its entitlement
+
+**sendgrid-style**
+
+- the bearer gate rejects missing and unknown keys with SendGrid's grant envelope
+- mail send answers 202 with an empty body, an X-Message-Id, and flattened personalizations
+- the retrieval endpoint pages with limit and the opaque offset cursor
+- the delivery lifecycle derives processed -> delivered (or dropped) on read, exactly once
+- the Email Activity query language narrows the list
+- event webhook settings round-trip and require a URL when enabled
+- deliveries are ECDSA P-256 signed over timestamp + raw body and fire once per recipient stage
+
**servicenow-style**
- unknown credentials are 401 at the gate
@@ -1288,6 +1547,14 @@ Named by their `// =====` section markers.
- unknown sys_id and unknown table are 404
- delete removes the record for good
+**signin-with-apple-style**
+
+- authorize redirects with a single-use code plus state and validates its params
+- the token exchange mints a real es256 id_token with apple claim shapes
+- the served jwks verifies the minted id_token signature
+- auth codes are single-use and client_secrets are verified cryptographically
+- the refresh grant rotates access tokens and rejects stale or foreign inputs
+
**smartbill-style**
- requests without credentials are turned away with the errorText envelope
@@ -1339,6 +1606,24 @@ Named by their `// =====` section markers.
- DeleteQueue tears the queue down for its messages too
- the throttled profile alternates empty receives deterministically
+**tenderly-style**
+
+- the access-key gate rejects missing and unknown bearers with the slug envelope
+- networks answer the bare array and switch to the paged envelope under perPage
+- a plain simulation round-trips the deterministic Tenderly shape
+- a value transfer emits the ERC-20 Transfer log and balance overrides
+- reverting simulations carry the ABI-encoded Error(string) output
+- bundles fan out per simulation and stored results list and retrieve by id
+
+**thegraph-style**
+
+- pools collection arguments sort by volume and join token0/token1
+- where filters map the graph-node suffix operators
+- validation failures and the first cap surface as GraphQL errors
+- domains join owner/resolvedAddress; lookups miss as null
+- _meta reports the deployment head; Token.pools joins in reverse
+- the REST SDL surface is public and rejects unknown bearer keys
+
**threads-style**
- authorize without redirect_uri, state or client_id is invalid_request
@@ -1396,6 +1681,20 @@ Named by their `// =====` section markers.
- max_results pages via meta.next_token; an invalid pagination_token is a 400
- the tweet list shares the same v2 paging and stays unpaged without max_results
+**walletconnect-style**
+
+- every route answers without a projectId (the gate is not wired)
+- a wc: URI pairing round-trips its topic, relay protocol, and symKey
+- an auto pairing mints a fresh topic and a 64-hex symKey
+- proposing requires pairingTopic — and accepts one never paired
+- approve acknowledges the session and derives eip155 namespaces
+- the session list is a bare array capped by limit
+- the approval gate is missing: an unacknowledged session still answers
+- wallet requests answer in a JSON-RPC 2.0 envelope with monotonic ids
+- signing and transaction methods return synthetic 0x-hex hashes
+- extend echoes the fixed session TTL without persisting anything
+- disconnect retires the topic and every later call 404s
+
**whatsapp-style**
- a missing bearer is 401 in the Meta error envelope
@@ -1700,10 +1999,11 @@ behavior notes live in each adapter's README.
- No 429 TooManyRequests or 503 ServiceUnavailable rate-limit responses
- No Web Push endpoints (Safari webpush) or VOIP push handling
-**Deviations** (2)
+**Deviations** (3)
- Provider JWT verified against one fixed P-256 key whose private half is published
- GET /3/device/{token}/notifications is a simulator-only endpoint (no real fetch API)
+- sent_at on the notifications endpoint is a constant; 410 Unregistered is modeled but unreachable
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -2174,9 +2474,11 @@ behavior notes live in each adapter's README.
- No customers, certificates, or exemption endpoints
- No jurisdiction lookups — definitions serve nexuses and taxcodes only
-**Deviations** (1)
+**Deviations** (3)
- Tax is a deterministic split — State 50%, County 25%, City 20%, Special 5% of the rate
+- Void returns a minimal {id, status} envelope; real AvaTax returns the full TransactionModel
+- Re-void is idempotent 200; real AvaTax rejects voiding a Cancelled document
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -3490,11 +3792,12 @@ behavior notes live in each adapter's README.
- No CSV transfer to external storage (transfers endpoints)
- No table metadata endpoints (GET /api/v1/table/{namespace}/{table})
-**Deviations** (3)
+**Deviations** (4)
- executions follow a fixed clock: EXECUTING at +1s, COMPLETED at +3s after execute
- queries come from a static 3-entry catalog; no real SQL is executed
- simulate_fail body flag forces QUERY_STATE_FAILED; real API has no failure trigger
+- Auth is a presence-only Bearer; real Dune uses the x-dune-api-key header
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -3756,12 +4059,14 @@ behavior notes live in each adapter's README.
- No debug_bundler_* ops: sendBundleNow, clearState, dropUserOperation
- No WebSocket transport or newHeads subscriptions
-**Deviations** (4)
+**Deviations** (6)
- gas estimates are deterministic fixed values; real bundlers differ per implementation
- inclusion runs on a fixed clock: mempool 0-1s, bundled 1-3s, included at >=3s
- eth_sendUserOperation accepts {simulate_fail:true} as a third params element
- mock paymaster POST /paymaster/sign mints synthetic sponsorship signatures
+- userOp keeps the v0.6 paymasterAndData field though the adapter is EntryPoint-v0.7-only
+- Error envelopes use generic -32602 rather than AA-prefixed codes (-32500...)
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -3864,13 +4169,14 @@ behavior notes live in each adapter's README.
**Missing** (3)
-- No txlistinternal, tokentx, or getLogs actions
+- No getLogs action (txlistinternal/tokentx are served, hard-coded empty)
- No logs, proxy, or gastracker modules (gasoracle, eth_call passthrough)
- No getminedblocks, getblocknobytime, or nodecount actions
-**Deviations** (1)
+**Deviations** (2)
- Auth accepts any non-empty apikey; only a missing key yields the error envelope
+- txlistinternal/tokentx/tokenbalance return hard-coded empty results
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -5543,12 +5849,14 @@ behavior notes live in each adapter's README.
- No hosted redirect verification flow; API scan creation only
- No Document Verification, Data Verification, or Transaction Screening APIs
-**Deviations** (4)
+**Deviations** (6)
- Scan lifecycle on a fixed clock: PENDING ~3s then DONE or FAILED at +3s
- simulate_fail and simulate_reject_reason create fields are stunt-only hooks
- Webhook HMAC secret is the public constant stunt_jumio_mock_signing_key
- POST /netverify/v2/webhooks is a local stand-in receiver, not a Jumio endpoint
+- Bearer-presence gate; real Jumio uses HTTP Basic against a server-token store
+- Scan references are decimal groups, not UUIDs; extracted PII is fixed synthetic
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -5586,9 +5894,11 @@ behavior notes live in each adapter's README.
- No reactions or likes endpoints (socialActions likes)
- No video upload flow or multi-image carousel posts
-**Deviations** (1)
+**Deviations** (3)
- Rate-limit injection on POST /v2/ugcPosts publish is a simulator test hook
+- An unknown analytics queryType silently falls back to the base total; real LinkedIn 400s
+- Refresh tokens never expire; only access tokens carry the 60-day expiry
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -6043,9 +6353,12 @@ behavior notes live in each adapter's README.
- No Limit Order Protocol endpoints (create, list, history)
- No raw transaction broadcast, status check, or chains list endpoints
-**Deviations** (1)
+**Deviations** (4)
- Quotes deterministic from src/dst/amount; same input yields same toAmount and split
+- Quote field is toAmount; real v6.0 returns dstAmount
+- Same-token quotes return amount x pseudo-rate; real API rejects
+- slippage is optional and ignored on swap; real v6.0 requires it
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -6142,12 +6455,14 @@ behavior notes live in each adapter's README.
- No motion capture or video uploads; no document/live photo list or delete
- No webhook registration or listing (local receiver only)
-**Deviations** (4)
+**Deviations** (6)
- Check lifecycle fixed: in_progress ~3s then complete; awaiting_applicant skipped
- simulate_fail yields result consider; real sandbox uses special sandbox documents
- Webhook HMAC secret is the public constant stunt_onfido_mock_signing_key
- POST /v3.6/webhooks is a local stand-in receiver, not an Onfido endpoint
+- Synthetic sequential ids (app-000001...) where real Onfido uses UUIDs
+- The awaiting_applicant phase is skipped; documents assumed on file
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -6245,9 +6560,12 @@ behavior notes live in each adapter's README.
- No account lookup (GET /api/v2/accounts/{address})
- No NFT transfer history endpoint (chain/{chain}/transfers)
-**Deviations** (1)
+**Deviations** (4)
- X-API-KEY accepted as any non-empty value; no real key validation
+- Asset routes serve the deprecated v2 surface; real v2 replaced them with collection-scoped nfts endpoints
+- Identical offers re-create and store duplicate orders (no order_hash dedupe)
+- Create-offer body and response are simulator-specific, not the documented criteria-offer shapes
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -6427,12 +6745,14 @@ behavior notes live in each adapter's README.
- No Transactions API (bank account linking and verification)
- No inquiry cancel, expire, redact, or mark-under-review actions
-**Deviations** (4)
+**Deviations** (6)
- Fixed clock lifecycle: created (0-1s), pending (1-3s), completed or declined (+3s)
- simulate_fail create flag yields declined instead of completed (stunt-only)
- Webhook HMAC secret is the public constant stunt_persona_mock_signing_key
- POST /api/inquiry/v1/webhooks is a local stand-in receiver with 5-minute replay window
+- JSON:API attributes are snake_case; real Persona serializes kebab-case
+- Create takes a flat body; real API expects the JSON:API data/attributes wrapper
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -6524,10 +6844,16 @@ behavior notes live in each adapter's README.
- No metadata update or pin-policy change (POST /pinning/hashMetadata)
- No pinned-data-usage endpoint (GET /data/userPinnedDataTotal)
+**Deviations** (3)
+
+- Credentials are presence-checked only; any non-empty key pair or Bearer JWT passes
+- Every stored pin is status pinned — unpinned/pending/failed not modeled (no job queue)
+- JSON pin CIDs derive from stunt canonical serialization, not byte-identical to real Pinata
+
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
-- `POST` `/pinning/pinFileToIPFS` — body, stateful
-- `POST` `/pinning/pinJSONToIPFS` — body, stateful
+- `POST` `/pinning/pinFileToIPFS` — stateful, clock
+- `POST` `/pinning/pinJSONToIPFS` — body, stateful, clock
- `DELETE` `/pinning/unpin/{cid}` — params, stateful
- `GET` `/data/pinList` — query, stateful, filter
- `GET` `/data/testAuthentication` — —
@@ -7075,9 +7401,11 @@ behavior notes live in each adapter's README.
- No token revocation (POST /api/v1/revoke_token)
- No subreddit, search, or inbox/messages endpoints
-**Deviations** (1)
+**Deviations** (3)
- User-Agent gate simplified: any UA containing / and ( passes, else 429
+- The authorization_code grant never validates code or redirect_uri — any or missing code mints tokens
+- Post ids are plain sequence strings, not Reddit base36
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -7198,12 +7526,13 @@ behavior notes live in each adapter's README.
- Only ios and android platforms — no amazon, stripe, or web billing
- No v2 REST surface (customers, subscriptions, entitlements)
-**Deviations** (4)
+**Deviations** (5)
-- Webhook registration endpoint is simulator-only; real RC v1 webhooks are dashboard-configured
- fetch_token prefixed with invalid is the deterministic bad-receipt 400 path
- Subscription expiry is derive-on-read; first read past it fires EXPIRATION
- POST /v1/subscribers accepts _expires_at seeding to drive EXPIRATION in tests
+- pk_ public keys are simulator convention; real RC public keys are appl_/goog_-prefixed
+- REST webhook registration does not exist in real RC v1 (dashboard-configured)
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -7363,19 +7692,20 @@ behavior notes live in each adapter's README.
- No sender authentication or domains setup
- No templates surface (/v3/templates)
-**Deviations** (5)
+**Deviations** (6)
- Webhook ECDSA signature is raw r||s (64 bytes), not Twilio ASN.1 DER encoding
- simulate_fail: true in send body forces dropped terminal (simulator extension)
- delivered derives at fixed +3s on first list read, not real async delivery
-- Webhook deliveries are single stunt-enveloped objects; real SendGrid POSTs a JSON array
- Email Activity query subset: =, !=, CONTAINS terms AND-ed over six fields
+- Each delivery wraps one event in the transport envelope; real SendGrid batches a JSON array of events
+- asm, sandbox_mode, and batch_id are accepted but not modeled
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
- `POST` `/v3/mail/send` — body, auth, stateful, webhooks, clock
- `GET` `/v3/messages` — query, auth, stateful, paginate, filter, webhooks, errors, clock
-- `POST` `/v3/user/webhooks/event/settings` — body, auth, stateful, clock
+- `POST` `/v3/user/webhooks/event/settings` — body, auth, stateful, errors, clock
- `GET` `/v3/user/webhooks/event/settings` — auth, stateful, clock
- `POST` `/v3/user/webhooks/event/test` — auth, stateful, webhooks, errors, clock
@@ -8365,10 +8695,12 @@ behavior notes live in each adapter's README.
- No alerts or notification-rules API
- No transaction lookup/list endpoints (transactions by hash)
-**Deviations** (2)
+**Deviations** (4)
- gas_used is derived from input length, not real EVM execution; status defaults to true
- An explicit revert:true body flag forces the revert path; real API has no such switch
+- Bearer auth where real Tenderly uses the X-Access-Key header
+- Responses mix camelCase where Tenderly is snake_case and nests simulation.id
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -8398,9 +8730,10 @@ behavior notes live in each adapter's README.
- No indexing-status queries beyond _meta (no /status endpoint)
- No GraphQL subscriptions over websockets; HTTP queries only
-**Deviations** (1)
+**Deviations** (2)
- One merged schema serves Uniswap and ENS entities; real Graph serves one per deployment
+- _meta.block.number advances per query; the real head is block-height driven
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
@@ -8692,10 +9025,13 @@ behavior notes live in each adapter's README.
- No session update, ping, or event emission (chainChanged/accountsChanged)
- No pairing list, expire, or ping operations
-**Deviations** (2)
+**Deviations** (5)
- Pairing, session approve, and JSON-RPC requests are auto-approved (no wallet device)
- personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed
+- The projectId gate ships but is never wired — every route answers without a credential
+- Expiry fields are TTL constants, not absolute unix timestamps; extend persists nothing
+- Unacknowledged sessions serve JSON-RPC requests immediately (no approval gate)
Derived behavior tags (static — from scripts/*.star, not SDK-verified)
diff --git a/adapters/apple-apns-style/scripts/lib.star b/adapters/apple-apns-style/scripts/lib.star
index c704b174..bdfaac92 100644
--- a/adapters/apple-apns-style/scripts/lib.star
+++ b/adapters/apple-apns-style/scripts/lib.star
@@ -169,13 +169,15 @@ def _provider_token_expired(claims):
return True
# _require_jwt returns the token if valid, or an error response if not.
-# Distinct reasons per real APNs: a present-but-expired provider token is
-# 403 ExpiredProviderToken; anything else unusable is 403 (bad/missing
-# provider auth).
+# Distinct reasons per real APNs: no bearer header at all is 403
+# MissingProviderToken; a present-but-expired provider token is 403
+# ExpiredProviderToken; anything else unusable is 403 InvalidProviderToken.
def _require_jwt(req):
auth = req["headers"].get("Authorization", "")
if auth == "":
auth = req["headers"].get("authorization", "")
+ if auth == "":
+ return None, respond(403, {"reason": "MissingProviderToken"})
token, expired = _verify_provider_token(auth)
if token == None:
if expired:
@@ -223,12 +225,16 @@ def _mint_jwt(header_json, payload_json):
# --- APNs helpers ---
-# _generate_apns_id creates a synthetic APNs ID (UUID-like).
+# _generate_apns_id creates a synthetic APNs ID in canonical UUID form
+# (8-4-4-4-12 hex; real APNs returns a canonical UUID when the request omits
+# apns-id). Derived from the sequence, so ids stay unique per notification.
def _generate_apns_id():
seq = store_kv_incr("apns", "apns_id_seq")
- # Format as a UUID-like string.
- s = str(0x10000000 + seq)
- return s + "-0000-0000-0000-0000000000" + str(seq)[-3:]
+ h = "%x" % seq
+ # % has no zero-padding verb here, so pad by hand to 32 hex digits.
+ while len(h) < 32:
+ h = "0" + h
+ return h[:8] + "-" + h[8:12] + "-4" + h[13:16] + "-8" + h[17:20] + "-" + h[20:32]
# _seed populates default device tokens on first access.
def _seed():
diff --git a/adapters/apple_apns_style_test.go b/adapters/apple_apns_style_test.go
new file mode 100644
index 00000000..1ba15559
--- /dev/null
+++ b/adapters/apple_apns_style_test.go
@@ -0,0 +1,289 @@
+package adapters
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the apple-apns-style adapter scripts directly (lib.star preloaded)
+// over a shared store and virtual clock: the provider-token gate (real ES256
+// verification with distinct reasons for missing/invalid/expired tokens),
+// the POST /3/device/{token} push shape (200 + canonical-UUID apns-id,
+// BadDeviceToken, Unregistered-shaped errors, PayloadEmpty) and the internal
+// per-device notifications retrieval.
+const (
+ apnsVMHost = "api.push.apple.test"
+ apnsVMKnownToken = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
+ apnsVMNewToken = "0000000000000000000000000000000000000000000000000000000000000000"
+)
+
+// apnsVMPrivPEM mirrors the adapter's documented synthetic provider keypair
+// (README): the public half is baked into lib.star, the private half signs
+// provider tokens here exactly the way a real APNs provider key would.
+const apnsVMPrivPEM = `-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgz399eDP4CEo1JoR7
+A5uHueHShJKhKvna8BiAvVQPkvyhRANCAAS6OMBYKYI6moCMo0FeQ23CAvQMT5sy
+MZrf7jMKmvhmI/aMJuodNWq4eLSq6/X4oWriaY7RsKxIrQ5F/Ql+y6XJ
+-----END PRIVATE KEY-----`
+
+// apnsVMUUID pins the canonical 8-4-4-4-12 hex apns-id real APNs returns.
+var apnsVMUUID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
+
+type apnsVMFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vm *starlark.VM
+ host string
+}
+
+func newApnsVMFixture(t *testing.T, start time.Time) *apnsVMFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "apple-apns-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ src, err := os.ReadFile(filepath.Join(root, "scripts", "send.star"))
+ if err != nil {
+ t.Fatalf("read send.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib send.star: %v", err)
+ }
+ return &apnsVMFixture{t: t, vc: vc, vm: vm, host: apnsVMHost}
+}
+
+// call drives a handler on the device route; auth "" sends no Authorization
+// header at all (the missing-token case).
+func (f *apnsVMFixture) call(handler, method, path, token string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vm.Call(handler, starlark.Request{
+ Method: method, Path: path, Host: f.host, Headers: headers, Body: body,
+ Params: map[string]string{"deviceToken": token},
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// send is the common push: an alert+badge aps payload to the given token.
+func (f *apnsVMFixture) send(token, auth string) starlark.Response {
+ f.t.Helper()
+ return f.call("on_send", "POST", "/3/device/"+token, token, map[string]any{
+ "aps": map[string]any{
+ "alert": map[string]any{"title": "Test Push", "body": "Hello from stunt!"},
+ "badge": 1,
+ },
+ }, auth)
+}
+
+// apnsVMSign signs a compact-JSON header/payload pair as a real ES256 JWT
+// (raw r||s signature) with the given key.
+func apnsVMSign(t *testing.T, priv *ecdsa.PrivateKey, header, payload string) string {
+ t.Helper()
+ h := base64.RawURLEncoding.EncodeToString([]byte(header))
+ p := base64.RawURLEncoding.EncodeToString([]byte(payload))
+ digest := sha256.Sum256([]byte(h + "." + p))
+ r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:])
+ if err != nil {
+ t.Fatalf("sign: %v", err)
+ }
+ sig := make([]byte, 64)
+ r.FillBytes(sig[:32])
+ s.FillBytes(sig[32:])
+ return h + "." + p + "." + base64.RawURLEncoding.EncodeToString(sig)
+}
+
+// apnsVMKey parses the documented provider private key PEM.
+func apnsVMKey(t *testing.T) *ecdsa.PrivateKey {
+ t.Helper()
+ block, _ := pem.Decode([]byte(apnsVMPrivPEM))
+ if block == nil {
+ t.Fatal("bad test key PEM")
+ }
+ key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ t.Fatalf("parse test key: %v", err)
+ }
+ priv, ok := key.(*ecdsa.PrivateKey)
+ if !ok {
+ t.Fatal("test key is not ECDSA")
+ }
+ return priv
+}
+
+// apnsVMMint mints a provider token: header {alg,kid}, payload
+// {iss,iat[,exp]} — signed with the documented key.
+func apnsVMMint(t *testing.T, priv *ecdsa.PrivateKey, iat, exp int64) string {
+ t.Helper()
+ header := `{"alg":"ES256","kid":"mock-apns-key-1"}`
+ payload := `{"iss":"MOCKTEAMID","iat":` + strconv.FormatInt(iat, 10)
+ if exp > 0 {
+ payload += `,"exp":` + strconv.FormatInt(exp, 10)
+ }
+ return apnsVMSign(t, priv, header, payload+"}")
+}
+
+// wantReason asserts an APNs error envelope {reason: ...}.
+func wantReason(t *testing.T, r starlark.Response, status int, reason, label string) {
+ t.Helper()
+ if r.Status != status {
+ t.Fatalf("%s -> %d, want %d; body %v", label, r.Status, status, r.Body)
+ }
+ if r.Body["reason"] != reason {
+ t.Fatalf("%s reason = %v, want %s", label, r.Body["reason"], reason)
+ }
+}
+
+func TestAppleApnsVMProviderTokenGate(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newApnsVMFixture(t, base)
+ priv := apnsVMKey(t)
+ fresh := "Bearer " + apnsVMMint(t, priv, base.Unix(), 0)
+
+ // ===== the provider token gate distinguishes missing invalid and expired tokens =====
+ // No authorization header at all: real APNs reports MissingProviderToken.
+ wantReason(t, f.send(apnsVMKnownToken, ""), 403, "MissingProviderToken", "no auth header")
+ // Garbage bearer value.
+ wantReason(t, f.send(apnsVMKnownToken, "Bearer not-a-jwt"), 403, "InvalidProviderToken", "garbage token")
+ // A JWT whose JOSE header claims a symmetric alg.
+ hs256 := "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJNT0NLVEVBTUlEIn0.c2ln"
+ wantReason(t, f.send(apnsVMKnownToken, "Bearer "+hs256), 403, "InvalidProviderToken", "HS256 alg")
+ // A well-formed token signed by an unregistered key.
+ forged, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ wantReason(t, f.send(apnsVMKnownToken, "Bearer "+apnsVMMint(t, forged, base.Unix(), 0)),
+ 403, "InvalidProviderToken", "forged signature")
+ // iat 2h old (APNs caps provider tokens at 1h when exp is absent).
+ wantReason(t, f.send(apnsVMKnownToken, "Bearer "+apnsVMMint(t, priv, base.Unix()-7200, 0)),
+ 403, "ExpiredProviderToken", "stale iat")
+ // An explicit exp in the past expires the token.
+ wantReason(t, f.send(apnsVMKnownToken, "Bearer "+apnsVMMint(t, priv, base.Unix()-7200, base.Unix()-3600)),
+ 403, "ExpiredProviderToken", "explicit past exp")
+ // A fresh iat (exp absent, within the 1h cap) goes through.
+ if r := f.send(apnsVMKnownToken, fresh); r.Status != 200 {
+ t.Fatalf("fresh provider token -> %d, want 200; body %v", r.Status, r.Body)
+ }
+}
+
+func TestAppleApnsVMDevicePush(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newApnsVMFixture(t, base)
+ auth := "Bearer " + apnsVMMint(t, apnsVMKey(t), base.Unix(), 0)
+
+ // ===== a push to the known device returns 200 with a canonical uuid apns-id =====
+ first := f.send(apnsVMKnownToken, auth)
+ if first.Status != 200 {
+ t.Fatalf("push known device -> %d: %v", first.Status, first.Body)
+ }
+ apnsID := first.Headers["apns-id"]
+ if !apnsVMUUID.MatchString(apnsID) {
+ t.Fatalf("apns-id = %q, want canonical 8-4-4-4-12 UUID", apnsID)
+ }
+ if len(first.Body) != 0 {
+ t.Fatalf("push body = %v, want empty body on 200", first.Body)
+ }
+ // A second push gets a different id.
+ second := f.send(apnsVMKnownToken, auth)
+ if second.Status != 200 {
+ t.Fatalf("second push -> %d: %v", second.Status, second.Body)
+ }
+ apnsID2 := second.Headers["apns-id"]
+ if apnsID2 == "" || apnsID2 == apnsID {
+ t.Fatalf("second apns-id = %q, want distinct from %q", apnsID2, apnsID)
+ }
+
+ // ===== unknown device tokens are 400 baddevicetoken =====
+ wantReason(t, f.send(apnsVMNewToken, auth), 400, "BadDeviceToken", "unknown device")
+
+ // ===== empty aps payloads are 400 payloadempty =====
+ // A nil body, a body without aps, and an aps with no alert/badge/sound.
+ if r := f.call("on_send", "POST", "/3/device/"+apnsVMKnownToken, apnsVMKnownToken, nil, auth); r.Status != 400 || r.Body["reason"] != "PayloadEmpty" {
+ t.Fatalf("nil body -> %d %v, want 400 PayloadEmpty", r.Status, r.Body)
+ }
+ if r := f.call("on_send", "POST", "/3/device/"+apnsVMKnownToken, apnsVMKnownToken,
+ map[string]any{"custom": map[string]any{"x": 1}}, auth); r.Status != 400 || r.Body["reason"] != "PayloadEmpty" {
+ t.Fatalf("no aps -> %d %v, want 400 PayloadEmpty", r.Status, r.Body)
+ }
+ if r := f.call("on_send", "POST", "/3/device/"+apnsVMKnownToken, apnsVMKnownToken,
+ map[string]any{"aps": map[string]any{}}, auth); r.Status != 400 || r.Body["reason"] != "PayloadEmpty" {
+ t.Fatalf("empty aps -> %d %v, want 400 PayloadEmpty", r.Status, r.Body)
+ }
+ // A sound-only aps is a valid minimal payload.
+ if r := f.call("on_send", "POST", "/3/device/"+apnsVMKnownToken, apnsVMKnownToken,
+ map[string]any{"aps": map[string]any{"sound": "chime.aiff"}}, auth); r.Status != 200 {
+ t.Fatalf("sound-only aps -> %d, want 200; body %v", r.Status, r.Body)
+ }
+
+ // ===== sent notifications are retrievable per device =====
+ list := f.call("on_get_notifications", "GET", "/3/device/"+apnsVMKnownToken+"/notifications",
+ apnsVMKnownToken, nil, auth)
+ if list.Status != 200 {
+ t.Fatalf("notifications -> %d: %v", list.Status, list.Body)
+ }
+ items := list.BodyList
+ if len(items) != 3 {
+ t.Fatalf("notifications count = %d, want 3 (two pushes + sound-only)", len(items))
+ }
+ ids := map[string]bool{}
+ for _, it := range items {
+ n, _ := it.(map[string]any)
+ ids[n["apns-id"].(string)] = true
+ if _, has := n["aps"]; !has {
+ t.Fatalf("notification %v missing aps", n)
+ }
+ if _, has := n["sent_at"]; !has {
+ t.Fatalf("notification %v missing sent_at", n)
+ }
+ }
+ if !ids[apnsID] || !ids[apnsID2] {
+ t.Fatalf("notification ids %v missing the served apns-ids %q / %q", ids, apnsID, apnsID2)
+ }
+ // The unknown device has nothing stored.
+ if r := f.call("on_get_notifications", "GET", "/3/device/"+apnsVMNewToken+"/notifications",
+ apnsVMNewToken, nil, auth); r.Status != 200 || len(r.BodyList) != 0 {
+ t.Fatalf("unknown device notifications -> %d %v, want 200 []", r.Status, r.BodyList)
+ }
+ // The gate applies to the internal route too.
+ wantReason(t, f.call("on_get_notifications", "GET", "/3/device/"+apnsVMKnownToken+"/notifications",
+ apnsVMKnownToken, nil, ""), 403, "MissingProviderToken", "notifications without auth")
+}
diff --git a/adapters/avalara-style/README.md b/adapters/avalara-style/README.md
index 1647abff..08b340d5 100644
--- a/adapters/avalara-style/README.md
+++ b/adapters/avalara-style/README.md
@@ -85,19 +85,19 @@ Each line's `details` array shows the per-jurisdiction breakdown (rate + tax).
```json
// Tax calculation
{
- "totalTax": "9.5",
- "totalTaxable": "100.0",
+ "totalTax": 9.5,
+ "totalTaxable": 100.0,
"totalRate": 0.095,
"lines": [{
"number": "1",
- "tax": "9.5",
+ "tax": 9.5,
"details": [
- { "jurisdiction": "CA", "jurisdictionType": "State", "rate": 0.0475, "tax": "4.75" },
- { "jurisdiction": "CA County", "jurisdictionType": "County", "rate": 0.0238, "tax": "2.38" },
+ { "jurisdiction": "CA", "jurisdictionType": "State", "rate": 0.0475, "tax": 4.75 },
+ { "jurisdiction": "CA County", "jurisdictionType": "County", "rate": 0.0238, "tax": 2.38 },
...
]
}],
- "summary": [{ "jurisName": "CA", "jurisdictionType": "State", "rate": 0.0475, "tax": "4.75" }]
+ "summary": [{ "jurisName": "CA", "jurisdictionType": "State", "rate": 0.0475, "tax": 4.75 }]
}
// Error
diff --git a/adapters/avalara-style/adapter.yaml b/adapters/avalara-style/adapter.yaml
index 2dd3bc74..03866676 100644
--- a/adapters/avalara-style/adapter.yaml
+++ b/adapters/avalara-style/adapter.yaml
@@ -36,6 +36,7 @@ endpoints:
- route: /v2/transactions/{id}/void
method: POST
handler: scripts/transactions.star#on_void_transaction
+ concurrency_key: id # read-then-cancel read-modify-write, serialized per transaction
# --- Companies ---
- route: /v2/companies
diff --git a/adapters/avalara-style/scripts/lib.star b/adapters/avalara-style/scripts/lib.star
index 1dcbef85..46f11e63 100644
--- a/adapters/avalara-style/scripts/lib.star
+++ b/adapters/avalara-style/scripts/lib.star
@@ -165,8 +165,8 @@ def _compute_tax(lines, state):
result_lines.append({
"number": number,
- "tax": _fmt(tax),
- "taxCalculated": _fmt(tax),
+ "tax": tax,
+ "taxCalculated": tax,
"taxCode": line_in.get("taxCode", "P0000000"),
"details": details,
})
@@ -180,19 +180,22 @@ def _compute_tax(lines, state):
"jurisdictionType": bd["jurisdictionType"],
"taxType": "Sales",
"rate": bd["rate"],
- "tax": _fmt(total_taxable * bd["rate"]),
+ "tax": total_taxable * bd["rate"],
"taxName": bd["taxName"],
})
+ # AvaTax serializes decimal fields as JSON numbers, not strings.
return {
- "totalTax": _fmt(total_tax),
- "totalTaxable": _fmt(total_taxable),
+ "totalTax": total_tax,
+ "totalTaxable": total_taxable,
"totalRate": rate,
"lines": result_lines,
"summary": summary,
}
-# _to_float converts a value to float64 (handles int, string, float).
+# _to_float converts a value to float64. AvaTax SDKs send decimal strings
+# ("100.00") as often as JSON numbers, so the string form is parsed too
+# (digits, one optional dot, one optional leading sign); junk becomes 0.0.
def _to_float(val):
if val == None:
return 0.0
@@ -200,8 +203,22 @@ def _to_float(val):
return float(val)
if type(val) == "float":
return val
- # String.
- return 0.0
+ s = str(val)
+ if s == "":
+ return 0.0
+ dots = 0
+ for i in range(len(s)):
+ ch = s[i]
+ if ch == ".":
+ dots = dots + 1
+ if dots > 1:
+ return 0.0
+ elif ch == "+" or ch == "-":
+ if i != 0 or len(s) == 1:
+ return 0.0
+ elif ch < "0" or ch > "9":
+ return 0.0
+ return float(s)
# _round2 rounds a float to 2 decimal places.
def _round2(val):
@@ -212,10 +229,6 @@ def _round2(val):
def _round4(val):
return float(int(val * 10000 + 0.5)) / 10000.0
-# _fmt formats a float as a string like "8.25".
-def _fmt(val):
- return str(val)
-
# --- Query + pagination helpers ---
# _get_query safely returns a query parameter value.
diff --git a/adapters/avalara-style/scripts/transactions.star b/adapters/avalara-style/scripts/transactions.star
index ff6d1f2e..e2904d74 100644
--- a/adapters/avalara-style/scripts/transactions.star
+++ b/adapters/avalara-style/scripts/transactions.star
@@ -36,10 +36,10 @@ def on_create_transaction(req):
state = _address_state(addresses)
tax_result = _compute_tax(lines, state)
- # Compute total amount = totalTaxable + totalTax.
+ # Compute total amount = totalTaxable + totalTax (decimals are numbers).
total_taxable = tax_result["totalTaxable"]
total_tax = tax_result["totalTax"]
- total_amount = _fmt(_to_float(total_taxable) + _to_float(total_tax))
+ total_amount = total_taxable + total_tax
txn_id = _txn_id()
code = _txn_code()
@@ -103,8 +103,8 @@ def on_list_transactions(req):
"id": doc.get("id", ""),
"code": doc.get("code", ""),
"type": doc.get("type", "SalesInvoice"),
- "totalAmount": doc.get("totalAmount", "0"),
- "totalTax": doc.get("totalTax", "0"),
+ "totalAmount": doc.get("totalAmount", 0),
+ "totalTax": doc.get("totalTax", 0),
"status": doc.get("status", "Saved"),
})
diff --git a/adapters/avalara_style_test.go b/adapters/avalara_style_test.go
new file mode 100644
index 00000000..c8a35b59
--- /dev/null
+++ b/adapters/avalara_style_test.go
@@ -0,0 +1,552 @@
+package adapters
+
+import (
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the avalara-style adapter scripts directly (lib.star preloaded) over
+// a shared store and a virtual clock: the Bearer-or-Basic credential gate on
+// every /v2 endpoint, the deterministic State/County/City/Special tax split
+// with its SDK-decimal-string inputs, the transaction lifecycle (create,
+// list with OData $filter/$orderBy/$top/$skip, read by id, void) and the
+// companies/nexus/taxcode catalogs — with the undated-transaction default
+// stamped from the clock instead of sleeps.
+const (
+ avHost = "sandbox-rest.avatax.test"
+ avBearer = "Bearer av-account-license-key"
+ avBasic = "Basic YXZheGE6bGljZW5zZQ==" // any credential opens the mock gate
+)
+
+type avalaraFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ host string
+}
+
+func newAvalaraFixture(t *testing.T, start time.Time) *avalaraFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "avalara-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &avalaraFixture{t: t, vc: vc, host: avHost, vms: map[string]*starlark.VM{
+ "tax": load("tax.star"), "txns": load("transactions.star"),
+ "companies": load("companies.star"), "defs": load("definitions.star"),
+ }}
+}
+
+func (f *avalaraFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// --- assertion helpers ---
+
+// avErr asserts the AvaTax error envelope — {error: {code, message, target,
+// details: []}} — plus the HTTP status.
+func avErr(t *testing.T, r starlark.Response, wantStatus int, wantCode string) {
+ t.Helper()
+ if r.Status != wantStatus {
+ t.Fatalf("%s error: status -> %d, want %d; body %v", wantCode, r.Status, wantStatus, r.Body)
+ }
+ e, ok := r.Body["error"].(map[string]any)
+ if !ok {
+ t.Fatalf("%s error: error = %v, want object", wantCode, r.Body["error"])
+ }
+ if e["code"] != wantCode {
+ t.Fatalf("error code = %v, want %s (envelope %v)", e["code"], wantCode, e)
+ }
+ if m, _ := e["message"].(string); m == "" {
+ t.Fatalf("%s error: message is empty", wantCode)
+ }
+ if _, has := e["target"]; !has {
+ t.Fatalf("%s error: no target field: %v", wantCode, e)
+ }
+ if _, ok := e["details"].([]any); !ok {
+ t.Fatalf("%s error: details = %v, want an array", wantCode, e["details"])
+ }
+}
+
+// avNum compares a JSON number regardless of int64/float64 width (stored
+// docs round-trip through the collection, where ints come back floats).
+func avNum(t *testing.T, v any, want float64, what string) {
+ t.Helper()
+ switch n := v.(type) {
+ case int64:
+ if float64(n) != want {
+ t.Fatalf("%s = %d, want %v", what, n, want)
+ }
+ case float64:
+ if n != want {
+ t.Fatalf("%s = %v, want %v", what, n, want)
+ }
+ default:
+ t.Fatalf("%s = %T(%v), want number %v", what, v, v, want)
+ }
+}
+
+// avClose compares a float with a 1e-9 tolerance (split rates are each the
+// nearest float64 to their decimal, so their sum carries representation dust).
+func avClose(t *testing.T, v any, want float64, what string) {
+ t.Helper()
+ n, ok := v.(float64)
+ if !ok {
+ t.Fatalf("%s = %T(%v), want float %v", what, v, v, want)
+ }
+ if math.Abs(n-want) > 1e-9 {
+ t.Fatalf("%s = %v, want %v (±1e-9)", what, n, want)
+ }
+}
+
+// avValue pulls the "value" array out of an OData list envelope.
+func avValue(t *testing.T, r starlark.Response) []any {
+ t.Helper()
+ if r.Status != 200 {
+ t.Fatalf("list -> %d: %v", r.Status, r.Body)
+ }
+ docs, ok := r.Body["value"].([]any)
+ if !ok {
+ t.Fatalf("value = %v, want array", r.Body["value"])
+ }
+ return docs
+}
+
+// TestAvalaraCredentialGate: AvaTax accepts a Bearer (account/license key) or
+// HTTP Basic credential on every /v2 endpoint; anything less gets the 401
+// AuthenticationRequired envelope.
+func TestAvalaraCredentialGate(t *testing.T) {
+ f := newAvalaraFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC))
+
+ // ===== every v2 endpoint demands a credential: a bare call is a 401 AuthenticationRequired envelope =====
+ for _, probe := range []struct{ group, handler, method, path string }{
+ {"tax", "on_calculate_tax", "POST", "/v2/tax/calculate"},
+ {"txns", "on_create_transaction", "POST", "/v2/transactions/create"},
+ {"txns", "on_list_transactions", "GET", "/v2/transactions"},
+ {"txns", "on_get_transaction", "GET", "/v2/transactions/3000000001"},
+ {"txns", "on_void_transaction", "POST", "/v2/transactions/3000000001/void"},
+ {"companies", "on_list_companies", "GET", "/v2/companies"},
+ {"defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses"},
+ {"defs", "on_list_taxcodes", "GET", "/v2/definitions/taxcodes"},
+ } {
+ avErr(t, f.call(probe.group, probe.handler, probe.method, probe.path,
+ map[string]string{"id": "3000000001"}, nil, map[string]any{}, ""), 401, "AuthenticationRequired")
+ }
+
+ // ===== any Bearer or any HTTP Basic credential opens the gate =====
+ // As-is: credentials are presence-checked only — the mock accepts any
+ // bearer token or basic pair (real AvaTax validates account/license keys).
+ for _, auth := range []string{avBearer, "Bearer anything", avBasic} {
+ r := f.call("tax", "on_calculate_tax", "POST", "/v2/tax/calculate", nil, nil,
+ map[string]any{"lines": []any{map[string]any{"amount": 100}}}, auth)
+ if r.Status != 200 {
+ t.Fatalf("calculate with %q -> %d: %v", auth, r.Status, r.Body)
+ }
+ }
+
+ // ===== a non-Basic/Non-Bearer scheme does not count as a credential =====
+ avErr(t, f.call("companies", "on_list_companies", "GET", "/v2/companies", nil, nil, nil, "Token abc123"),
+ 401, "AuthenticationRequired")
+}
+
+// TestAvalaraTaxCalculateJurisdictionSplit: the quick estimate — state-keyed
+// effective rate, the deterministic State/County/City/Special split, per-line
+// cents rounding, and the SDK's decimal-string amounts.
+func TestAvalaraTaxCalculateJurisdictionSplit(t *testing.T) {
+ f := newAvalaraFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC))
+ calc := func(body map[string]any) starlark.Response {
+ return f.call("tax", "on_calculate_tax", "POST", "/v2/tax/calculate", nil, nil, body, avBearer)
+ }
+ caAddr := map[string]any{"singleLocation": map[string]any{
+ "line1": "100 Main St", "city": "San Francisco", "region": "CA", "country": "US", "postalCode": "94016",
+ }}
+
+ // ===== the effective rate keys off the address state (CA 0.095) with a State/County/City/Special breakdown =====
+ r := calc(map[string]any{
+ "addresses": caAddr,
+ "lines": []any{map[string]any{"number": "1", "quantity": 1, "amount": 100.0, "taxCode": "P0000000"}},
+ })
+ if r.Status != 200 {
+ t.Fatalf("calculate -> %d: %v", r.Status, r.Body)
+ }
+ avNum(t, r.Body["totalRate"], 0.095, "CA totalRate")
+ avNum(t, r.Body["totalTaxable"], 100, "CA totalTaxable")
+ avNum(t, r.Body["totalTax"], 9.5, "CA totalTax")
+ lines, ok := r.Body["lines"].([]any)
+ if !ok || len(lines) != 1 {
+ t.Fatalf("lines = %v, want the one submitted line", r.Body["lines"])
+ }
+ line := lines[0].(map[string]any)
+ if line["number"] != "1" || line["taxCode"] != "P0000000" {
+ t.Fatalf("line echo = %v", line)
+ }
+ avNum(t, line["tax"], 9.5, "line tax")
+ avNum(t, line["taxCalculated"], 9.5, "line taxCalculated")
+ details, ok := line["details"].([]any)
+ if !ok || len(details) != 4 {
+ t.Fatalf("details = %v, want the four jurisdictions", line["details"])
+ }
+ wantSplit := []struct {
+ juris, jtype string
+ rate, tax float64
+ }{
+ {"CA", "State", 0.0475, 4.75},
+ {"CA County", "County", 0.0238, 2.38},
+ {"CA City", "City", 0.019, 1.9},
+ {"Special", "Special", 0.0047, 0.47},
+ }
+ sum := 0.0
+ for i, w := range wantSplit {
+ d := details[i].(map[string]any)
+ if d["jurisdiction"] != w.juris || d["jurisdictionType"] != w.jtype {
+ t.Fatalf("details[%d] = %v, want %s/%s", i, d, w.juris, w.jtype)
+ }
+ avNum(t, d["rate"], w.rate, w.jtype+" rate")
+ avNum(t, d["tax"], w.tax, w.jtype+" tax")
+ sum += d["rate"].(float64)
+ }
+ avClose(t, sum, 0.095, "sum of jurisdiction rates")
+
+ // ===== the summary aggregates the taxable base per jurisdiction =====
+ summary, ok := r.Body["summary"].([]any)
+ if !ok || len(summary) != 4 {
+ t.Fatalf("summary = %v, want the four jurisdictions", r.Body["summary"])
+ }
+ s0 := summary[0].(map[string]any)
+ if s0["jurisName"] != "CA" || s0["jurisCode"] != "CA" || s0["taxType"] != "Sales" {
+ t.Fatalf("summary[0] = %v", s0)
+ }
+ avNum(t, s0["rate"], 0.0475, "summary state rate")
+ avNum(t, s0["tax"], 4.75, "summary state tax")
+
+ // ===== per-line tax rounds to cents: two lines aggregate, line 2 keeps its own tax =====
+ r = calc(map[string]any{
+ "addresses": caAddr,
+ "lines": []any{
+ map[string]any{"number": "1", "amount": 100.0},
+ map[string]any{"number": "2", "amount": 50.0},
+ },
+ })
+ avNum(t, r.Body["totalTaxable"], 150, "two-line totalTaxable")
+ avNum(t, r.Body["totalTax"], 14.25, "two-line totalTax")
+ lines = r.Body["lines"].([]any)
+ avNum(t, lines[1].(map[string]any)["tax"], 4.75, "line 2 tax")
+
+ // ===== SDK decimal strings ("100.00") price identically to JSON numbers =====
+ // AvaTax SDKs serialize decimals as strings as often as numbers; both
+ // feed the same engine.
+ r = calc(map[string]any{
+ "addresses": caAddr,
+ "lines": []any{map[string]any{"number": "1", "amount": "100.00"}},
+ })
+ avNum(t, r.Body["totalTaxable"], 100, "string-amount totalTaxable")
+ avNum(t, r.Body["totalTax"], 9.5, "string-amount totalTax")
+
+ // ===== the shipFrom/shipTo form keys off shipTo (NY 0.0875) =====
+ r = calc(map[string]any{
+ "addresses": map[string]any{
+ "shipFrom": map[string]any{"line1": "1 Main St", "city": "Seattle", "region": "WA", "country": "US"},
+ "shipTo": map[string]any{"line1": "9 Broadway", "city": "New York", "region": "NY", "country": "US"},
+ },
+ "lines": []any{map[string]any{"number": "1", "amount": "200.00"}},
+ })
+ avNum(t, r.Body["totalRate"], 0.0875, "shipTo NY totalRate")
+ avNum(t, r.Body["totalTax"], 17.5, "shipTo NY totalTax")
+
+ // ===== unknown or missing addresses fall back to the synthetic 0.0825 default =====
+ // As-is: real AvaTax geocodes and rejects unresolvable addresses; the
+ // mock applies a flat default rate.
+ r = calc(map[string]any{"lines": []any{map[string]any{"amount": 100}}})
+ avNum(t, r.Body["totalRate"], 0.0825, "no-address totalRate")
+ avNum(t, r.Body["totalTax"], 8.25, "no-address totalTax")
+ r = calc(map[string]any{
+ "addresses": map[string]any{"singleLocation": map[string]any{"region": "ZZ"}},
+ "lines": []any{map[string]any{"amount": 100}},
+ })
+ avNum(t, r.Body["totalRate"], 0.0825, "unknown-state totalRate")
+}
+
+// TestAvalaraTransactionLifecycle: create with AvaTax defaults, the
+// OData-filterable list with $top/$skip paging, read by id, and void.
+func TestAvalaraTransactionLifecycle(t *testing.T) {
+ f := newAvalaraFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC))
+ caAddr := map[string]any{"singleLocation": map[string]any{
+ "line1": "100 Main St", "city": "San Francisco", "region": "CA", "country": "US",
+ }}
+ create := func(body map[string]any) starlark.Response {
+ return f.call("txns", "on_create_transaction", "POST", "/v2/transactions/create", nil, nil, body, avBearer)
+ }
+ get := func(id string) starlark.Response {
+ return f.call("txns", "on_get_transaction", "GET", "/v2/transactions/"+id, map[string]string{"id": id}, nil, nil, avBearer)
+ }
+ list := func(query map[string]string) starlark.Response {
+ return f.call("txns", "on_list_transactions", "GET", "/v2/transactions", nil, query, nil, avBearer)
+ }
+
+ // ===== create prices the document, mints id/code/companyId and applies AvaTax defaults =====
+ created := create(map[string]any{
+ "companyCode": "DEFAULT", "date": "2026-06-15",
+ "addresses": caAddr,
+ "lines": []any{
+ map[string]any{"number": "1", "quantity": 1, "amount": 100.0, "taxCode": "P0000000"},
+ map[string]any{"number": "2", "quantity": 2, "amount": "50.00", "taxCode": "P0000000"},
+ },
+ })
+ if created.Status != 200 {
+ t.Fatalf("create transaction -> %d: %v", created.Status, created.Body)
+ }
+ txnID, _ := created.Body["id"].(string)
+ if !strings.HasPrefix(txnID, "3000000") {
+ t.Fatalf("transaction id = %q, want an AvaTax-style 3000000... id", txnID)
+ }
+ if code, _ := created.Body["code"].(string); !strings.HasPrefix(code, "INV-") {
+ t.Fatalf("transaction code = %v, want INV- prefix", created.Body["code"])
+ }
+ if cid, _ := created.Body["companyId"].(string); !strings.HasPrefix(cid, "2000000") {
+ t.Fatalf("companyId = %v, want a 2000000... company id", created.Body["companyId"])
+ }
+ if created.Body["type"] != "SalesInvoice" || created.Body["status"] != "Saved" || created.Body["customerCode"] != "CUST001" {
+ t.Fatalf("create defaults = %v", created.Body)
+ }
+ avNum(t, created.Body["totalTaxable"], 150, "created totalTaxable")
+ avNum(t, created.Body["totalTax"], 14.25, "created totalTax")
+ avNum(t, created.Body["totalAmount"], 164.25, "created totalAmount (taxable + tax)")
+ if addr, _ := created.Body["addresses"].(map[string]any); addr["singleLocation"] == nil {
+ t.Fatalf("created addresses = %v, want the singleLocation echo", created.Body["addresses"])
+ }
+ if lines, ok := created.Body["lines"].([]any); !ok || len(lines) != 2 {
+ t.Fatalf("created lines = %v, want the two submitted lines", created.Body["lines"])
+ }
+
+ // ===== an omitted date defaults to the clock's today, and advances with it =====
+ undated := create(map[string]any{
+ "addresses": caAddr,
+ "lines": []any{map[string]any{"amount": 10}},
+ })
+ if undated.Body["date"] != "2026-02-03" {
+ t.Fatalf("undated transaction date = %v, want the clock's today", undated.Body["date"])
+ }
+ f.vc.Advance(26 * time.Hour)
+ next := create(map[string]any{
+ "addresses": caAddr,
+ "lines": []any{map[string]any{"amount": 10}},
+ })
+ if next.Body["date"] != "2026-02-04" {
+ t.Fatalf("post-advance date = %v, want the advanced clock's day", next.Body["date"])
+ }
+ if created.Body["date"] != "2026-06-15" {
+ t.Fatalf("explicit date = %v, want it kept verbatim", created.Body["date"])
+ }
+
+ // ===== read round-trips by id; unknown ids are 404 NotFound =====
+ byID := get(txnID)
+ if byID.Status != 200 {
+ t.Fatalf("get transaction -> %d: %v", byID.Status, byID.Body)
+ }
+ if byID.Body["id"] != txnID || byID.Body["status"] != "Saved" {
+ t.Fatalf("get transaction echo = %v", byID.Body)
+ }
+ avNum(t, byID.Body["totalAmount"], 164.25, "read-back totalAmount")
+ avErr(t, get("3000000999"), 404, "NotFound")
+
+ // ===== the list supports OData $filter and $orderBy with @recordsetCount =====
+ ret := create(map[string]any{
+ "type": "ReturnInvoice", "date": "2026-06-16",
+ "addresses": map[string]any{"shipTo": map[string]any{"region": "NY", "country": "US"}},
+ "lines": []any{map[string]any{"amount": "200.00"}},
+ })
+ retID, _ := ret.Body["id"].(string)
+ all := avValue(t, list(nil))
+ if len(all) != 4 {
+ t.Fatalf("unfiltered list -> %d rows, want the 4 created", len(all))
+ }
+ avNum(t, all[0].(map[string]any)["totalAmount"], 164.25, "list row 0 totalAmount")
+ filtered := avValue(t, list(map[string]string{"$filter": "type eq 'ReturnInvoice'"}))
+ if len(filtered) != 1 || filtered[0].(map[string]any)["id"] != retID {
+ t.Fatalf("$filter type -> %v, want the ReturnInvoice row", filtered)
+ }
+ avNum(t, filtered[0].(map[string]any)["totalTax"], 17.5, "ReturnInvoice totalTax")
+ sorted := avValue(t, list(map[string]string{"$orderBy": "totalTax desc"}))
+ if got := sorted[0].(map[string]any)["id"]; got != retID {
+ t.Fatalf("$orderBy totalTax desc = %v first, want the NY return (17.5 > 14.25)", got)
+ }
+
+ // ===== $top/$skip pages through an @odata.nextLink that round-trips =====
+ page1 := list(map[string]string{"$top": "1"})
+ rows := avValue(t, page1)
+ avNum(t, page1.Body["@recordsetCount"], 4, "paged @recordsetCount")
+ if len(rows) != 1 || rows[0].(map[string]any)["id"] != txnID {
+ t.Fatalf("$top 1 first page = %v", rows)
+ }
+ if link, _ := page1.Body["@odata.nextLink"].(string); link != "/v2/transactions?$top=1&$skip=1" {
+ t.Fatalf("@odata.nextLink = %v, want /v2/transactions?$top=1&$skip=1", page1.Body["@odata.nextLink"])
+ }
+ page2 := list(map[string]string{"$top": "1", "$skip": "1"})
+ rows = avValue(t, page2)
+ if len(rows) != 1 || rows[0].(map[string]any)["id"] == txnID {
+ t.Fatalf("$skip 1 second page = %v, want the next row", rows)
+ }
+ if link, _ := page2.Body["@odata.nextLink"].(string); link != "/v2/transactions?$top=1&$skip=2" {
+ t.Fatalf("second page @odata.nextLink = %v, want $skip=2", page2.Body["@odata.nextLink"])
+ }
+ last := list(map[string]string{"$top": "1", "$skip": "3"})
+ rows = avValue(t, last)
+ if len(rows) != 1 {
+ t.Fatalf("final page = %v rows, want the fourth row", rows)
+ }
+ if _, has := last.Body["@odata.nextLink"]; has {
+ t.Fatalf("exhausted page still carries @odata.nextLink: %v", last.Body["@odata.nextLink"])
+ }
+ avErr(t, list(map[string]string{"$top": "1", "$skip": "abc"}), 400, "InvalidCursor")
+
+ // ===== void flips status to Cancelled and the record reads back cancelled =====
+ void := f.call("txns", "on_void_transaction", "POST", "/v2/transactions/"+txnID+"/void",
+ map[string]string{"id": txnID}, nil, map[string]any{}, avBearer)
+ if void.Status != 200 {
+ t.Fatalf("void transaction -> %d: %v", void.Status, void.Body)
+ }
+ // As-is: the void response is a minimal {id, status} envelope; real
+ // AvaTax returns the full TransactionModel.
+ if void.Body["id"] != txnID || void.Body["status"] != "Cancelled" {
+ t.Fatalf("void response = %v, want {id, status Cancelled}", void.Body)
+ }
+ if after := get(txnID); after.Status != 200 || after.Body["status"] != "Cancelled" {
+ t.Fatalf("get after void -> %d %v, want the record kept with status Cancelled", after.Status, after.Body)
+ }
+ cancelled := avValue(t, list(map[string]string{"$filter": "status eq 'Cancelled'"}))
+ if len(cancelled) != 1 || cancelled[0].(map[string]any)["id"] != txnID {
+ t.Fatalf("$filter status Cancelled -> %v, want the voided row", cancelled)
+ }
+ avErr(t, f.call("txns", "on_void_transaction", "POST", "/v2/transactions/3000000999/void",
+ map[string]string{"id": "3000000999"}, nil, map[string]any{}, avBearer), 404, "NotFound")
+
+ // ===== re-void is idempotent =====
+ // As-is: real AvaTax rejects voiding an already-Cancelled document; the
+ // mock re-cancels with 200.
+ again := f.call("txns", "on_void_transaction", "POST", "/v2/transactions/"+txnID+"/void",
+ map[string]string{"id": txnID}, nil, map[string]any{}, avBearer)
+ if again.Status != 200 || again.Body["status"] != "Cancelled" {
+ t.Fatalf("re-void -> %d %v, want 200 Cancelled (as-is)", again.Status, again.Body)
+ }
+}
+
+// TestAvalaraCatalogs: the companies, nexus and tax-code definition catalogs
+// with their typed OData $filter literals.
+func TestAvalaraCatalogs(t *testing.T) {
+ f := newAvalaraFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC))
+
+ // ===== the companies catalog lists DEFAULT and STORE1 with default locations =====
+ companies := f.call("companies", "on_list_companies", "GET", "/v2/companies", nil, nil, nil, avBearer)
+ rows := avValue(t, companies)
+ if len(rows) != 2 {
+ t.Fatalf("companies -> %d rows, want 2", len(rows))
+ }
+ c0 := rows[0].(map[string]any)
+ c1 := rows[1].(map[string]any)
+ if c0["companyCode"] != "DEFAULT" || c0["name"] != "Default Company" {
+ t.Fatalf("company 0 = %v", c0)
+ }
+ if c1["companyCode"] != "STORE1" {
+ t.Fatalf("company 1 = %v", c1)
+ }
+ loc, _ := c1["defaultLocation"].(map[string]any)
+ if loc["region"] != "NY" || loc["city"] != "New York" {
+ t.Fatalf("STORE1 defaultLocation = %v", loc)
+ }
+ if _, ok := c0["id"].(string); !ok {
+ t.Fatalf("company id = %v, want a string id", c0["id"])
+ }
+
+ // ===== nexus $filter literals are typed: id eq 1001 matches ints, hasNexus eq true matches bools =====
+ nexuses := f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil, nil, nil, avBearer)
+ rows = avValue(t, nexuses)
+ if len(rows) != 3 {
+ t.Fatalf("nexuses -> %d rows, want 3", len(rows))
+ }
+ n0 := rows[0].(map[string]any)
+ avNum(t, n0["id"], 1001, "nexus id")
+ if n0["jurisdictionCode"] != "CA" || n0["jurisdictionName"] != "California" || n0["hasNexus"] != true {
+ t.Fatalf("nexus 0 = %v", n0)
+ }
+ byID := avValue(t, f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil,
+ map[string]string{"$filter": "id eq 1001"}, nil, avBearer))
+ if len(byID) != 1 || byID[0].(map[string]any)["jurisdictionCode"] != "CA" {
+ t.Fatalf("$filter id eq 1001 -> %v, want the CA nexus", byID)
+ }
+ if got := avValue(t, f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil,
+ map[string]string{"$filter": "id eq '1001'"}, nil, avBearer)); len(got) != 0 {
+ t.Fatalf("$filter id eq '1001' -> %v, want no rows (quoted literals compare as strings)", got)
+ }
+ if got := avValue(t, f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil,
+ map[string]string{"$filter": "hasNexus eq true"}, nil, avBearer)); len(got) != 3 {
+ t.Fatalf("$filter hasNexus eq true -> %d rows, want all 3", len(got))
+ }
+ if got := avValue(t, f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil,
+ map[string]string{"$filter": "hasNexus eq false"}, nil, avBearer)); len(got) != 0 {
+ t.Fatalf("$filter hasNexus eq false -> %d rows, want none", len(got))
+ }
+ if got := avValue(t, f.call("defs", "on_list_nexuses", "GET", "/v2/definitions/nexuses", nil,
+ map[string]string{"$filter": "jurisdictionCode eq 'NY'"}, nil, avBearer)); len(got) != 1 {
+ t.Fatalf("$filter jurisdictionCode eq 'NY' -> %d rows, want the NY nexus", len(got))
+ }
+
+ // ===== the taxcode catalog is filterable by taxCode =====
+ codes := f.call("defs", "on_list_taxcodes", "GET", "/v2/definitions/taxcodes", nil, nil, nil, avBearer)
+ rows = avValue(t, codes)
+ if len(rows) != 4 {
+ t.Fatalf("taxcodes -> %d rows, want 4", len(rows))
+ }
+ nt := avValue(t, f.call("defs", "on_list_taxcodes", "GET", "/v2/definitions/taxcodes", nil,
+ map[string]string{"$filter": "taxCode eq 'NT'"}, nil, avBearer))
+ if len(nt) != 1 || nt[0].(map[string]any)["description"] != "Non-Taxable" {
+ t.Fatalf("$filter taxCode eq 'NT' -> %v", nt)
+ }
+}
diff --git a/adapters/cloudkit-style/scripts/lib.star b/adapters/cloudkit-style/scripts/lib.star
index 9538c615..d1af539e 100644
--- a/adapters/cloudkit-style/scripts/lib.star
+++ b/adapters/cloudkit-style/scripts/lib.star
@@ -263,10 +263,15 @@ def _err(status, code, reason):
"reason": reason,
})
-# _to_int parses a decimal string to int.
+# _to_int parses a decimal string to int. JSON numbers (resultsLimit) reach
+# handlers as int or float, so those pass through untouched.
def _to_int(s):
if s == None or s == "":
return 0
+ if type(s) == "int":
+ return s
+ if type(s) == "float":
+ return int(s)
n = 0
for i in range(len(s)):
ch = s[i]
diff --git a/adapters/cloudkit-style/scripts/records.star b/adapters/cloudkit-style/scripts/records.star
index 8ccfea72..c831bc3d 100644
--- a/adapters/cloudkit-style/scripts/records.star
+++ b/adapters/cloudkit-style/scripts/records.star
@@ -181,7 +181,8 @@ def _do_update(rc, record):
def _do_delete(rc, name):
for doc in rc.list():
if doc.get("recordName") == name:
- rc.delete(doc)
+ # delete takes the stored id string, not the doc (like update).
+ rc.delete(doc.get("id", ""))
return
# _matches_filters is retained for the legacy EQUALS-only shape; the query
diff --git a/adapters/cloudkit_style_test.go b/adapters/cloudkit_style_test.go
new file mode 100644
index 00000000..9d3e3031
--- /dev/null
+++ b/adapters/cloudkit_style_test.go
@@ -0,0 +1,467 @@
+package adapters
+
+import (
+ "crypto/ecdsa"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the cloudkit-style adapter scripts directly (lib.star preloaded)
+// over a shared store and virtual clock: the server-to-server request
+// signature gate (real ECDSA P-256 over date:raw_body:path), and the five
+// public-database routes — users/current, zones/list, records/lookup,
+// records/query and records/modify — against the seeded zones and Notes.
+const ckVMKeyID = "stunt-cloudkit-s2s-key-1"
+
+// ckVMPrivPEM mirrors the adapter's documented synthetic server-to-server
+// keypair (README): the private half signs here exactly the way a real
+// CloudKit web-services client would.
+const ckVMPrivPEM = `-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYWuBd8XWfDZ/VcJu
+QB09aJCel9cxSAjTK0x6bsCiCVGhRANCAAQ6HcT9YUUVXeqvZzOGGORZ89rQX0Ne
+n8el83/HqrrAlhhMFWpHo3iuSuqqFdhgd9XBSPPM9+E2RK/+qy+C4Qiw
+-----END PRIVATE KEY-----`
+
+// ckVMPrefix is the public-database path every route hangs off (the
+// container/env path params are opaque to the handlers).
+const ckVMPrefix = "/database/1/iCloud.stunt.test/production/public"
+
+type ckVMFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ host string
+}
+
+func newCloudKitVMFixture(t *testing.T, start time.Time) *ckVMFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "cloudkit-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &ckVMFixture{t: t, vc: vc, host: "api.apple-cloudkit.test", vms: map[string]*starlark.VM{
+ "records": load("records.star"), "zones": load("zones.star"), "users": load("users.star"),
+ }}
+}
+
+// signed computes the three X-Apple-CloudKit-Request-* headers over
+// date:rawBody:path for the marshaled body (ECDSA P-256 + SHA-256, base64
+// raw r||s) — the client side of the adapter's documented scheme.
+func (f *ckVMFixture) signed(path string, body map[string]any, date time.Time) map[string]string {
+ f.t.Helper()
+ raw, err := json.Marshal(body)
+ if err != nil {
+ f.t.Fatalf("marshal sign body: %v", err)
+ }
+ block, _ := pem.Decode([]byte(ckVMPrivPEM))
+ if block == nil {
+ f.t.Fatal("bad test private key PEM")
+ }
+ key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ f.t.Fatalf("parse test key: %v", err)
+ }
+ priv, ok := key.(*ecdsa.PrivateKey)
+ if !ok {
+ f.t.Fatal("test key is not ECDSA")
+ }
+ dateStr := date.UTC().Format(time.RFC3339)
+ msg := dateStr + ":" + string(raw) + ":" + path
+ h := sha256.Sum256([]byte(msg))
+ r, s, err := ecdsa.Sign(rand.Reader, priv, h[:])
+ if err != nil {
+ f.t.Fatalf("ecdsa sign: %v", err)
+ }
+ sig := make([]byte, 64)
+ r.FillBytes(sig[:32])
+ s.FillBytes(sig[32:])
+ return map[string]string{
+ "X-Apple-CloudKit-Request-KeyID": ckVMKeyID,
+ "X-Apple-CloudKit-Request-ISO8601Date": dateStr,
+ "X-Apple-CloudKit-Request-SignatureBase64": base64.StdEncoding.EncodeToString(sig),
+ }
+}
+
+// call drives handler on the named script VM with a JSON body: the raw bytes
+// feed raw_body (the signature covers them verbatim) and the re-parsed map
+// feeds body — what the engine hands a handler. hdrs nil = correctly signed
+// for this body at the virtual clock's now.
+func (f *ckVMFixture) call(group, handler, method, path string, body map[string]any, hdrs map[string]string) starlark.Response {
+ f.t.Helper()
+ if body == nil {
+ body = map[string]any{}
+ }
+ raw, err := json.Marshal(body)
+ if err != nil {
+ f.t.Fatalf("marshal body: %v", err)
+ }
+ var parsed map[string]any
+ if err := json.Unmarshal(raw, &parsed); err != nil {
+ f.t.Fatalf("re-parse body: %v", err)
+ }
+ if hdrs == nil {
+ hdrs = f.signed(path, body, f.vc.Now())
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: f.host, Headers: hdrs, Body: parsed, RawBody: string(raw),
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// ckVMNum coerces a JSON number that may surface as int64 or float64 (the
+// backing store round-trips records as JSON) to float64 for comparison.
+func ckVMNum(v any) float64 {
+ switch n := v.(type) {
+ case int64:
+ return float64(n)
+ case float64:
+ return n
+ default:
+ return -1
+ }
+}
+
+// wantAuthFailed asserts the CloudKit auth-failure envelope.
+func wantAuthFailed(t *testing.T, r starlark.Response, label string) {
+ t.Helper()
+ if r.Status != 401 {
+ t.Fatalf("%s -> %d, want 401; body %v", label, r.Status, r.Body)
+ }
+ if r.Body["serverErrorCode"] != "AUTHENTICATION_FAILED" {
+ t.Fatalf("%s serverErrorCode = %v, want AUTHENTICATION_FAILED", label, r.Body["serverErrorCode"])
+ }
+}
+
+func TestCloudKitVMRequestSignatureGate(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newCloudKitVMFixture(t, base)
+ lookup := ckVMPrefix + "/records/lookup"
+ body := map[string]any{"records": []any{map[string]any{"recordName": "note-001"}}}
+
+ // ===== the s2s signature gate rejects unsigned tampered stale and foreign-key requests =====
+ // Unsigned (no X-Apple-CloudKit-Request-* headers at all) -> 401.
+ wantAuthFailed(t, f.call("records", "on_lookup", "GET", lookup, body, map[string]string{}), "unsigned")
+
+ // Unknown KeyID (signed correctly, but not the registered key id).
+ h := f.signed(lookup, body, f.vc.Now())
+ h["X-Apple-CloudKit-Request-KeyID"] = "someone-elses-key"
+ wantAuthFailed(t, f.call("records", "on_lookup", "GET", lookup, body, h), "unknown key id")
+
+ // Tampered body: the signature covers note-001's raw bytes, note-002 ships.
+ h = f.signed(lookup, body, f.vc.Now())
+ other := map[string]any{"records": []any{map[string]any{"recordName": "note-002"}}}
+ wantAuthFailed(t, f.call("records", "on_lookup", "GET", lookup, other, h), "tampered body")
+
+ // Garbage signature (valid base64, wrong bytes).
+ h = f.signed(lookup, body, f.vc.Now())
+ junk := make([]byte, 64)
+ for i := range junk {
+ junk[i] = byte(i)
+ }
+ h["X-Apple-CloudKit-Request-SignatureBase64"] = base64.StdEncoding.EncodeToString(junk)
+ wantAuthFailed(t, f.call("records", "on_lookup", "GET", lookup, body, h), "garbage signature")
+
+ // Stale date: 20 minutes off the (virtual) server clock is outside the
+ // 10-minute window.
+ wantAuthFailed(t, f.call("records", "on_lookup", "GET", lookup, body,
+ f.signed(lookup, body, f.vc.Now().Add(-20*time.Minute))), "stale date")
+
+ // Inside the window (9 minutes old) still passes.
+ if r := f.call("records", "on_lookup", "GET", lookup, body,
+ f.signed(lookup, body, f.vc.Now().Add(-9*time.Minute))); r.Status != 200 {
+ t.Fatalf("9-minute-old date -> %d, want 200; body %v", r.Status, r.Body)
+ }
+}
+
+func TestCloudKitVMDatabaseRoutes(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newCloudKitVMFixture(t, base)
+
+ // ===== users current returns the s2s owner identity =====
+ user := f.call("users", "on_current_user", "GET", ckVMPrefix+"/users/current", nil, nil)
+ if user.Status != 200 {
+ t.Fatalf("users/current -> %d: %v", user.Status, user.Body)
+ }
+ if user.Body["userRecordName"] != "_owner" {
+ t.Fatalf("userRecordName = %v, want _owner", user.Body["userRecordName"])
+ }
+ if user.Body["firstName"] != "Test" || user.Body["lastName"] != "User" {
+ t.Fatalf("user identity = %v %v, want Test User", user.Body["firstName"], user.Body["lastName"])
+ }
+
+ // ===== zones list seeds defaults filters by prefix and pages =====
+ zones := f.call("zones", "on_list_zones", "GET", ckVMPrefix+"/zones/list", nil, nil)
+ if zones.Status != 200 {
+ t.Fatalf("zones/list -> %d: %v", zones.Status, zones.Body)
+ }
+ zoneList, _ := zones.Body["zones"].([]any)
+ if len(zoneList) != 2 {
+ t.Fatalf("seeded zones = %d, want 2 (%v)", len(zoneList), zones.Body)
+ }
+ first, _ := zoneList[0].(map[string]any)
+ if first["zoneName"] != "_default" || first["zoneType"] != "DEFAULT_ZONE" {
+ t.Fatalf("first zone = %v, want _default DEFAULT_ZONE", first)
+ }
+ // zoneNamePrefix filters before paging.
+ prefixed := f.call("zones", "on_list_zones", "GET", ckVMPrefix+"/zones/list",
+ map[string]any{"zoneNamePrefix": "_own"}, nil)
+ pl, _ := prefixed.Body["zones"].([]any)
+ if len(pl) != 1 || pl[0].(map[string]any)["zoneName"] != "_owner" {
+ t.Fatalf("prefix _own zones = %v, want only _owner", prefixed.Body)
+ }
+ // resultsLimit pages through the seeded zones with a continuation marker
+ // (JSON number, the shape real CloudKit documents).
+ page1 := f.call("zones", "on_list_zones", "GET", ckVMPrefix+"/zones/list",
+ map[string]any{"resultsLimit": float64(1)}, nil)
+ if page1.Status != 200 {
+ t.Fatalf("zones page 1 -> %d: %v", page1.Status, page1.Body)
+ }
+ p1, _ := page1.Body["zones"].([]any)
+ if len(p1) != 1 || p1[0].(map[string]any)["zoneName"] != "_default" {
+ t.Fatalf("zones page 1 = %v, want just _default", page1.Body)
+ }
+ marker, has := page1.Body["continuationMarker"]
+ if !has || marker == "" {
+ t.Fatalf("zones page 1 continuationMarker = %v, want non-empty", page1.Body["continuationMarker"])
+ }
+ page2 := f.call("zones", "on_list_zones", "GET", ckVMPrefix+"/zones/list",
+ map[string]any{"resultsLimit": float64(1), "continuationMarker": marker}, nil)
+ p2, _ := page2.Body["zones"].([]any)
+ if len(p2) != 1 || p2[0].(map[string]any)["zoneName"] != "_owner" {
+ t.Fatalf("zones page 2 = %v, want just _owner", page2.Body)
+ }
+ if _, has := page2.Body["continuationMarker"]; has {
+ t.Fatalf("zones page 2 continuationMarker = %v, want absent (exhausted)", page2.Body["continuationMarker"])
+ }
+
+ // ===== records lookup returns the seeded shape with inline NOT_FOUND =====
+ look := f.call("records", "on_lookup", "GET", ckVMPrefix+"/records/lookup",
+ map[string]any{"records": []any{
+ map[string]any{"recordName": "note-001"},
+ map[string]any{"recordName": "no-such-record"},
+ }}, nil)
+ if look.Status != 200 {
+ t.Fatalf("records/lookup -> %d: %v", look.Status, look.Body)
+ }
+ recs, _ := look.Body["records"].([]any)
+ if len(recs) != 2 {
+ t.Fatalf("lookup records = %d, want 2", len(recs))
+ }
+ rec, _ := recs[0].(map[string]any)
+ if rec["recordName"] != "note-001" || rec["recordType"] != "Notes" {
+ t.Fatalf("lookup record = %v, want note-001 Notes", rec)
+ }
+ fields, _ := rec["fields"].(map[string]any)
+ title, _ := fields["title"].(map[string]any)
+ if title["value"] != "Welcome Note" {
+ t.Fatalf("note-001 title = %v, want Welcome Note", title["value"])
+ }
+ created, _ := rec["created"].(map[string]any)
+ if ts := ckVMNum(created["timestamp"]); ts != 1700000000000 {
+ t.Fatalf("note-001 created.timestamp = %v, want 1700000000000", created["timestamp"])
+ }
+ // A missing name yields an inline per-record error, not a failed request.
+ missing, _ := recs[1].(map[string]any)
+ if missing["serverErrorCode"] != "NOT_FOUND" {
+ t.Fatalf("missing record entry = %v, want inline NOT_FOUND", missing)
+ }
+
+ // ===== records query filters sorts and pages on a numeric resultsLimit =====
+ eq := f.call("records", "on_query", "GET", ckVMPrefix+"/records/query", map[string]any{
+ "query": map[string]any{
+ "recordType": "Notes",
+ "filterBy": []any{map[string]any{"fieldName": "title", "comparator": "EQUALS", "fieldValue": map[string]any{"value": "Welcome Note"}}},
+ },
+ }, nil)
+ if eq.Status != 200 {
+ t.Fatalf("query EQUALS -> %d: %v", eq.Status, eq.Body)
+ }
+ if eqRecs, _ := eq.Body["records"].([]any); len(eqRecs) != 1 {
+ t.Fatalf("query EQUALS records = %d, want 1 (%v)", len(eqRecs), eq.Body)
+ }
+ bw := f.call("records", "on_query", "GET", ckVMPrefix+"/records/query", map[string]any{
+ "query": map[string]any{
+ "recordType": "Notes",
+ "filterBy": []any{map[string]any{"fieldName": "title", "comparator": "BEGINS_WITH", "fieldValue": map[string]any{"value": "Shopping"}}},
+ },
+ }, nil)
+ if bwRecs, _ := bw.Body["records"].([]any); len(bwRecs) != 1 ||
+ bwRecs[0].(map[string]any)["recordName"] != "note-002" {
+ t.Fatalf("query BEGINS_WITH = %v, want note-002 only", bw.Body)
+ }
+ // sortBy descending on title puts "Welcome Note" before "Shopping List".
+ sorted := f.call("records", "on_query", "GET", ckVMPrefix+"/records/query", map[string]any{
+ "query": map[string]any{
+ "recordType": "Notes",
+ "sortBy": []any{map[string]any{"fieldName": "title", "ascending": false}},
+ },
+ }, nil)
+ sRecs, _ := sorted.Body["records"].([]any)
+ if len(sRecs) != 2 {
+ t.Fatalf("query sorted records = %d, want 2", len(sRecs))
+ }
+ s0 := sRecs[0].(map[string]any)["fields"].(map[string]any)["title"].(map[string]any)["value"]
+ s1 := sRecs[1].(map[string]any)["fields"].(map[string]any)["title"].(map[string]any)["value"]
+ if s0 != "Welcome Note" || s1 != "Shopping List" {
+ t.Fatalf("sortBy title desc = [%v %v], want [Welcome Note Shopping List]", s0, s1)
+ }
+ // Page through both notes one at a time following continuationMarker.
+ pages, marker := 0, ""
+ for {
+ qb := map[string]any{"query": map[string]any{"recordType": "Notes"}, "resultsLimit": float64(1)}
+ if marker != "" {
+ qb["continuationMarker"] = marker
+ }
+ page := f.call("records", "on_query", "GET", ckVMPrefix+"/records/query", qb, nil)
+ if page.Status != 200 {
+ t.Fatalf("query page %d -> %d: %v", pages+1, page.Status, page.Body)
+ }
+ pRecs, _ := page.Body["records"].([]any)
+ if len(pRecs) != 1 {
+ t.Fatalf("query page %d records = %d, want 1", pages+1, len(pRecs))
+ }
+ pages++
+ m, has := page.Body["continuationMarker"]
+ if !has {
+ break
+ }
+ marker, _ = m.(string)
+ }
+ if pages != 2 {
+ t.Fatalf("query pages = %d, want 2", pages)
+ }
+ // A syntactically invalid marker is the adapter's 400, not a 500.
+ if r := f.call("records", "on_query", "GET", ckVMPrefix+"/records/query", map[string]any{
+ "query": map[string]any{"recordType": "Notes"}, "continuationMarker": "bogus",
+ }, nil); r.Status != 400 {
+ t.Fatalf("invalid continuationMarker -> %d, want 400; body %v", r.Status, r.Body)
+ }
+
+ // ===== records modify creates updates and deletes round-trip =====
+ mod := f.call("records", "on_modify", "POST", ckVMPrefix+"/records/modify", map[string]any{
+ "operations": []any{map[string]any{
+ "operationType": "create",
+ "record": map[string]any{
+ "recordName": "note-003", "recordType": "Notes",
+ "fields": map[string]any{
+ "title": map[string]any{"value": "New Note"},
+ "body": map[string]any{"value": "Created via modify"},
+ },
+ },
+ }},
+ }, nil)
+ if mod.Status != 200 {
+ t.Fatalf("modify create -> %d: %v", mod.Status, mod.Body)
+ }
+ mRecs, _ := mod.Body["records"].([]any)
+ if len(mRecs) != 1 || mRecs[0].(map[string]any)["recordName"] != "note-003" {
+ t.Fatalf("modify create records = %v, want note-003", mod.Body)
+ }
+ // The created record reads back through lookup (stateful).
+ back := f.call("records", "on_lookup", "GET", ckVMPrefix+"/records/lookup",
+ map[string]any{"records": []any{map[string]any{"recordName": "note-003"}}}, nil)
+ bRecs, _ := back.Body["records"].([]any)
+ if len(bRecs) != 1 {
+ t.Fatalf("lookup note-003 = %v", back.Body)
+ }
+ if got := bRecs[0].(map[string]any)["fields"].(map[string]any)["title"].(map[string]any)["value"]; got != "New Note" {
+ t.Fatalf("note-003 title = %v, want New Note", got)
+ }
+ // Update merges fields (body untouched) and bumps modified, not created.
+ upd := f.call("records", "on_modify", "POST", ckVMPrefix+"/records/modify", map[string]any{
+ "operations": []any{map[string]any{
+ "operationType": "update",
+ "record": map[string]any{
+ "recordName": "note-003",
+ "fields": map[string]any{"title": map[string]any{"value": "Renamed"}},
+ },
+ }},
+ }, nil)
+ uRec, _ := upd.Body["records"].([]any)[0].(map[string]any)
+ uFields := uRec["fields"].(map[string]any)
+ if uFields["title"].(map[string]any)["value"] != "Renamed" {
+ t.Fatalf("updated title = %v, want Renamed", uFields["title"])
+ }
+ if uFields["body"].(map[string]any)["value"] != "Created via modify" {
+ t.Fatalf("updated body = %v, want merged original", uFields["body"])
+ }
+ if ckVMNum(uRec["modified"].(map[string]any)["timestamp"]) <= ckVMNum(uRec["created"].(map[string]any)["timestamp"]) {
+ t.Fatalf("modified %v <= created %v", uRec["modified"], uRec["created"])
+ }
+ // forceUpdate of a missing record is an inline NOT_FOUND entry.
+ miss := f.call("records", "on_modify", "POST", ckVMPrefix+"/records/modify", map[string]any{
+ "operations": []any{map[string]any{
+ "operationType": "forceUpdate",
+ "record": map[string]any{"recordName": "ghost", "fields": map[string]any{}},
+ }},
+ }, nil)
+ if e := miss.Body["records"].([]any)[0].(map[string]any)["serverErrorCode"]; e != "NOT_FOUND" {
+ t.Fatalf("forceUpdate ghost = %v, want inline NOT_FOUND", e)
+ }
+ // Unknown operation types are reported inline, not fatal.
+ bad := f.call("records", "on_modify", "POST", ckVMPrefix+"/records/modify", map[string]any{
+ "operations": []any{map[string]any{"operationType": "frobnicate"}},
+ }, nil)
+ if e := bad.Body["records"].([]any)[0].(map[string]any)["serverErrorCode"]; e != "BAD_REQUEST" {
+ t.Fatalf("unknown operation = %v, want inline BAD_REQUEST", e)
+ }
+ // Delete removes the record and reports {recordName, deleted}.
+ del := f.call("records", "on_modify", "POST", ckVMPrefix+"/records/modify", map[string]any{
+ "operations": []any{map[string]any{
+ "operationType": "delete", "record": map[string]any{"recordName": "note-003"},
+ }},
+ }, nil)
+ dRec, _ := del.Body["records"].([]any)[0].(map[string]any)
+ if dRec["deleted"] != true || dRec["recordName"] != "note-003" {
+ t.Fatalf("delete record = %v, want {note-003, deleted}", dRec)
+ }
+ gone := f.call("records", "on_lookup", "GET", ckVMPrefix+"/records/lookup",
+ map[string]any{"records": []any{map[string]any{"recordName": "note-003"}}}, nil)
+ if e := gone.Body["records"].([]any)[0].(map[string]any)["serverErrorCode"]; e != "NOT_FOUND" {
+ t.Fatalf("lookup deleted note-003 = %v, want NOT_FOUND", e)
+ }
+}
diff --git a/adapters/dropbox-style/README.md b/adapters/dropbox-style/README.md
index d7625624..dda69b79 100644
--- a/adapters/dropbox-style/README.md
+++ b/adapters/dropbox-style/README.md
@@ -29,7 +29,7 @@ create in one request is visible in subsequent requests within the same
| Method | Route | Handler | Description |
|--------|-------|---------|-------------|
-| POST | `/2/files/upload` | `files.star#on_upload` | Upload a file (JSON `{path, content}`) |
+| POST | `/2/files/upload` | `files.star#on_upload` | Upload a file (JSON `{path, content}`; an existing path → `409 path/conflict`, like the real default `mode: add`) |
| POST | `/2/files/download` | `files.star#on_download` | Download file content (`{path}` or `{id}`) |
| POST | `/2/files/list_folder` | `files.star#on_list_folder` | List entries under a path prefix (missing path → `409 path/not_found`; file path → `409 path/not_folder`) |
| POST | `/2/files/get_metadata` | `files.star#on_get_metadata` | Get entry metadata (`{path}`) |
diff --git a/adapters/dropbox-style/scripts/files.star b/adapters/dropbox-style/scripts/files.star
index 965818cb..6411259c 100644
--- a/adapters/dropbox-style/scripts/files.star
+++ b/adapters/dropbox-style/scripts/files.star
@@ -80,13 +80,35 @@ def on_upload(req):
if body == None:
body = {}
- # path: JSON body wins, else the Dropbox-API-Arg header.
+ # path and write mode: JSON body wins, else the Dropbox-API-Arg header.
+ arg = _api_arg(req)
path = body.get("path", "")
if path == None or path == "":
- path = _api_arg(req).get("path", "")
+ path = arg.get("path", "")
if path == None or path == "":
return respond(409, _error("path"))
+ mode = body.get("mode", arg.get("mode", ""))
+ if type(mode) == "dict":
+ mode = mode.get(".tag", "add")
+ if mode == None or mode == "":
+ mode = "add"
+ autorename = body.get("autorename", arg.get("autorename", False))
+ if autorename == None:
+ autorename = False
+
+ # Real upload defaults to mode "add": an existing path is a 409 conflict.
+ # Inserting anyway would fork the path (two rows at one path_display) and
+ # strand the older row ahead of every later path read — a stale download.
+ existing = _find_by_path(path)
+ if existing != None:
+ if mode == "overwrite" or mode == "update":
+ pass # replace in place below
+ elif autorename:
+ path = _renamed_path(path)
+ else:
+ return respond(409, _error("path/conflict"))
+
# content: JSON body wins, else the raw request body (real octet-stream).
content = body.get("content", None)
if content == None:
@@ -95,6 +117,10 @@ def on_upload(req):
content = ""
file_id = _next_id()
+ if existing != None and (mode == "overwrite" or mode == "update"):
+ # Replace in place: keep the existing id so revisions and the blob
+ # stay anchored to the same file.
+ file_id = existing.get("id", file_id)
b = store_blob("dropbox")
b.put(file_id, content)
@@ -121,9 +147,30 @@ def on_upload(req):
"content_hash": _content_hash(content),
}
c = store_collection("entries")
- c.insert(doc)
+ if existing != None and (mode == "overwrite" or mode == "update"):
+ c.update(file_id, doc)
+ else:
+ c.insert(doc)
return respond(200, doc)
+# _renamed_path suffixes " (1)", " (2)"... before the extension until the
+# path is free — the autorename contract.
+def _renamed_path(path):
+ c = store_collection("entries")
+ stem = path
+ ext = ""
+ dot = path.rfind(".")
+ slash = path.rfind("/")
+ if dot > slash:
+ stem = path[:dot]
+ ext = path[dot:]
+ n = 1
+ while True:
+ candidate = stem + " (" + str(n) + ")" + ext
+ if _find_by_path(candidate) == None:
+ return candidate
+ n = n + 1
+
# POST /2/files/download — download file content.
#
# Body: {path} or {id}. Returns the raw file content with Content-Type
@@ -203,7 +250,9 @@ def on_list_folder(req):
page, next_cursor = _list_page(req, entries)
if page == None:
- return respond(400, {"error_summary": "invalid_cursor", "error": {".tag": "invalid_cursor"}})
+ # Real invalid_cursor carries the same summary+tag envelope as the
+ # 409s (list_folder/continue answers exactly this shape on a bad cursor).
+ return respond(400, _error("invalid_cursor"))
return respond(200, {
"entries": page,
"cursor": next_cursor if next_cursor != None else "",
diff --git a/adapters/dropbox_style_test.go b/adapters/dropbox_style_test.go
new file mode 100644
index 00000000..d440eea7
--- /dev/null
+++ b/adapters/dropbox_style_test.go
@@ -0,0 +1,732 @@
+package adapters
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the dropbox-style adapter scripts directly (lib.star preloaded)
+// over a shared store and virtual clock. The API is RPC-style: all eight
+// routes are POST /2/{files|users}/{action} with the arguments in the JSON
+// body (no path params, no query strings), and every handler here is called
+// exactly that way. Covered: both upload request shapes (the documented
+// JSON {path, content} convenience form and the real Dropbox-API-Arg +
+// raw-body form), the Dropbox content_hash scheme under the virtual clock,
+// the path_prefix subtree listing with body-cursor paging, raw octet-stream
+// downloads by path and id, the 409 path/* and 401 access-token error
+// envelopes, and the permanent cascading folder delete audited through the
+// internal trash tombstones.
+
+const (
+ dropboxHost = "api.dropbox.test"
+ dropboxToken = "sl.test_token_mock" // README's seeded static mock token
+)
+
+type dropboxFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ store *primitives.Store
+ kv *kv.KV
+ host string
+}
+
+func newDropboxFixture(t *testing.T, start time.Time) *dropboxFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "dropbox-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, err := primitives.Open(filepath.Join(tmp, "s.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { store.Close() })
+ kvStore, err := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, err := blob.Open(filepath.Join(tmp, "blobs"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+
+ // Seed the entries collection like the engine does on boot (the one
+ // synthetic seed folder the adapter ships).
+ entries, err := store.Collection("entries")
+ if err != nil {
+ t.Fatalf("entries collection: %v", err)
+ }
+ if err := entries.Seed(filepath.Join(root, "fixtures", "entries.jsonl")); err != nil {
+ t.Fatalf("seed entries: %v", err)
+ }
+ if _, err := store.Collection("trash"); err != nil {
+ t.Fatalf("trash collection: %v", err)
+ }
+
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &dropboxFixture{
+ t: t, vc: vc, store: store, kv: kvStore, host: dropboxHost,
+ vms: map[string]*starlark.VM{"files": load("files.star"), "users": load("users.star")},
+ }
+}
+
+// call invokes one of the eight POST /2/... RPC handlers. Every argument an
+// RPC handler reads lives in the body (round-tripped through JSON so numbers
+// arrive as floats, the wire shape); query is only ever ignored. auth is the
+// exact Authorization header value ("" omits the header).
+func (f *dropboxFixture) call(group, handler, route string, query map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ var wire map[string]any
+ if body != nil {
+ wire = wireBody(f.t, body)
+ }
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: "POST", Path: route, Host: f.host, Headers: headers,
+ Body: wire, Params: map[string]string{}, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, route, err)
+ }
+ return resp
+}
+
+// uploadRaw drives on_upload in the real request shape: the file arguments
+// in the Dropbox-API-Arg header (a JSON string) and the raw file bytes as
+// the octet-stream request body. apiArg is used verbatim, so malformed
+// values exercise the never-raise path.
+func (f *dropboxFixture) uploadRaw(apiArg, raw, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{
+ "Content-Type": "application/octet-stream",
+ "Dropbox-API-Arg": apiArg,
+ }
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vms["files"].Call("on_upload", starlark.Request{
+ Method: "POST", Path: "/2/files/upload", Host: f.host, Headers: headers,
+ Params: map[string]string{}, RawBody: raw,
+ })
+ if err != nil {
+ f.t.Fatalf("on_upload raw: %v", err)
+ }
+ return resp
+}
+
+// dropboxNum coerces a response number to float64: freshly built docs carry
+// int64 while collection round-trips (get_metadata, listings) come back as
+// JSON floats.
+func dropboxNum(t *testing.T, v any) float64 {
+ t.Helper()
+ switch n := v.(type) {
+ case int64:
+ return float64(n)
+ case float64:
+ return n
+ default:
+ t.Fatalf("value %v (%T) is not a number", v, v)
+ return 0
+ }
+}
+
+// dropboxPaths returns the path_lower of every entry in a list_folder
+// response, in server order.
+func dropboxPaths(t *testing.T, r starlark.Response) []string {
+ t.Helper()
+ entries, ok := r.Body["entries"].([]any)
+ if !ok {
+ t.Fatalf("entries = %v (%T), want list", r.Body["entries"], r.Body["entries"])
+ }
+ out := make([]string, 0, len(entries))
+ for _, e := range entries {
+ p, _ := e.(map[string]any)["path_lower"].(string)
+ out = append(out, p)
+ }
+ return out
+}
+
+// dropboxWantErr asserts the simplified Dropbox error envelope: an
+// error_summary of "/.." and a nested error .tag of exactly "".
+func dropboxWantErr(t *testing.T, r starlark.Response, status int, tag string) {
+ t.Helper()
+ if r.Status != status {
+ t.Fatalf("status = %d, want %d (body %v)", r.Status, status, r.Body)
+ }
+ if r.Body["error_summary"] != tag+"/.." {
+ t.Fatalf("error_summary = %v, want %q", r.Body["error_summary"], tag+"/..")
+ }
+ errObj, ok := r.Body["error"].(map[string]any)
+ if !ok || errObj[".tag"] != tag {
+ t.Fatalf("error = %v, want nested {\".tag\": %q}", r.Body["error"], tag)
+ }
+}
+
+// dropboxStyleContentHash mirrors the adapter's content hash: Dropbox's own
+// scheme of SHA-256 over the concatenated per-4MiB-block SHA-256 digests.
+func dropboxStyleContentHash(b []byte) string {
+ const block = 4 << 20
+ h := sha256.New()
+ for i := 0; i == 0 || i < len(b); i += block {
+ end := i + block
+ if end > len(b) {
+ end = len(b)
+ }
+ d := sha256.Sum256(b[i:end])
+ h.Write(d[:])
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// TestDropboxUploadForms: the documented JSON {path, content} convenience
+// deviation, the real Dropbox-API-Arg + raw-body upload form, and the
+// mode:"add" conflict an existing path now answers with.
+func TestDropboxUploadForms(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newDropboxFixture(t, base)
+
+ // ===== upload takes the JSON {path, content} convenience body (documented deviation) =====
+ // Real /2/files/upload posts raw bytes with the arguments in the
+ // Dropbox-API-Arg header; the mock also accepts this JSON form (see
+ // conformance/matrix.yaml) — asserted here as-is.
+ content := "dropbox-style vm suite notes"
+ up := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/VM Suite/notes.txt",
+ "content": content,
+ "client_modified": "2020-02-03T04:05:06Z",
+ }, "")
+ if up.Status != 200 {
+ t.Fatalf("JSON-form upload -> %d: %v", up.Status, up.Body)
+ }
+ if up.Body[".tag"] != "file" || up.Body["id"] != "id_1" {
+ t.Fatalf("upload doc = %v, want .tag file / id_1", up.Body)
+ }
+ if up.Body["name"] != "notes.txt" || up.Body["path_display"] != "/VM Suite/notes.txt" ||
+ up.Body["path_lower"] != "/vm suite/notes.txt" {
+ t.Fatalf("upload paths = %v", up.Body)
+ }
+ if got := dropboxNum(t, up.Body["size"]); got != float64(len(content)) {
+ t.Fatalf("size = %v, want %d", up.Body["size"], len(content))
+ }
+ if up.Body["client_modified"] != "2020-02-03T04:05:06Z" {
+ t.Fatalf("client_modified = %v, want the client-declared value echoed", up.Body["client_modified"])
+ }
+ if up.Body["server_modified"] != base.Format(time.RFC3339) {
+ t.Fatalf("server_modified = %v, want the live (virtual) clock %s",
+ up.Body["server_modified"], base.Format(time.RFC3339))
+ }
+ if up.Body["content_hash"] != dropboxStyleContentHash([]byte(content)) {
+ t.Fatalf("content_hash = %v, want the Go-computed Dropbox-scheme hash %s",
+ up.Body["content_hash"], dropboxStyleContentHash([]byte(content)))
+ }
+
+ // ===== the real RPC upload (Dropbox-API-Arg header + raw octet-stream body) lands identically =====
+ raw := "raw-binary-payload"
+ argUp := f.uploadRaw(`{"path": "/VM Suite/raw.bin", "client_modified": "2021-06-07T08:09:10Z"}`, raw, "")
+ if argUp.Status != 200 {
+ t.Fatalf("RPC-form upload -> %d: %v", argUp.Status, argUp.Body)
+ }
+ if argUp.Body["id"] != "id_2" || argUp.Body["name"] != "raw.bin" {
+ t.Fatalf("RPC-form upload doc = %v, want id_2 / raw.bin", argUp.Body)
+ }
+ if got := dropboxNum(t, argUp.Body["size"]); got != float64(len(raw)) {
+ t.Fatalf("RPC-form size = %v, want %d", argUp.Body["size"], len(raw))
+ }
+ if argUp.Body["content_hash"] != dropboxStyleContentHash([]byte(raw)) {
+ t.Fatalf("RPC-form content_hash = %v, want %s", argUp.Body["content_hash"], dropboxStyleContentHash([]byte(raw)))
+ }
+ // The stored bytes download back verbatim under the octet-stream type.
+ dl := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"path": "/VM Suite/raw.bin"}, "")
+ if dl.Status != 200 || dl.RawBody != raw {
+ t.Fatalf("download after RPC-form upload -> %d %q, want the raw bytes", dl.Status, dl.RawBody)
+ }
+ if ct := dl.Headers["Content-Type"]; ct != "application/octet-stream" {
+ t.Fatalf("download Content-Type = %q, want application/octet-stream", ct)
+ }
+ // A malformed Dropbox-API-Arg is untrusted header input: it must never
+ // raise, and with no path anywhere the flat path error answers.
+ badArg := f.uploadRaw("{not-json", "ignored-bytes", "")
+ dropboxWantErr(t, badArg, 409, "path")
+
+ // ===== re-uploading an existing path answers the real mode:"add" conflict =====
+ // Real upload defaults to mode "add" + autorename:false: an existing
+ // path is a conflict. The mock used to insert a second row at the same
+ // path, leaving the older row ahead of every later path read.
+ dup := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/VM Suite/notes.txt", "content": "different bytes entirely",
+ }, "")
+ dropboxWantErr(t, dup, 409, "path/conflict")
+ // Dropbox paths are case-insensitive (path_lower is the canonical key),
+ // so a differently-cased path is the same conflict.
+ ci := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/VM SUITE/NOTES.TXT", "content": "x",
+ }, "")
+ dropboxWantErr(t, ci, 409, "path/conflict")
+ // The original entry is untouched: same id, and exactly one notes row.
+ meta := f.call("files", "on_get_metadata", "/2/files/get_metadata", nil, map[string]any{"path": "/VM Suite/notes.txt"}, "")
+ if meta.Status != 200 || meta.Body["id"] != "id_1" {
+ t.Fatalf("metadata after conflicting upload -> %d %v, want the original id_1", meta.Status, meta.Body)
+ }
+ root := f.call("files", "on_list_folder", "/2/files/list_folder", nil, map[string]any{"path": ""}, "")
+ notes := 0
+ for _, p := range dropboxPaths(t, root) {
+ if p == "/vm suite/notes.txt" {
+ notes++
+ }
+ }
+ if notes != 1 || len(dropboxPaths(t, root)) != 3 { // seed folder + notes + raw.bin
+ t.Fatalf("root after conflicting upload = %v, want exactly one notes row of 3", dropboxPaths(t, root))
+ }
+
+ // ===== mode overwrite replaces in place; autorename forks a suffixed path =====
+ // The default conflict is mode-specific: SDK clients re-upload via
+ // WriteMode.overwrite, and autorename is the escape hatch for adds.
+ over := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/VM Suite/notes.txt", "content": "replaced", "mode": map[string]any{".tag": "overwrite"},
+ }, "")
+ if over.Status != 200 || over.Body["id"] != "id_1" {
+ t.Fatalf("overwrite upload -> %d %v, want 200 replacing id_1 in place", over.Status, over.Body)
+ }
+ if got := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"path": "/VM Suite/notes.txt"}, ""); got.Status != 200 || got.RawBody != "replaced" {
+ t.Fatalf("download after overwrite -> %d %q, want the replaced bytes", got.Status, got.RawBody)
+ }
+ auto := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/VM Suite/notes.txt", "content": "forked", "autorename": true,
+ }, "")
+ if auto.Status != 200 || auto.Body["path_display"] != "/VM Suite/notes (1).txt" {
+ t.Fatalf("autorename upload -> %d %v, want the (1)-suffixed path", auto.Status, auto.Body)
+ }
+}
+
+// TestDropboxListFolderSubtreeAndPaging: the whole-subtree listing deviation,
+// the root/missing/file-path listing errors, and body-cursor paging applied
+// after the path filter.
+func TestDropboxListFolderSubtreeAndPaging(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newDropboxFixture(t, base)
+
+ must200 := func(handler, route string, body map[string]any) starlark.Response {
+ t.Helper()
+ r := f.call("files", handler, route, nil, body, "")
+ if r.Status != 200 {
+ t.Fatalf("%s %v -> %d: %v", handler, body, r.Status, r.Body)
+ }
+ return r
+ }
+ must200("on_create_folder", "/2/files/create_folder", map[string]any{"path": "/Projects"}) // id_1
+ must200("on_create_folder", "/2/files/create_folder", map[string]any{"path": "/Projects/Archive"}) // id_2
+ must200("on_upload", "/2/files/upload", map[string]any{"path": "/Projects/plan.txt", "content": "cascade plan"})
+ must200("on_upload", "/2/files/upload", map[string]any{"path": "/Projects/Archive/2019.txt", "content": "old stuff"})
+ must200("on_upload", "/2/files/upload", map[string]any{"path": "/root.txt", "content": "root level"})
+
+ // ===== list_folder returns the whole path-prefix subtree, not one level =====
+ // Real list_folder returns only the direct children; the mock returns
+ // the entire subtree at once (documented deviation) — including the
+ // folder itself and depth-2 entries.
+ sub := must200("on_list_folder", "/2/files/list_folder", map[string]any{"path": "/Projects"})
+ got := dropboxPaths(t, sub)
+ want := []string{"/projects", "/projects/archive", "/projects/plan.txt", "/projects/archive/2019.txt"}
+ if len(got) != len(want) {
+ t.Fatalf("subtree = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("subtree[%d] = %q, want %q (full: %v)", i, got[i], want[i], got)
+ }
+ }
+ if sub.Body["has_more"] != false || sub.Body["cursor"] != "" {
+ t.Fatalf("unpaged envelope = %v %v, want has_more false / empty cursor", sub.Body["has_more"], sub.Body["cursor"])
+ }
+ // The path lookup is case-insensitive, like path_lower itself.
+ subCI := must200("on_list_folder", "/2/files/list_folder", map[string]any{"path": "/projects"})
+ if p := dropboxPaths(t, subCI); len(p) != 4 || p[3] != "/projects/archive/2019.txt" {
+ t.Fatalf("case-insensitive subtree = %v, want the same 4 entries", p)
+ }
+
+ // ===== the root listing spans everything; unknown and file paths carry distinct 409 tags =====
+ root := must200("on_list_folder", "/2/files/list_folder", map[string]any{"path": ""})
+ if p := dropboxPaths(t, root); len(p) != 6 { // seed + 5 created
+ t.Fatalf("root listing = %v, want 6 entries", p)
+ }
+ slash := must200("on_list_folder", "/2/files/list_folder", map[string]any{"path": "/"})
+ if p := dropboxPaths(t, slash); len(p) != 6 {
+ t.Fatalf("\"/\" listing = %v, want the same 6 entries as \"\"", p)
+ }
+ missing := f.call("files", "on_list_folder", "/2/files/list_folder", nil, map[string]any{"path": "/nope"}, "")
+ dropboxWantErr(t, missing, 409, "path/not_found")
+ fileArg := f.call("files", "on_list_folder", "/2/files/list_folder", nil, map[string]any{"path": "/root.txt"}, "")
+ dropboxWantErr(t, fileArg, 409, "path/not_folder")
+
+ // ===== paging slices the filtered subtree by body cursor, ignoring query strings =====
+ // The /2/ RPC style reads its arguments from the JSON body: a REST-style
+ // ?limit=1 query is not even looked at.
+ qOnly := f.call("files", "on_list_folder", "/2/files/list_folder",
+ map[string]string{"limit": "1"}, map[string]any{"path": "/Projects"}, "")
+ if p := dropboxPaths(t, qOnly); len(p) != 4 {
+ t.Fatalf("query-string limit was honored (%v) — paging must read the body", p)
+ }
+ p1 := must200("on_list_folder", "/2/files/list_folder", map[string]any{"path": "/Projects", "limit": 2})
+ if p := dropboxPaths(t, p1); len(p) != 2 || p[0] != "/projects" || p[1] != "/projects/archive" {
+ t.Fatalf("page 1 = %v, want the first two subtree entries", p)
+ }
+ if p1.Body["has_more"] != true || p1.Body["cursor"] != "2" {
+ t.Fatalf("page 1 envelope = %v %v, want has_more true / cursor \"2\"", p1.Body["has_more"], p1.Body["cursor"])
+ }
+ p2 := must200("on_list_folder", "/2/files/list_folder",
+ map[string]any{"path": "/Projects", "limit": 2, "cursor": "2"})
+ if p := dropboxPaths(t, p2); len(p) != 2 || p[0] != "/projects/plan.txt" || p[1] != "/projects/archive/2019.txt" {
+ t.Fatalf("page 2 = %v, want the last two subtree entries", p)
+ }
+ if p2.Body["has_more"] != false || p2.Body["cursor"] != "" {
+ t.Fatalf("final page envelope = %v %v, want has_more false / empty cursor", p2.Body["has_more"], p2.Body["cursor"])
+ }
+ // The two pages cover the subtree exactly once.
+ seen := map[string]int{}
+ for _, p := range append(dropboxPaths(t, p1), dropboxPaths(t, p2)...) {
+ seen[p]++
+ }
+ if len(seen) != 4 {
+ t.Fatalf("paged union = %v, want the 4 distinct subtree paths", seen)
+ }
+ for _, n := range seen {
+ if n != 1 {
+ t.Fatalf("paged union repeats a path: %v", seen)
+ }
+ }
+ // A garbage cursor gets the real 400 invalid_cursor envelope (same
+ // summary+tag shape as the 409s).
+ badCursor := f.call("files", "on_list_folder", "/2/files/list_folder", nil,
+ map[string]any{"path": "/Projects", "limit": 2, "cursor": "abc"}, "")
+ dropboxWantErr(t, badCursor, 400, "invalid_cursor")
+}
+
+// TestDropboxReadsAndErrorEnvelopes: octet-stream downloads by path and id,
+// the 409 path envelope across declined reads, and the synthetic temp link.
+func TestDropboxReadsAndErrorEnvelopes(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newDropboxFixture(t, base)
+
+ content := "quarterly numbers"
+ up := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/reports/q3.txt", "content": content,
+ }, "")
+ if up.Status != 200 {
+ t.Fatalf("upload -> %d: %v", up.Status, up.Body)
+ }
+ fileID, _ := up.Body["id"].(string)
+
+ // ===== download streams raw bytes by path and by id, with metadata alongside =====
+ byPath := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"path": "/reports/q3.txt"}, "")
+ if byPath.Status != 200 || byPath.RawBody != content {
+ t.Fatalf("download by path -> %d %q, want the raw content", byPath.Status, byPath.RawBody)
+ }
+ if ct := byPath.Headers["Content-Type"]; ct != "application/octet-stream" {
+ t.Fatalf("download Content-Type = %q, want application/octet-stream (no JSON envelope)", ct)
+ }
+ byID := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"id": fileID}, "")
+ if byID.Status != 200 || byID.RawBody != content {
+ t.Fatalf("download by id -> %d %q, want the same raw content", byID.Status, byID.RawBody)
+ }
+ // Metadata persists through the collection with the upload's fields.
+ meta := f.call("files", "on_get_metadata", "/2/files/get_metadata", nil, map[string]any{"path": "/reports/q3.txt"}, "")
+ if meta.Status != 200 || meta.Body["id"] != fileID || meta.Body["name"] != "q3.txt" {
+ t.Fatalf("get_metadata -> %d %v, want the persisted file doc", meta.Status, meta.Body)
+ }
+ if got := dropboxNum(t, meta.Body["size"]); got != float64(len(content)) {
+ t.Fatalf("persisted size = %v, want %d", meta.Body["size"], len(content))
+ }
+ if meta.Body["content_hash"] != dropboxStyleContentHash([]byte(content)) {
+ t.Fatalf("persisted content_hash = %v", meta.Body["content_hash"])
+ }
+
+ // ===== folders and unknown paths decline under the 409 path envelope =====
+ folder := f.call("files", "on_create_folder", "/2/files/create_folder", nil, map[string]any{"path": "/Dossier"}, "")
+ if folder.Status != 200 || folder.Body[".tag"] != "folder" {
+ t.Fatalf("create folder -> %d %v", folder.Status, folder.Body)
+ }
+ dlFolder := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"path": "/Dossier"}, "")
+ dropboxWantErr(t, dlFolder, 409, "path/disallowed")
+ dlMissing := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"path": "/gone.txt"}, "")
+ dropboxWantErr(t, dlMissing, 409, "path/not_found")
+ metaMissing := f.call("files", "on_get_metadata", "/2/files/get_metadata", nil, map[string]any{"path": "/gone.txt"}, "")
+ dropboxWantErr(t, metaMissing, 409, "path/not_found")
+ // A request with no path at all gets the bare "path" tag, not a crash.
+ noPath := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{}, "")
+ dropboxWantErr(t, noPath, 409, "path")
+ noFolderPath := f.call("files", "on_create_folder", "/2/files/create_folder", nil, map[string]any{}, "")
+ dropboxWantErr(t, noFolderPath, 409, "path")
+ delMissing := f.call("files", "on_delete", "/2/files/delete", nil, map[string]any{"path": "/gone.txt"}, "")
+ dropboxWantErr(t, delMissing, 409, "path/not_found")
+
+ // ===== get_temporary_link pairs the file's metadata with the synthetic link =====
+ link := f.call("files", "on_get_temporary_link", "/2/files/get_temporary_link", nil, map[string]any{"path": "/reports/q3.txt"}, "")
+ if link.Status != 200 {
+ t.Fatalf("get_temporary_link -> %d: %v", link.Status, link.Body)
+ }
+ // The URL is synthetic and does not serve the content (documented
+ // deviation) — asserted verbatim.
+ if link.Body["link"] != "https://dl.dropboxusercontent.com/synthetic-temporary-link" {
+ t.Fatalf("link = %v, want the synthetic URL", link.Body["link"])
+ }
+ if md, ok := link.Body["metadata"].(map[string]any); !ok || md["id"] != fileID {
+ t.Fatalf("link metadata = %v, want the full file doc for %s", link.Body["metadata"], fileID)
+ }
+ linkFolder := f.call("files", "on_get_temporary_link", "/2/files/get_temporary_link", nil, map[string]any{"path": "/Dossier"}, "")
+ dropboxWantErr(t, linkFolder, 409, "path/disallowed")
+ linkMissing := f.call("files", "on_get_temporary_link", "/2/files/get_temporary_link", nil, map[string]any{"path": "/gone.txt"}, "")
+ dropboxWantErr(t, linkMissing, 409, "path/not_found")
+}
+
+// TestDropboxAuthGate: the bearer validation the adapter applies only when a
+// token is presented, and the documented no-header openness.
+func TestDropboxAuthGate(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newDropboxFixture(t, base)
+
+ // ===== a presented bearer must be registered: unknown and expired tokens get distinct 401 tags =====
+ // An unknown token is invalid on every surface, in Dropbox's envelope.
+ unknown := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/x.txt", "content": "x",
+ }, "Bearer sl.not_a_real_token")
+ dropboxWantErr(t, unknown, 401, "invalid_access_token")
+ unknownAcct := f.call("users", "on_get_current_account", "/2/users/get_current_account", nil,
+ map[string]any{}, "Bearer sl.not_a_real_token")
+ dropboxWantErr(t, unknownAcct, 401, "invalid_access_token")
+ // A registered but past-expiry token flips to the expired tag. Tokens
+ // live in the KV store as token_ -> unix-seconds expiry.
+ if err := f.kv.Set("dropbox", "token_sl.stale_token", "1000"); err != nil {
+ t.Fatalf("seed stale token: %v", err)
+ }
+ expired := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/x.txt", "content": "x",
+ }, "Bearer sl.stale_token")
+ dropboxWantErr(t, expired, 401, "expired_access_token")
+ // The README's static mock token is seeded on first use and passes.
+ static := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/authed.txt", "content": "authed",
+ }, "Bearer "+dropboxToken)
+ if static.Status != 200 {
+ t.Fatalf("static mock token -> %d: %v", static.Status, static.Body)
+ }
+
+ // ===== an absent Authorization header stays open (documented deviation) =====
+ // Real Dropbox requires auth on every route; the mock accepts requests
+ // with no Authorization header at all (documented deviation, kept for
+ // the shared engine test helpers).
+ open := f.call("users", "on_get_current_account", "/2/users/get_current_account", nil, map[string]any{}, "")
+ if open.Status != 200 || open.Body["account_id"] == nil {
+ t.Fatalf("no-header account -> %d %v, want 200", open.Status, open.Body)
+ }
+ openUp := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/open.txt", "content": "no auth header",
+ }, "")
+ if openUp.Status != 200 {
+ t.Fatalf("no-header upload -> %d: %v", openUp.Status, openUp.Body)
+ }
+}
+
+// TestDropboxFolderAndAccount: folder creation with the case-insensitive
+// conflict, and the synthetic /2/users account snapshot.
+func TestDropboxFolderAndAccount(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newDropboxFixture(t, base)
+
+ // ===== create_folder mints folder metadata and conflicts case-insensitively =====
+ cf := f.call("files", "on_create_folder", "/2/files/create_folder", nil, map[string]any{"path": "/Reports"}, "")
+ if cf.Status != 200 {
+ t.Fatalf("create_folder -> %d: %v", cf.Status, cf.Body)
+ }
+ if cf.Body[".tag"] != "folder" || cf.Body["id"] != "id_1" || cf.Body["name"] != "Reports" {
+ t.Fatalf("folder doc = %v, want .tag folder / id_1 / Reports", cf.Body)
+ }
+ if cf.Body["path_lower"] != "/reports" || cf.Body["path_display"] != "/Reports" {
+ t.Fatalf("folder paths = %v", cf.Body)
+ }
+ if cf.Body["server_modified"] != base.Format(time.RFC3339) {
+ t.Fatalf("folder server_modified = %v, want the clock's %s",
+ cf.Body["server_modified"], base.Format(time.RFC3339))
+ }
+ // Folders carry no file fields.
+ for _, k := range []string{"size", "content_hash", "client_modified"} {
+ if _, has := cf.Body[k]; has {
+ t.Fatalf("folder doc carries file field %q: %v", k, cf.Body)
+ }
+ }
+ dup := f.call("files", "on_create_folder", "/2/files/create_folder", nil, map[string]any{"path": "/Reports"}, "")
+ dropboxWantErr(t, dup, 409, "path/conflict")
+ // The case-insensitive namespace: /REPORTS is the same folder.
+ ci := f.call("files", "on_create_folder", "/2/files/create_folder", nil, map[string]any{"path": "/REPORTS"}, "")
+ dropboxWantErr(t, ci, 409, "path/conflict")
+
+ // ===== get_current_account returns the synthetic /2/users snapshot =====
+ acct := f.call("users", "on_get_current_account", "/2/users/get_current_account", nil, map[string]any{}, "")
+ if acct.Status != 200 {
+ t.Fatalf("get_current_account -> %d: %v", acct.Status, acct.Body)
+ }
+ if acct.Body["account_id"] != "dbid:synthetic-local-test-account" ||
+ acct.Body["email"] != "test-user@example.local" ||
+ acct.Body["email_verified"] != true || acct.Body["country"] != "US" || acct.Body["locale"] != "en" {
+ t.Fatalf("account = %v, want the synthetic snapshot", acct.Body)
+ }
+ name, ok := acct.Body["name"].(map[string]any)
+ if !ok || name["given_name"] != "Local" || name["surname"] != "Test User" ||
+ name["display_name"] != "Local Test User" || name["abbreviated_name"] != "LT" {
+ t.Fatalf("account name = %v, want all five name fields", acct.Body["name"])
+ }
+}
+
+// TestDropboxCascadeDeleteAndTrash: the permanent, cascading folder delete —
+// visible removal from every read path, the trash tombstone audit trail, and
+// the no-restore freshness of re-created paths.
+func TestDropboxCascadeDeleteAndTrash(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newDropboxFixture(t, base)
+
+ must200 := func(handler, route string, body map[string]any) map[string]any {
+ t.Helper()
+ r := f.call("files", handler, route, nil, body, "")
+ if r.Status != 200 {
+ t.Fatalf("%s %v -> %d: %v", handler, body, r.Status, r.Body)
+ }
+ return r.Body
+ }
+ proj := must200("on_create_folder", "/2/files/create_folder", map[string]any{"path": "/Projects"}) // id_1
+ archive := must200("on_create_folder", "/2/files/create_folder", map[string]any{"path": "/Projects/Archive"}) // id_2
+ plan := must200("on_upload", "/2/files/upload", map[string]any{"path": "/Projects/plan.txt", "content": "cascade plan"})
+ old := must200("on_upload", "/2/files/upload", map[string]any{"path": "/Projects/Archive/2019.txt", "content": "old stuff"})
+ keep := must200("on_upload", "/2/files/upload", map[string]any{"path": "/keep-me.txt", "content": "survivor"})
+
+ // The delete lands at a known later clock time so the tombstone stamps
+ // are assertable.
+ f.vc.Advance(2 * time.Hour)
+ delAt := base.Add(2 * time.Hour).Format(time.RFC3339)
+
+ // ===== deleting a folder removes its entire subtree from every read path =====
+ del := f.call("files", "on_delete", "/2/files/delete", nil, map[string]any{"path": "/Projects"}, "")
+ if del.Status != 200 || del.Body["id"] != proj["id"] || del.Body[".tag"] != "folder" {
+ t.Fatalf("delete -> %d %v, want 200 echoing the folder doc", del.Status, del.Body)
+ }
+ for _, path := range []string{
+ "/Projects", "/Projects/Archive", "/Projects/plan.txt", "/Projects/Archive/2019.txt",
+ } {
+ r := f.call("files", "on_get_metadata", "/2/files/get_metadata", nil, map[string]any{"path": path}, "")
+ dropboxWantErr(t, r, 409, "path/not_found")
+ }
+ for _, id := range []any{plan["id"], old["id"]} {
+ r := f.call("files", "on_download", "/2/files/download", nil, map[string]any{"id": id.(string)}, "")
+ dropboxWantErr(t, r, 409, "path/not_found")
+ }
+ // The deleted folder itself cannot be listed anymore.
+ listGone := f.call("files", "on_list_folder", "/2/files/list_folder", nil, map[string]any{"path": "/Projects"}, "")
+ dropboxWantErr(t, listGone, 409, "path/not_found")
+ // The root keeps the seed folder and the sibling — nothing else leaks.
+ root := f.call("files", "on_list_folder", "/2/files/list_folder", nil, map[string]any{"path": ""}, "")
+ if p := dropboxPaths(t, root); len(p) != 2 || p[0] != "/seed folder" || p[1] != "/keep-me.txt" {
+ t.Fatalf("root after cascade = %v, want only the seed folder and /keep-me.txt", p)
+ }
+ _ = archive
+
+ // ===== trash tombstones audit the exact cascade batch =====
+ // Every removed row lands in the internal trash collection exactly once,
+ // stamped with the delete time and the batch root that took it out.
+ trashCol, err := f.store.Collection("trash")
+ if err != nil {
+ t.Fatalf("trash collection: %v", err)
+ }
+ tombs, err := trashCol.List()
+ if err != nil {
+ t.Fatalf("trash list: %v", err)
+ }
+ if len(tombs) != 4 {
+ t.Fatalf("trash has %d tombstones, want the 4 cascaded entries", len(tombs))
+ }
+ wantIDs := map[string]bool{
+ proj["id"].(string): true, archive["id"].(string): true,
+ plan["id"].(string): true, old["id"].(string): true,
+ }
+ for _, tomb := range tombs {
+ id, _ := tomb["id"].(string)
+ if !wantIDs[id] {
+ t.Fatalf("tombstone for unexpected id %q (want %v)", id, wantIDs)
+ }
+ delete(wantIDs, id)
+ if tomb["_deleted_at"] != delAt {
+ t.Fatalf("tombstone %s _deleted_at = %v, want %s", id, tomb["_deleted_at"], delAt)
+ }
+ if tomb["_batch_root"] != proj["id"] {
+ t.Fatalf("tombstone %s _batch_root = %v, want the deleted root %v",
+ id, tomb["_batch_root"], proj["id"])
+ }
+ }
+ if len(wantIDs) != 0 {
+ t.Fatalf("cascade missed entries: %v", wantIDs)
+ }
+ // The entries collection itself holds only the seed folder and the
+ // survivor — no orphans left under the deleted parent.
+ entriesCol, err := f.store.Collection("entries")
+ if err != nil {
+ t.Fatalf("entries collection: %v", err)
+ }
+ n, err := entriesCol.Count()
+ if err != nil {
+ t.Fatalf("entries count: %v", err)
+ }
+ if n != 2 {
+ t.Fatalf("entries count after cascade = %d, want 2 (seed + /keep-me.txt)", n)
+ }
+ _ = keep
+
+ // ===== delete is permanent: a re-created path is a brand-new entry =====
+ fresh := f.call("files", "on_upload", "/2/files/upload", nil, map[string]any{
+ "path": "/Projects/plan.txt", "content": "new plan",
+ }, "")
+ if fresh.Status != 200 {
+ t.Fatalf("re-upload at deleted path -> %d: %v", fresh.Status, fresh.Body)
+ }
+ if fresh.Body["id"] == plan["id"] || fresh.Body["content_hash"] == plan["content_hash"] {
+ t.Fatalf("re-upload reused the deleted entry: %v", fresh.Body)
+ }
+ // A single-file delete still works standalone, and stays deleted.
+ single := f.call("files", "on_delete", "/2/files/delete", nil, map[string]any{"path": "/keep-me.txt"}, "")
+ if single.Status != 200 || single.Body["id"] != keep["id"] {
+ t.Fatalf("single delete -> %d %v", single.Status, single.Body)
+ }
+ after := f.call("files", "on_get_metadata", "/2/files/get_metadata", nil, map[string]any{"path": "/keep-me.txt"}, "")
+ dropboxWantErr(t, after, 409, "path/not_found")
+}
diff --git a/adapters/dune-style/scripts/lib.star b/adapters/dune-style/scripts/lib.star
index 74f145ff..22af19a1 100644
--- a/adapters/dune-style/scripts/lib.star
+++ b/adapters/dune-style/scripts/lib.star
@@ -283,14 +283,17 @@ def _csv_field(v):
# _next_uri mints the absolute continuation URL Dune returns when more rows
# remain beyond the requested page (offset carries the position, limit the
# page size). The host is the simulator's own request host so clients can
-# follow the URL directly.
+# follow the URL directly; behind a TLS proxy the forwarded proto is
+# honored so the absolute URL stays followable.
def _next_uri(req, exec_id, offset, limit):
host = req.get("host", "")
if host == None:
host = ""
scheme = req.get("headers", {}).get("x-forwarded-proto", "")
if scheme == None or scheme == "":
- scheme = "http" if host.startswith("127.0.0.1") or host.startswith("localhost") else "http"
+ # direct simulator traffic is plain http — never guess https, the
+ # local listener does not speak it
+ scheme = "http"
return (scheme + "://" + host + "/api/v1/execution/" + exec_id
+ "/results?offset=" + str(offset) + "&limit=" + str(limit))
diff --git a/adapters/dune_style_test.go b/adapters/dune_style_test.go
new file mode 100644
index 00000000..e6e78127
--- /dev/null
+++ b/adapters/dune_style_test.go
@@ -0,0 +1,403 @@
+package adapters
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the dune-style adapter scripts directly (lib.star preloaded) over a
+// shared store and virtual clock: the API-key gate, the derive-on-read
+// execution lifecycle (PENDING -> EXECUTING -> COMPLETED / FAILED), the
+// query-parameter model behind the 400 envelope, results paging with the
+// followable next_uri, the CSV variant, and the inline-result route.
+const duneAuth = "Bearer dune-api-key"
+
+type duneFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+}
+
+func newDuneFixture(t *testing.T, start time.Time) *duneFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "dune-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &duneFixture{t: t, vc: vc, vms: map[string]*starlark.VM{
+ "exec": load("execute.star"), "auth": load("auth.star"),
+ }}
+}
+
+func (f *duneFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ return f.callHdrs(group, handler, method, path, params, query, body, auth, nil)
+}
+
+// callHdrs is call plus extra request headers (e.g. X-Forwarded-Proto for a
+// request that arrived through a TLS proxy).
+func (f *duneFixture) callHdrs(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string, extra map[string]string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ for k, v := range extra {
+ headers[k] = v
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: "api.dune.test", Headers: headers, Body: body, Params: params, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// execute POSTs an execution for queryID and returns the landed execution_id.
+func (f *duneFixture) execute(queryID string, body map[string]any) string {
+ f.t.Helper()
+ r := f.call("exec", "on_execute", "POST", "/api/v1/query/"+queryID+"/execute",
+ map[string]string{"query_id": queryID}, nil, body, duneAuth)
+ if r.Status != 200 {
+ f.t.Fatalf("execute %s -> %d: %v", queryID, r.Status, r.Body)
+ }
+ id, _ := r.Body["execution_id"].(string)
+ if id == "" {
+ f.t.Fatalf("execute %s: no execution_id: %v", queryID, r.Body)
+ }
+ return id
+}
+
+func (f *duneFixture) status(execID string) map[string]any {
+ f.t.Helper()
+ r := f.call("exec", "on_get_status", "GET", "/api/v1/execution/"+execID+"/status",
+ map[string]string{"execution_id": execID}, nil, nil, duneAuth)
+ if r.Status != 200 {
+ f.t.Fatalf("status %s -> %d: %v", execID, r.Status, r.Body)
+ }
+ return r.Body
+}
+
+func (f *duneFixture) results(execID string, query map[string]string) starlark.Response {
+ f.t.Helper()
+ return f.call("exec", "on_get_results", "GET", "/api/v1/execution/"+execID+"/results",
+ map[string]string{"execution_id": execID}, query, nil, duneAuth)
+}
+
+func TestDuneAuthAndExecutionLifecycle(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newDuneFixture(t, base)
+
+ // ===== the api-key gate rejects missing, empty and non-bearer authorization =====
+ // The gate is presence-only (any non-empty bearer validates, per the
+ // manifest's uniform token_scheme); everything else is the 401 envelope.
+ if r := f.call("auth", "on_validate", "GET", "/api/v1/auth/validate", nil, nil, nil, ""); r.Status != 401 || r.Body["error"] != "Invalid API key" {
+ t.Fatalf("validate without auth -> %d %v, want 401 {error: Invalid API key}", r.Status, r.Body)
+ }
+ if r := f.call("auth", "on_validate", "GET", "/api/v1/auth/validate", nil, nil, nil, "Bearer "); r.Status != 401 {
+ t.Fatalf("validate with empty bearer -> %d, want 401", r.Status)
+ }
+ if r := f.call("auth", "on_validate", "GET", "/api/v1/auth/validate", nil, nil, nil, "Basic something"); r.Status != 401 {
+ t.Fatalf("validate with non-bearer auth -> %d, want 401", r.Status)
+ }
+ ok := f.call("auth", "on_validate", "GET", "/api/v1/auth/validate", nil, nil, nil, "Bearer any-token-at-all")
+ if ok.Status != 200 || ok.Body["valid"] != true {
+ t.Fatalf("validate with any bearer -> %d %v, want 200 {valid: true}", ok.Status, ok.Body)
+ }
+
+ // ===== an execution walks PENDING -> EXECUTING -> COMPLETED as the clock advances =====
+ // Results stay 404 until the poll derives completion; the stamps and the
+ // 35-day result retention are derived from the injectable clock.
+ execID := f.execute("12345", map[string]any{"query_parameters": map[string]any{}})
+ atStart := f.status(execID)
+ if atStart["state"] != "QUERY_STATE_PENDING" || atStart["is_execution_finished"] != false {
+ t.Fatalf("status at t=0 = %v", atStart)
+ }
+ if atStart["execution_started_at"] != nil || atStart["execution_ended_at"] != nil {
+ t.Fatalf("pending stamps = %v / %v, want null/null", atStart["execution_started_at"], atStart["execution_ended_at"])
+ }
+ duneNum(t, atStart["query_id"], 12345, "pending query_id")
+ if r := f.results(execID, nil); r.Status != 404 || r.Body["error"] != "Execution is not completed yet" {
+ t.Fatalf("results while running -> %d %v, want 404 not-completed-yet", r.Status, r.Body)
+ }
+
+ f.vc.Advance(2 * time.Second) // 2s: inside the 1s..3s executing window
+ running := f.status(execID)
+ if running["state"] != "QUERY_STATE_EXECUTING" || running["is_execution_finished"] != false {
+ t.Fatalf("status at 2s = %v, want EXECUTING", running)
+ }
+ if got, _ := running["execution_started_at"].(string); got != base.Add(1*time.Second).Format(time.RFC3339) {
+ t.Fatalf("executing execution_started_at = %v, want %s", running["execution_started_at"], base.Add(1*time.Second).Format(time.RFC3339))
+ }
+ if running["execution_ended_at"] != nil {
+ t.Fatalf("executing execution_ended_at = %v, want null", running["execution_ended_at"])
+ }
+
+ f.vc.Advance(2 * time.Second) // 4s: past the 3s window
+ done := f.status(execID)
+ if done["state"] != "QUERY_STATE_COMPLETED" || done["is_execution_finished"] != true {
+ t.Fatalf("status at 4s = %v, want COMPLETED / finished", done)
+ }
+ if got, _ := done["execution_ended_at"].(string); got != base.Add(3*time.Second).Format(time.RFC3339) {
+ t.Fatalf("completed execution_ended_at = %v, want %s", done["execution_ended_at"], base.Add(3*time.Second).Format(time.RFC3339))
+ }
+ if got, _ := done["expires_at"].(string); got != base.Add(3*time.Second).Add(35*24*time.Hour).Format(time.RFC3339) {
+ t.Fatalf("completed expires_at = %v, want finish + 35d retention", done["expires_at"])
+ }
+ if r := f.results(execID, nil); r.Status != 200 {
+ t.Fatalf("results after completion -> %d: %v", r.Status, r.Body)
+ }
+
+ // ===== a missing required parameter is the 400 envelope and both SDK parameter shapes resolve =====
+ // Query 4242 declares wallet_address as required TEXT; Dune accepts both
+ // the plain value and the SDK {type, value} object for it.
+ if r := f.call("exec", "on_execute", "POST", "/api/v1/query/4242/execute",
+ map[string]string{"query_id": "4242"}, nil, map[string]any{"query_parameters": map[string]any{}}, duneAuth); r.Status != 400 || r.Body["error"] != "Bad Request" {
+ t.Fatalf("execute without required param -> %d %v, want 400 {error: Bad Request}", r.Status, r.Body)
+ }
+ sdkShape := f.execute("4242", map[string]any{"query_parameters": map[string]any{
+ "wallet_address": map[string]any{"type": "TEXT", "value": "0xAbC123dEf456"}, "min_usd": 250,
+ }})
+ plainShape := f.execute("4242", map[string]any{"query_parameters": map[string]any{
+ "wallet_address": "0xFeD987aBc321", "min_usd": 250,
+ }})
+ f.vc.Advance(4 * time.Second)
+ resA := f.results(sdkShape, nil)
+ resB := f.results(plainShape, nil)
+ if resA.Status != 200 || resB.Status != 200 {
+ t.Fatalf("parameterized results -> %d / %d", resA.Status, resB.Status)
+ }
+ duneNum(t, resA.Body["query_id"], 4242, "results query_id (integer)")
+ rowsA := duneRows(t, resA)
+ rowsB := duneRows(t, resB)
+ if len(rowsA) != 8 || len(rowsB) != 8 {
+ t.Fatalf("query 4242 rows = %d / %d, want 8 each", len(rowsA), len(rowsB))
+ }
+ if rowsA[0]["amount_usd"] == rowsB[0]["amount_usd"] {
+ t.Fatalf("distinct wallet_address produced identical rows: %v", rowsA[0]["amount_usd"])
+ }
+
+ // ===== simulate_fail terminates QUERY_STATE_FAILED and results carry the failure envelope =====
+ failID := f.execute("12345", map[string]any{"simulate_fail": true})
+ f.vc.Advance(4 * time.Second)
+ failed := f.status(failID)
+ if failed["state"] != "QUERY_STATE_FAILED" || failed["is_execution_finished"] != true {
+ t.Fatalf("failed status = %v, want QUERY_STATE_FAILED / finished", failed)
+ }
+ if r := f.results(failID, nil); r.Status != 404 || r.Body["error"] != "Execution failed; no results are available" {
+ t.Fatalf("failed results -> %d %v, want the no-results 404", r.Status, r.Body)
+ }
+ if r := f.call("exec", "on_get_results_csv", "GET", "/api/v1/execution/"+failID+"/results/csv",
+ map[string]string{"execution_id": failID}, nil, nil, duneAuth); r.Status != 404 {
+ t.Fatalf("failed csv -> %d, want 404", r.Status)
+ }
+ // An unknown execution id answers the object-not-found 404 on both reads.
+ for _, h := range []string{"on_get_status", "on_get_results"} {
+ if r := f.call("exec", h, "GET", "/api/v1/execution/nope/"+map[string]string{
+ "on_get_status": "status", "on_get_results": "results",
+ }[h], map[string]string{"execution_id": "nope"}, nil, nil, duneAuth); r.Status != 404 || r.Body["error"] != "Object not found" {
+ t.Fatalf("%s unknown execution -> %d %v, want 404 {error: Object not found}", h, r.Status, r.Body)
+ }
+ }
+
+ // ===== the inline-result route completes synchronously =====
+ // POST result returns the terminal envelope immediately, without a poll.
+ r := f.call("exec", "on_inline_result", "POST", "/api/v1/query/3971/result",
+ map[string]string{"query_id": "3971"}, nil, map[string]any{
+ "query_parameters": map[string]any{"token_symbol": "WETH"},
+ }, duneAuth)
+ if r.Status != 200 || r.Body["state"] != "QUERY_STATE_COMPLETED" || r.Body["is_execution_finished"] != true {
+ t.Fatalf("inline result -> %d %v, want COMPLETED / finished", r.Status, r.Body)
+ }
+ if r.Body["next_uri"] != nil || r.Body["next_offset"] != nil {
+ t.Fatalf("inline next_uri/next_offset = %v / %v, want null (single page)", r.Body["next_uri"], r.Body["next_offset"])
+ }
+ inlineRows := duneRows(t, r)
+ if len(inlineRows) != 12 {
+ t.Fatalf("inline rows = %d, want 12 (query 3971)", len(inlineRows))
+ }
+ if inlineRows[0]["token_symbol"] != "WETH" {
+ t.Fatalf("inline token_symbol = %v, want WETH (parameter substituted)", inlineRows[0]["token_symbol"])
+ }
+}
+
+func TestDuneResultsPagingAndCSV(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newDuneFixture(t, base)
+
+ execID := f.execute("3971", map[string]any{"query_parameters": map[string]any{"token_symbol": "WETH"}})
+ f.vc.Advance(4 * time.Second) // complete the execution
+
+ // ===== results pages honor limit/offset with a followable next_uri =====
+ // Page metadata describes the returned page; total_* the full set; the
+ // continuation URL is absolute on the request host so clients follow it.
+ p1 := f.results(execID, map[string]string{"limit": "5"})
+ if p1.Status != 200 {
+ t.Fatalf("page 1 -> %d: %v", p1.Status, p1.Body)
+ }
+ rows1 := duneRows(t, p1)
+ meta1 := duneMeta(t, p1)
+ if len(rows1) != 5 {
+ t.Fatalf("page 1 rows = %d, want 5 (limit honored)", len(rows1))
+ }
+ if rows1[0]["token_symbol"] != "WETH" {
+ t.Fatalf("page 1 token_symbol = %v, want WETH", rows1[0]["token_symbol"])
+ }
+ duneNum(t, meta1["row_count"], 5, "page 1 row_count")
+ duneNum(t, meta1["total_row_count"], 12, "page 1 total_row_count")
+ duneNum(t, meta1["datapoint_count"], 48, "page 1 datapoint_count (12 rows x 4 columns)")
+ cols, _ := meta1["column_names"].([]any)
+ if len(cols) != 4 {
+ t.Fatalf("column_names = %v, want the 4 result columns", meta1["column_names"])
+ }
+ duneNum(t, p1.Body["next_offset"], 5, "page 1 next_offset")
+ nextURI, _ := p1.Body["next_uri"].(string)
+ if !strings.Contains(nextURI, "/api/v1/execution/"+execID+"/results?offset=5&limit=5") {
+ t.Fatalf("page 1 next_uri = %q, want the offset=5&limit=5 continuation", nextURI)
+ }
+ if !strings.HasPrefix(nextURI, "http://api.dune.test/") {
+ t.Fatalf("page 1 next_uri = %q, want absolute http on the request host", nextURI)
+ }
+ // Behind a TLS proxy the forwarded proto keeps the continuation followable.
+ proxied := f.callHdrs("exec", "on_get_results", "GET", "/api/v1/execution/"+execID+"/results",
+ map[string]string{"execution_id": execID}, map[string]string{"limit": "5"},
+ nil, duneAuth, map[string]string{"X-Forwarded-Proto": "https"})
+ proxiedURI, _ := proxied.Body["next_uri"].(string)
+ if !strings.HasPrefix(proxiedURI, "https://api.dune.test/") {
+ t.Fatalf("proxied next_uri = %q, want the forwarded https scheme", proxiedURI)
+ }
+
+ p2 := f.results(execID, map[string]string{"limit": "5", "offset": "5"})
+ rows2 := duneRows(t, p2)
+ if len(rows2) != 5 {
+ t.Fatalf("page 2 rows = %d, want 5 (offset honored)", len(rows2))
+ }
+ if rows2[0]["block_time"] == rows1[0]["block_time"] {
+ t.Fatalf("page 2 repeated page 1's first row (offset not applied)")
+ }
+ p3 := f.results(execID, map[string]string{"limit": "5", "offset": "10"})
+ rows3 := duneRows(t, p3)
+ if len(rows3) != 2 || p3.Body["next_uri"] != nil || p3.Body["next_offset"] != nil {
+ t.Fatalf("final page = %d rows, next %v/%v, want 2 rows and no continuation", len(rows3), p3.Body["next_uri"], p3.Body["next_offset"])
+ }
+
+ // Same parameters reproduce the same rows: paging never re-rolls the data.
+ again := f.execute("3971", map[string]any{"query_parameters": map[string]any{"token_symbol": "WETH"}})
+ f.vc.Advance(4 * time.Second)
+ replay := duneRows(t, f.results(again, nil))
+ if len(replay) != 12 || replay[0]["amount_usd"] != rows1[0]["amount_usd"] {
+ t.Fatalf("re-executed rows differ: %v vs %v", replay[0]["amount_usd"], rows1[0]["amount_usd"])
+ }
+
+ // ===== the CSV variant streams text/csv for the same page =====
+ csv := f.call("exec", "on_get_results_csv", "GET", "/api/v1/execution/"+execID+"/results/csv",
+ map[string]string{"execution_id": execID}, map[string]string{"limit": "3"}, nil, duneAuth)
+ if csv.Status != 200 {
+ t.Fatalf("csv -> %d", csv.Status)
+ }
+ if ct, _ := csv.Headers["Content-Type"]; ct != "text/csv" {
+ t.Fatalf("csv Content-Type = %q, want text/csv", ct)
+ }
+ lines := strings.Split(strings.TrimSpace(csv.RawBody), "\n")
+ if len(lines) != 4 {
+ t.Fatalf("csv lines = %d, want 4 (header + 3 rows honoring limit); body %q", len(lines), csv.RawBody)
+ }
+ if lines[0] != "block_time,protocol,amount_usd,token_symbol" {
+ t.Fatalf("csv header = %q, want the result column names", lines[0])
+ }
+ if !strings.Contains(lines[1], "WETH") {
+ t.Fatalf("csv row 1 = %q, want WETH substitution", lines[1])
+ }
+ if r := f.call("exec", "on_get_results_csv", "GET", "/api/v1/execution/unknown/csv",
+ map[string]string{"execution_id": "unknown"}, nil, nil, duneAuth); r.Status != 404 || r.Body["error"] != "Object not found" {
+ t.Fatalf("csv unknown execution -> %d %v, want 404 Object not found", r.Status, r.Body)
+ }
+}
+
+// duneRows pulls result.rows out of a results envelope.
+func duneRows(t *testing.T, r starlark.Response) []map[string]any {
+ t.Helper()
+ result, ok := r.Body["result"].(map[string]any)
+ if !ok {
+ t.Fatalf("result = %v, want object", r.Body["result"])
+ }
+ raw, _ := result["rows"].([]any)
+ out := make([]map[string]any, 0, len(raw))
+ for i, row := range raw {
+ m, ok := row.(map[string]any)
+ if !ok {
+ t.Fatalf("rows[%d] is %T, want object", i, row)
+ }
+ out = append(out, m)
+ }
+ return out
+}
+
+// duneMeta pulls result.metadata out of a results envelope.
+func duneMeta(t *testing.T, r starlark.Response) map[string]any {
+ t.Helper()
+ result, ok := r.Body["result"].(map[string]any)
+ if !ok {
+ t.Fatalf("result = %v, want object", r.Body["result"])
+ }
+ meta, ok := result["metadata"].(map[string]any)
+ if !ok {
+ t.Fatalf("metadata = %v, want object", result["metadata"])
+ }
+ return meta
+}
+
+// duneNum compares a JSON number regardless of int64/float64 width (stamps
+// round-trip through the collection store, where ints come back floats).
+func duneNum(t *testing.T, v any, want float64, what string) {
+ t.Helper()
+ switch n := v.(type) {
+ case int64:
+ if float64(n) != want {
+ t.Fatalf("%s = %d, want %v", what, n, want)
+ }
+ case float64:
+ if n != want {
+ t.Fatalf("%s = %v, want %v", what, n, want)
+ }
+ default:
+ t.Fatalf("%s is %T(%v), want number %v", what, v, v, want)
+ }
+}
diff --git a/adapters/erc4337_style_test.go b/adapters/erc4337_style_test.go
new file mode 100644
index 00000000..6366764b
--- /dev/null
+++ b/adapters/erc4337_style_test.go
@@ -0,0 +1,404 @@
+package adapters
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the erc4337-style adapter scripts directly (lib.star preloaded)
+// over a shared store and virtual clock: the JSON-RPC 2.0 envelope
+// (including batch bodies and the -32600/-32601/-32602 error codes),
+// eth_estimateUserOperationGas' v0.7 field validation, the deterministic
+// eth_sendUserOperation hash, the derive-on-read mempool -> bundled ->
+// included lifecycle (no sleeps — the clock advances), the simulate_fail
+// revert path, and the paymaster sponsorship endpoint.
+const (
+ ercEntryPoint = "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
+ ercSender = "0x1234567890abcdef1234567890abcdef12345678"
+)
+
+type erc4337Fixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ host string
+}
+
+func newErc4337Fixture(t *testing.T, start time.Time) *erc4337Fixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "erc4337-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &erc4337Fixture{t: t, vc: vc, host: "bundler.erc4337.test", vms: map[string]*starlark.VM{
+ "rpc": load("rpc.star"), "paymaster": load("paymaster.star"),
+ }}
+}
+
+// rpc posts one JSON-RPC request object to on_jsonrpc the way the engine
+// dispatches POST /.
+func (f *erc4337Fixture) rpc(method string, params []any, id any) map[string]any {
+ f.t.Helper()
+ r, err := f.vms["rpc"].Call("on_jsonrpc", starlark.Request{
+ Method: "POST", Path: "/", Host: f.host, Headers: map[string]string{},
+ Body: map[string]any{"jsonrpc": "2.0", "method": method, "params": params, "id": id},
+ Params: map[string]string{}, Query: map[string]string{},
+ })
+ if err != nil {
+ f.t.Fatalf("on_jsonrpc %s: %v", method, err)
+ }
+ if r.Status != 200 {
+ f.t.Fatalf("on_jsonrpc %s -> HTTP %d: %v (JSON-RPC errors ride HTTP 200)", method, r.Status, r.Body)
+ }
+ return r.Body
+}
+
+// rpcBatch posts a JSON-RPC batch (the engine wraps array bodies in
+// {"_batch": [...]}) and returns the array of envelopes.
+func (f *erc4337Fixture) rpcBatch(calls []map[string]any) []any {
+ f.t.Helper()
+ // The engine's JSON path yields []any, not a typed slice.
+ elems := make([]any, len(calls))
+ for i, c := range calls {
+ elems[i] = c
+ }
+ r, err := f.vms["rpc"].Call("on_jsonrpc", starlark.Request{
+ Method: "POST", Path: "/", Host: f.host, Headers: map[string]string{},
+ Body: map[string]any{"_batch": elems},
+ Params: map[string]string{}, Query: map[string]string{},
+ })
+ if err != nil {
+ f.t.Fatalf("on_jsonrpc batch: %v", err)
+ }
+ if r.Status != 200 || r.BodyList == nil {
+ f.t.Fatalf("batch -> HTTP %d body %v list %v, want 200 with an array body", r.Status, r.Body, r.BodyList)
+ }
+ return r.BodyList
+}
+
+// rpcRaw posts a prebuilt body verbatim (for invalid-request shapes).
+func (f *erc4337Fixture) rpcRaw(body map[string]any) map[string]any {
+ f.t.Helper()
+ r, err := f.vms["rpc"].Call("on_jsonrpc", starlark.Request{
+ Method: "POST", Path: "/", Host: f.host, Headers: map[string]string{},
+ Body: body, Params: map[string]string{}, Query: map[string]string{},
+ })
+ if err != nil {
+ f.t.Fatalf("on_jsonrpc raw: %v", err)
+ }
+ return r.Body
+}
+
+// ercUserOp builds a fully valid v0.7-shaped userOperation (the adapter's
+// USEROP_FIELDS, which keeps the v0.6-style paymasterAndData field).
+func ercUserOp(nonce string) map[string]any {
+ return map[string]any{
+ "sender": ercSender,
+ "nonce": nonce,
+ "initCode": "0x",
+ "callData": "0xdeadbeef",
+ "callGasLimit": "0x7d00",
+ "verificationGasLimit": "0x186a0",
+ "preVerificationGas": "0xc8",
+ "maxFeePerGas": "0x3b9aca00",
+ "maxPriorityFeePerGas": "0x1",
+ "paymasterAndData": "0x",
+ "signature": "0x" + ercRepeat("aa", 65),
+ }
+}
+
+func ercRepeat(s string, n int) string {
+ out := ""
+ for i := 0; i < n; i++ {
+ out += s
+ }
+ return out
+}
+
+// ercAssertEnvelope checks the jsonrpc/version + id echo on a response.
+func ercAssertEnvelope(t *testing.T, resp map[string]any, id any) {
+ t.Helper()
+ if resp["jsonrpc"] != "2.0" {
+ t.Fatalf("jsonrpc = %v, want \"2.0\"", resp["jsonrpc"])
+ }
+ if resp["id"] != id {
+ t.Fatalf("id = %v (%T), want the request id %v echoed", resp["id"], resp["id"], id)
+ }
+}
+
+// ercAssertErr asserts a JSON-RPC error envelope with the given code.
+func ercAssertErr(t *testing.T, resp map[string]any, id any, code int64, messagePart string) {
+ t.Helper()
+ ercAssertEnvelope(t, resp, id)
+ e, ok := resp["error"].(map[string]any)
+ if !ok {
+ t.Fatalf("want an error object, got %v", resp)
+ }
+ if got := ercNum(e["code"]); got != code {
+ t.Fatalf("error code = %v, want %d (%v)", e["code"], code, e)
+ }
+ if msg, _ := e["message"].(string); !strings.Contains(msg, messagePart) {
+ t.Fatalf("error message = %q, want it to contain %q", msg, messagePart)
+ }
+ if _, hasResult := resp["result"]; hasResult {
+ t.Fatalf("error response also carries a result: %v", resp)
+ }
+}
+
+// ercNum reads a response number whether the adapter produced a fresh
+// Starlark int or a value that round-tripped the JSON document store.
+func ercNum(v any) int64 {
+ switch n := v.(type) {
+ case int64:
+ return n
+ case float64:
+ return int64(n)
+ }
+ return 0
+}
+
+// TestErc4337BundlerRPC: the JSON-RPC bundler surface.
+func TestErc4337BundlerRPC(t *testing.T) {
+ f := newErc4337Fixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC))
+
+ // ===== supportedEntryPoints, chainId, and the JSON-RPC envelope =====
+ eps := f.rpc("eth_supportedEntryPoints", []any{}, int64(1))
+ ercAssertEnvelope(t, eps, int64(1))
+ if list, ok := eps["result"].([]any); !ok || len(list) != 1 || list[0] != ercEntryPoint {
+ t.Fatalf("supportedEntryPoints = %v, want [%s]", eps["result"], ercEntryPoint)
+ }
+ if r := f.rpc("eth_chainId", nil, "chain-1"); r["result"] != "0x1" {
+ t.Fatalf("eth_chainId = %v, want 0x1", r["result"])
+ }
+ ercAssertErr(t, f.rpc("eth_bogusMethod", []any{}, int64(7)), int64(7), -32601, "does not exist/is not available")
+ ercAssertErr(t, f.rpcRaw(map[string]any{"params": []any{}}), nil, -32600, "Invalid Request")
+ // Batch bodies answer element-by-element in order.
+ batch := f.rpcBatch([]map[string]any{
+ {"jsonrpc": "2.0", "method": "eth_supportedEntryPoints", "params": []any{}, "id": int64(11)},
+ {"jsonrpc": "2.0", "method": "eth_bogusMethod", "params": []any{}, "id": int64(12)},
+ })
+ if len(batch) != 2 {
+ t.Fatalf("batch = %d envelopes, want 2", len(batch))
+ }
+ first, _ := batch[0].(map[string]any)
+ second, _ := batch[1].(map[string]any)
+ if list, _ := first["result"].([]any); len(list) != 1 || list[0] != ercEntryPoint {
+ t.Fatalf("batch[0] = %v, want the entry points result", first)
+ }
+ if e, _ := second["error"].(map[string]any); ercNum(e["code"]) != -32601 {
+ t.Fatalf("batch[1] = %v, want the -32601 method-not-found envelope", second)
+ }
+
+ // ===== estimateUserOperationGas validates the full v0.7 field set =====
+ gas := f.rpc("eth_estimateUserOperationGas", []any{ercUserOp("0x0"), ercEntryPoint}, int64(2))
+ ercAssertEnvelope(t, gas, int64(2))
+ est, ok := gas["result"].(map[string]any)
+ if !ok {
+ t.Fatalf("estimateGas result = %v, want an object", gas["result"])
+ }
+ for field, want := range map[string]string{
+ "preVerificationGas": "0xc8", "verificationGasLimit": "0x186a0", "callGasLimit": "0x7d00",
+ } {
+ if est[field] != want {
+ t.Fatalf("estimateGas %s = %v, want the fixed %s", field, est[field], want)
+ }
+ }
+ missing := ercUserOp("0x0")
+ delete(missing, "callData")
+ ercAssertErr(t, f.rpc("eth_estimateUserOperationGas", []any{missing, ercEntryPoint}, int64(3)),
+ int64(3), -32602, "missing required field: callData")
+ nullSig := ercUserOp("0x0")
+ nullSig["signature"] = nil
+ ercAssertErr(t, f.rpc("eth_estimateUserOperationGas", []any{nullSig, ercEntryPoint}, int64(4)),
+ int64(4), -32602, "null value for required field: signature")
+ ercAssertErr(t, f.rpc("eth_estimateUserOperationGas", []any{}, int64(5)),
+ int64(5), -32602, "missing userOperation")
+
+ // ===== sendUserOperation answers a deterministic hash and defaults the entry point =====
+ sent := f.rpc("eth_sendUserOperation", []any{ercUserOp("0x0"), ercEntryPoint}, int64(6))
+ ercAssertEnvelope(t, sent, int64(6))
+ userOpHash, _ := sent["result"].(string)
+ if len(userOpHash) != 66 || !strings.HasPrefix(userOpHash, "0x") {
+ t.Fatalf("userOpHash = %q, want 0x + 64 hex chars", userOpHash)
+ }
+ // Omitting the entryPoint param defaults to v0.7 — same hash.
+ defaulted := f.rpc("eth_sendUserOperation", []any{ercUserOp("0x0")}, int64(7))
+ if defaulted["result"] != userOpHash {
+ t.Fatalf("defaulted entry point hash = %v, want the same %v", defaulted["result"], userOpHash)
+ }
+ if again := f.rpc("eth_sendUserOperation", []any{ercUserOp("0x0"), ercEntryPoint}, int64(8)); again["result"] != userOpHash {
+ t.Fatalf("resend hash = %v, want the deterministic %v", again["result"], userOpHash)
+ }
+ badOp := map[string]any{"sender": ercSender, "nonce": "0x0"}
+ ercAssertErr(t, f.rpc("eth_sendUserOperation", []any{badOp, ercEntryPoint}, int64(9)),
+ int64(9), -32602, "missing required field:")
+
+ // ===== the op walks mempool -> bundled -> included on the virtual clock =====
+ byHash := func(hash string) map[string]any {
+ t.Helper()
+ resp := f.rpc("eth_getUserOperationByHash", []any{hash}, int64(20))
+ ercAssertEnvelope(t, resp, int64(20))
+ return resp
+ }
+ receipt := func(hash string) map[string]any {
+ t.Helper()
+ resp := f.rpc("eth_getUserOperationReceipt", []any{hash}, int64(21))
+ ercAssertEnvelope(t, resp, int64(21))
+ return resp
+ }
+ // 0-1s: in the mempool, not yet addressable by hash.
+ if r := byHash(userOpHash)["result"]; r != nil {
+ t.Fatalf("byHash in mempool = %v, want null", r)
+ }
+ if r := receipt(userOpHash)["result"]; r != nil {
+ t.Fatalf("receipt in mempool = %v, want null", r)
+ }
+ // 1-3s: bundled — visible, but no block placement and no receipt yet.
+ f.vc.Advance(2 * time.Second)
+ bundled, ok := byHash(userOpHash)["result"].(map[string]any)
+ if !ok {
+ t.Fatalf("byHash at +2s = %v, want the bundled shape", byHash(userOpHash)["result"])
+ }
+ op, ok := bundled["userOperation"].(map[string]any)
+ if !ok || op["sender"] != ercSender || op["callData"] != "0xdeadbeef" {
+ t.Fatalf("bundled userOperation = %v, want the stored op echoed", bundled["userOperation"])
+ }
+ if bundled["blockNumber"] != nil || bundled["blockHash"] != nil || bundled["transactionHash"] != nil {
+ t.Fatalf("bundled placement = %v, want null block fields until inclusion", bundled)
+ }
+ if r := receipt(userOpHash)["result"]; r != nil {
+ t.Fatalf("receipt while bundled = %v, want null until inclusion", r)
+ }
+ // >=3s: included — full on-chain shapes, and repeated polls agree.
+ f.vc.Advance(2 * time.Second)
+ inc, ok := receipt(userOpHash)["result"].(map[string]any)
+ if !ok {
+ t.Fatalf("receipt after inclusion = %v, want the full shape", receipt(userOpHash)["result"])
+ }
+ if inc["userOpHash"] != userOpHash || inc["sender"] != ercSender || inc["success"] != true {
+ t.Fatalf("receipt = %v, want success:true echoing hash and sender", inc)
+ }
+ if inc["actualGasCost"] != "0x186a0" || inc["actualGasUsed"] != "0xc350" {
+ t.Fatalf("receipt gas = %v/%v, want the fixed hex values", inc["actualGasCost"], inc["actualGasUsed"])
+ }
+ logs, ok := inc["logs"].([]any)
+ if !ok || len(logs) != 1 {
+ t.Fatalf("receipt logs = %v, want the single UserOperationEvent log", inc["logs"])
+ }
+ log0, _ := logs[0].(map[string]any)
+ topics, _ := log0["topics"].([]any)
+ if len(topics) != 1 || !strings.HasPrefix(topics[0].(string), "0x") {
+ t.Fatalf("log topics = %v, want the event topic hash", log0["topics"])
+ }
+ if inc["reason"] != nil {
+ t.Fatalf("successful receipt carries a reason: %v", inc)
+ }
+ mined, ok := byHash(userOpHash)["result"].(map[string]any)
+ if !ok || mined["blockNumber"] != "0x1" {
+ t.Fatalf("byHash after inclusion = %v, want blockNumber 0x1", byHash(userOpHash)["result"])
+ }
+ if txHash, _ := mined["transactionHash"].(string); len(txHash) != 66 {
+ t.Fatalf("byHash transactionHash = %v, want 0x + 64 hex", mined["transactionHash"])
+ }
+ // Unknown hashes resolve to null results, not errors.
+ if r := receipt("0x" + ercRepeat("ab", 32))["result"]; r != nil {
+ t.Fatalf("unknown hash receipt = %v, want null", r)
+ }
+
+ // ===== simulate_fail reverts on inclusion with the AA95 reason =====
+ failOp := ercUserOp("0x1")
+ failOp["callData"] = "0xdeadbeed"
+ failSend := f.rpc("eth_sendUserOperation", []any{failOp, ercEntryPoint, map[string]any{"simulate_fail": true}}, int64(30))
+ failHash, _ := failSend["result"].(string)
+ if failHash == "" || failHash == userOpHash {
+ t.Fatalf("simulate_fail hash = %q, want a distinct non-empty hash", failHash)
+ }
+ f.vc.Advance(4 * time.Second)
+ failReceipt, ok := receipt(failHash)["result"].(map[string]any)
+ if !ok {
+ t.Fatalf("simulate_fail receipt = %v, want the reverted shape", receipt(failHash)["result"])
+ }
+ if failReceipt["success"] != false {
+ t.Fatalf("simulate_fail success = %v, want false", failReceipt["success"])
+ }
+ if failReceipt["reason"] != "AA95 user operation execution reverted" {
+ t.Fatalf("simulate_fail reason = %v, want the AA95 revert reason", failReceipt["reason"])
+ }
+}
+
+// TestErc4337PaymasterSign: the sponsorship-signing REST side endpoint.
+func TestErc4337PaymasterSign(t *testing.T) {
+ f := newErc4337Fixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC))
+ sign := func(body map[string]any) starlark.Response {
+ t.Helper()
+ r, err := f.vms["paymaster"].Call("on_sign", starlark.Request{
+ Method: "POST", Path: "/paymaster/sign", Host: f.host, Headers: map[string]string{},
+ Body: body, Params: map[string]string{}, Query: map[string]string{},
+ })
+ if err != nil {
+ t.Fatalf("on_sign: %v", err)
+ }
+ return r
+ }
+
+ // ===== the paymaster signs the op into paymasterAndData =====
+ ok := sign(map[string]any{"userOp": ercUserOp("0x0")})
+ if ok.Status != 200 {
+ t.Fatalf("paymaster/sign -> %d: %v", ok.Status, ok.Body)
+ }
+ pmData, _ := ok.Body["paymasterAndData"].(string)
+ if !strings.HasPrefix(pmData, "0x0000000000000000000000000000000000000001") || len(pmData) < 42 {
+ t.Fatalf("paymasterAndData = %q, want the mock paymaster address prefix", pmData)
+ }
+ if ok.Body["validUntil"] != "0x0" || ok.Body["validAfter"] != "0x0" {
+ t.Fatalf("validity = %v/%v, want zero windows", ok.Body["validUntil"], ok.Body["validAfter"])
+ }
+
+ // ===== missing or invalid userOps are 400s =====
+ if r := sign(map[string]any{}); r.Status != 400 || r.Body["error"] != "missing_userOp" {
+ t.Fatalf("sign without userOp -> %d %v, want 400 missing_userOp", r.Status, r.Body)
+ }
+ partial := sign(map[string]any{"userOp": map[string]any{"sender": ercSender}})
+ if partial.Status != 400 || partial.Body["error"] != "invalid_userOp" {
+ t.Fatalf("sign with partial userOp -> %d %v, want 400 invalid_userOp", partial.Status, partial.Body)
+ }
+ if msg, _ := partial.Body["message"].(string); !strings.Contains(msg, "missing required field:") {
+ t.Fatalf("invalid_userOp message = %q, want the field-validation detail", msg)
+ }
+}
diff --git a/adapters/etherscan-style/README.md b/adapters/etherscan-style/README.md
index 5fcbf9dd..b7c90e6b 100644
--- a/adapters/etherscan-style/README.md
+++ b/adapters/etherscan-style/README.md
@@ -18,7 +18,7 @@ lookups, contract verification, and chain stats.
### Auth
Auth is via the `apikey` query parameter. The mock accepts any non-empty
-value. A missing `apikey` returns `{status: "0", message: "Missing API key"}`.
+value. A missing `apikey` returns `{status: "0", message: "NOTOK", result: "Missing API Key"}`.
### Modules & Actions
diff --git a/adapters/etherscan-style/scripts/lib.star b/adapters/etherscan-style/scripts/lib.star
index 970066b4..26c94468 100644
--- a/adapters/etherscan-style/scripts/lib.star
+++ b/adapters/etherscan-style/scripts/lib.star
@@ -10,9 +10,11 @@
def _ok(result):
return {"status": "1", "message": "OK", "result": result}
-# _err wraps an error in the Etherscan envelope.
+# _err wraps an error in the Etherscan envelope: message stays "NOTOK" and
+# result carries the human description (the real wire shape — Etherscan
+# answers {"status":"0","message":"NOTOK","result":"Missing API Key"}).
def _err(message):
- return {"status": "0", "message": message, "result": ""}
+ return {"status": "0", "message": "NOTOK", "result": message}
# --- auth ---
@@ -21,7 +23,7 @@ def _err(message):
def _require_apikey(req):
apikey = req["query"].get("apikey", "")
if apikey == None or apikey == "":
- return respond(200, {"status": "0", "message": "Missing API key", "result": []})
+ return respond(200, _err("Missing API Key"))
return None
# --- deterministic hashing (same as eth-jsonrpc for cross-adapter fidelity) ---
diff --git a/adapters/etherscan_style_test.go b/adapters/etherscan_style_test.go
new file mode 100644
index 00000000..30e7bf86
--- /dev/null
+++ b/adapters/etherscan_style_test.go
@@ -0,0 +1,259 @@
+package adapters
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the etherscan-style adapter scripts directly (lib.star preloaded)
+// over a shared store and virtual clock: the apikey query-parameter gate,
+// the module/action grammar with the NOTOK error envelope, the seeded
+// account/contract/token-holder ledger (every number a decimal string),
+// and txlist's startblock/endblock/filter_by/sort/page/offset parameters.
+const (
+ esKey = "mock-etherscan-key"
+ esZero = "0x0000000000000000000000000000000000000000"
+ esOne = "0x0000000000000000000000000000000000000001"
+ esTwo = "0x0000000000000000000000000000000000000002"
+ esMockToken = "0x0000000000000000000000000000000000000100"
+)
+
+type etherscanFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ host string
+}
+
+func newEtherscanFixture(t *testing.T, start time.Time) *etherscanFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "etherscan-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ src, err := os.ReadFile(filepath.Join(root, "scripts", "api.star"))
+ if err != nil {
+ t.Fatalf("read api.star: %v", err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib api.star: %v", err)
+ }
+ return ðerscanFixture{t: t, vc: vc, host: "api.etherscan.test", vms: map[string]*starlark.VM{"api": vm}}
+}
+
+// get drives the single GET /api handler with the module/action query
+// grammar; apikey is added unless withKey is false.
+func (f *etherscanFixture) get(query map[string]string, withKey bool) starlark.Response {
+ f.t.Helper()
+ q := map[string]string{}
+ for k, v := range query {
+ q[k] = v
+ }
+ if withKey {
+ q["apikey"] = esKey
+ }
+ resp, err := f.vms["api"].Call("on_api", starlark.Request{
+ Method: "GET", Path: "/api", Host: f.host, Headers: map[string]string{},
+ Params: map[string]string{}, Query: q,
+ })
+ if err != nil {
+ f.t.Fatalf("on_api %v: %v", query, err)
+ }
+ return resp
+}
+
+// esEnvelope unwraps the {status, message, result} envelope after checking
+// the HTTP status (Etherscan always answers 200) and the status/message
+// pair.
+func esEnvelope(t *testing.T, r starlark.Response, wantStatus, wantMessage string) any {
+ t.Helper()
+ if r.Status != 200 {
+ t.Fatalf("GET /api -> HTTP %d: %v (Etherscan answers HTTP 200 for in-band errors too)", r.Status, r.Body)
+ }
+ if r.Body["status"] != wantStatus {
+ t.Fatalf("status = %v (%T), want %q; envelope %v", r.Body["status"], r.Body["status"], wantStatus, r.Body)
+ }
+ if r.Body["message"] != wantMessage {
+ t.Fatalf("message = %v, want %q", r.Body["message"], wantMessage)
+ }
+ return r.Body["result"]
+}
+
+// esResultList asserts the result is an array and returns it.
+func esResultList(t *testing.T, result any) []any {
+ t.Helper()
+ arr, ok := result.([]any)
+ if !ok {
+ t.Fatalf("result = %v (%T), want an array", result, result)
+ }
+ return arr
+}
+
+func TestEtherscanModuleActionGrammar(t *testing.T) {
+ f := newEtherscanFixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC))
+
+ // ===== the apikey query parameter gates every module call =====
+ // Real Etherscan keeps message "NOTOK" and carries the description in
+ // result; only a MISSING key errors (any non-empty value is accepted).
+ noKey := esEnvelope(t, f.get(map[string]string{"module": "account", "action": "balance", "address": esZero}, false), "0", "NOTOK")
+ if noKey != "Missing API Key" {
+ t.Fatalf("missing-key result = %v (%T), want the \"Missing API Key\" string", noKey, noKey)
+ }
+ if r := f.get(map[string]string{"module": "account", "action": "balance", "address": esZero}, true); r.Body["status"] != "1" {
+ t.Fatalf("any non-empty apikey rejected: %v", r.Body)
+ }
+
+ // ===== unknown modules and actions answer the NOTOK envelope over HTTP 200 =====
+ if r := esEnvelope(t, f.get(map[string]string{"module": "nomodule", "action": "balance"}, true), "0", "NOTOK"); r != "Invalid module" {
+ t.Fatalf("unknown module result = %v, want \"Invalid module\"", r)
+ }
+ if r := esEnvelope(t, f.get(map[string]string{"module": "account", "action": "bogus"}, true), "0", "NOTOK"); r != "Invalid account action" {
+ t.Fatalf("unknown action result = %v, want \"Invalid account action\"", r)
+ }
+ if r := esEnvelope(t, f.get(map[string]string{"module": "account", "action": "balance"}, true), "0", "NOTOK"); r != "Missing address" {
+ t.Fatalf("balance without address result = %v, want \"Missing address\"", r)
+ }
+
+ // ===== balance reads the seeded ledger; unknown addresses default to "0" =====
+ bal := esEnvelope(t, f.get(map[string]string{"module": "account", "action": "balance", "address": esZero, "tag": "latest"}, true), "1", "OK")
+ if bal != "1000000000000000000000" {
+ t.Fatalf("zero-address balance = %v (%T), want the seeded wei string", bal, bal)
+ }
+ if unknown := esEnvelope(t, f.get(map[string]string{"module": "account", "action": "balance", "address": "0x9999999999999999999999999999999999999999", "tag": "latest"}, true), "1", "OK"); unknown != "0" {
+ t.Fatalf("unknown-address balance = %v, want \"0\"", unknown)
+ }
+ multi := esResultList(t, esEnvelope(t, f.get(map[string]string{
+ "module": "account", "action": "balancemulti",
+ "address": esZero + "," + esOne + "," + esTwo,
+ }, true), "1", "OK"))
+ if len(multi) != 3 {
+ t.Fatalf("balancemulti = %d rows, want 3 (one per comma-separated address)", len(multi))
+ }
+ row, _ := multi[1].(map[string]any)
+ if row["account"] != esOne || row["balance"] != "500000000000000000000" {
+ t.Fatalf("balancemulti[1] = %v, want account/balance echo for %s", row, esOne)
+ }
+
+ // ===== txlist scopes by address then applies block filters, sort and paging =====
+ // 0x…0002 touches both seeded txs (to seed-1, from seed-2).
+ txAddr := esResultList(t, esEnvelope(t, f.get(map[string]string{
+ "module": "account", "action": "txlist", "address": esTwo,
+ }, true), "1", "OK"))
+ if len(txAddr) != 2 {
+ t.Fatalf("txlist for %s = %d rows, want 2 (to seed-1, from seed-2)", esTwo, len(txAddr))
+ }
+ first, _ := txAddr[0].(map[string]any)
+ if first["from"] != esOne || first["value"] != "1000000000000000000" || first["isError"] != "0" {
+ t.Fatalf("txlist[0] = %v, want the seed-1 wire shape (numbers as strings)", first)
+ }
+ // filter_by narrows the address scope to one side.
+ fromOnly := esResultList(t, esEnvelope(t, f.get(map[string]string{
+ "module": "account", "action": "txlist", "address": esTwo, "filter_by": "from",
+ }, true), "1", "OK"))
+ if len(fromOnly) != 1 || fromOnly[0].(map[string]any)["blockNumber"] != "2" {
+ t.Fatalf("filter_by=from = %v, want only the block-2 tx", fromOnly)
+ }
+ // startblock filters numerically on the stored decimal-string blockNumber.
+ late := esResultList(t, esEnvelope(t, f.get(map[string]string{
+ "module": "account", "action": "txlist", "address": esTwo, "startblock": "2",
+ }, true), "1", "OK"))
+ if len(late) != 1 || late[0].(map[string]any)["timeStamp"] != "1700000001" {
+ t.Fatalf("startblock=2 = %v, want only the block-2 tx", late)
+ }
+ // sort=desc orders by timeStamp newest-first; page/offset slice.
+ desc := esResultList(t, esEnvelope(t, f.get(map[string]string{
+ "module": "account", "action": "txlist", "address": esTwo, "sort": "desc",
+ }, true), "1", "OK"))
+ if len(desc) != 2 || desc[0].(map[string]any)["timeStamp"] != "1700000001" {
+ t.Fatalf("sort=desc = %v, want the newer tx first", desc)
+ }
+ page1 := esResultList(t, esEnvelope(t, f.get(map[string]string{
+ "module": "account", "action": "txlist", "address": esTwo, "page": "1", "offset": "1",
+ }, true), "1", "OK"))
+ if len(page1) != 1 || page1[0].(map[string]any)["timeStamp"] != "1700000000" {
+ t.Fatalf("page=1 offset=1 = %v, want exactly the first seeded tx", page1)
+ }
+ page2 := esResultList(t, esEnvelope(t, f.get(map[string]string{
+ "module": "account", "action": "txlist", "address": esTwo, "page": "2", "offset": "1",
+ }, true), "1", "OK"))
+ if len(page2) != 1 || page2[0].(map[string]any)["timeStamp"] != "1700000001" {
+ t.Fatalf("page=2 offset=1 = %v, want exactly the second seeded tx", page2)
+ }
+
+ // ===== contract verification: ABI, source, and the unverified fallback =====
+ abiStr, ok := esEnvelope(t, f.get(map[string]string{
+ "module": "contract", "action": "getabi", "address": esMockToken,
+ }, true), "1", "OK").(string)
+ if !ok || len(abiStr) == 0 || abiStr[0] != '[' {
+ t.Fatalf("getabi result = %v, want a JSON ABI string starting with '['", abiStr)
+ }
+ var abi []map[string]any
+ if err := json.Unmarshal([]byte(abiStr), &abi); err != nil || len(abi) == 0 {
+ t.Fatalf("getabi result is not a JSON array of functions: %v (err %v)", abiStr, err)
+ }
+ src := esResultList(t, esEnvelope(t, f.get(map[string]string{
+ "module": "contract", "action": "getsourcecode", "address": esMockToken,
+ }, true), "1", "OK"))
+ meta, _ := src[0].(map[string]any)
+ if meta["ContractName"] != "MockToken" || meta["CompilerVersion"] != "v0.8.20+commit.a1b79de6" || meta["Proxy"] != "0" {
+ t.Fatalf("getsourcecode[0] = %v, want the seeded MockToken verification record", meta)
+ }
+ unverified := esEnvelope(t, f.get(map[string]string{
+ "module": "contract", "action": "getabi", "address": "0x1234567890abcdef1234567890abcdef12345678",
+ }, true), "0", "NOTOK")
+ if unverified != "Contract source code not verified" {
+ t.Fatalf("unverified getabi = %v, want the not-verified description", unverified)
+ }
+ // getsourcecode stays status "1" for unverified contracts, with the
+ // notice carried inside the ABI field — the real Etherscan quirk.
+ fallback := esResultList(t, esEnvelope(t, f.get(map[string]string{
+ "module": "contract", "action": "getsourcecode", "address": "0x1234567890abcdef1234567890abcdef12345678",
+ }, true), "1", "OK"))
+ if fb, _ := fallback[0].(map[string]any); fb["ABI"] != "Contract source code not verified" || fb["ContractName"] != "" {
+ t.Fatalf("unverified getsourcecode = %v, want the empty record with the ABI notice", fb)
+ }
+
+ // ===== stats and token holders keep every number a decimal string =====
+ price, ok := esEnvelope(t, f.get(map[string]string{"module": "stats", "action": "ethprice"}, true), "1", "OK").(map[string]any)
+ if !ok || price["ethusd"] != "2500.00" || price["ethbtc"] != "15.0" {
+ t.Fatalf("ethprice = %v, want the seeded string-valued prices", price)
+ }
+ if supply := esEnvelope(t, f.get(map[string]string{"module": "stats", "action": "ethsupply"}, true), "1", "OK"); supply != "120000000000000000000000000" {
+ t.Fatalf("ethsupply = %v (%T), want the wei string", supply, supply)
+ }
+ holders := esResultList(t, esEnvelope(t, f.get(map[string]string{
+ "module": "token", "action": "tokenholderlist", "contractaddress": esMockToken,
+ "page": "2", "offset": "1",
+ }, true), "1", "OK"))
+ if len(holders) != 1 {
+ t.Fatalf("tokenholderlist page 2 offset 1 = %d rows, want 1", len(holders))
+ }
+ if h, _ := holders[0].(map[string]any); h["TokenHolderAddress"] != esTwo || h["TokenHolderQuantity"] != "250000000000000000000" {
+ t.Fatalf("tokenholderlist page 2 = %v, want the second seeded holder", h)
+ }
+}
diff --git a/adapters/jumio_style_test.go b/adapters/jumio_style_test.go
new file mode 100644
index 00000000..e10d2cc2
--- /dev/null
+++ b/adapters/jumio_style_test.go
@@ -0,0 +1,564 @@
+package adapters
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the jumio-style adapter scripts directly (lib.star preloaded) over
+// a shared store, virtual clock and webhook sink: the Bearer gate and the
+// Netverify retrieval-v2 response shapes, the derive-on-read scan lifecycle
+// (PENDING -> DONE | FAILED with real reject-reason codes), the extracted
+// document data (None while PENDING, 409 when FAILED), scan deletion with
+// its advance-then-remove semantics, and the X-Jumio-Webhook-Signature HMAC
+// scheme in both directions.
+const (
+ jumioAuth = "Bearer jumio_stunt_test_token"
+ jumioHost = "netverify.jumio.test"
+ jumioWebhookSecret = "stunt_jumio_mock_signing_key"
+)
+
+// jumioScanRefRE pins the synthetic scan-reference shape: decimal groups
+// assembled at runtime (as-is: real Netverify scan references are UUIDs).
+var jumioScanRefRE = regexp.MustCompile(`^\d{9}-0000-4000-8000-\d{9}$`)
+
+type jumioFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ emitter *events.Emitter
+ host string
+}
+
+func newJumioFixture(t *testing.T, start time.Time) *jumioFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "jumio-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ em := events.NewEmitter()
+ t.Cleanup(em.Close)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: em,
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &jumioFixture{t: t, vc: vc, vms: map[string]*starlark.VM{
+ "scans": load("scans.star"), "hooks": load("webhooks.star"),
+ }, emitter: em, host: jumioHost}
+}
+
+// call invokes handler on the named script VM; auth is the full Authorization
+// header value ("" = header absent).
+func (f *jumioFixture) call(group, handler, method, path string, params map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// jumioCreate posts a scan create and returns the minted scan reference.
+func (f *jumioFixture) jumioCreate(body map[string]any) string {
+ f.t.Helper()
+ r := f.call("scans", "on_create_scan", "POST", "/netverify/v2/scans", nil, body, jumioAuth)
+ if r.Status != 200 {
+ f.t.Fatalf("create scan -> %d: %v", r.Status, r.Body)
+ }
+ ref, _ := r.Body["scanReference"].(string)
+ if ref == "" {
+ f.t.Fatalf("create scan: no scanReference: %v", r.Body)
+ }
+ return ref
+}
+
+// jumioStatus reads GET /netverify/v2/scans/{ref} and returns its status.
+func (f *jumioFixture) jumioStatus(ref string) string {
+ f.t.Helper()
+ r := f.call("scans", "on_get_scan", "GET", "/netverify/v2/scans/"+ref,
+ map[string]string{"scan_reference": ref}, nil, jumioAuth)
+ if r.Status != 200 {
+ f.t.Fatalf("get scan %s -> %d: %v", ref, r.Status, r.Body)
+ }
+ return r.Body["status"].(string)
+}
+
+// jumioNum compares a response number against want whether it arrives as an
+// int (handler literal) or a float (round-tripped through a collection).
+func jumioNum(got any, want int64) bool {
+ switch n := got.(type) {
+ case int64:
+ return n == want
+ case float64:
+ return n == float64(want)
+ }
+ return false
+}
+
+// jumioSeq extracts the trailing sequence group from a scan reference.
+func jumioSeq(t *testing.T, ref string) int64 {
+ t.Helper()
+ groups := strings.Split(ref, "-")
+ n, err := strconv.ParseInt(groups[len(groups)-1], 10, 64)
+ if err != nil {
+ t.Fatalf("scan reference %q: trailing group unparsable: %v", ref, err)
+ }
+ return n
+}
+
+// jumioDelivery is one webhook POST captured by the sink.
+type jumioDelivery struct {
+ body []byte
+ sig string
+}
+
+// envelope returns the delivery's parsed {type, payload} envelope.
+func (d jumioDelivery) envelope(t *testing.T) (string, map[string]any) {
+ t.Helper()
+ var env struct {
+ Type string `json:"type"`
+ Payload map[string]any `json:"payload"`
+ }
+ if err := json.Unmarshal(d.body, &env); err != nil {
+ t.Fatalf("webhook body %s unparsable: %v", d.body, err)
+ }
+ if env.Type == "" || env.Payload == nil {
+ t.Fatalf("webhook body %s, want a {type, payload} envelope", d.body)
+ }
+ return env.Type, env.Payload
+}
+
+// captureWebhooks registers a sink and returns a collector over the raw
+// deliveries (body + X-Jumio-Webhook-Signature header).
+func (f *jumioFixture) captureWebhooks() func() []jumioDelivery {
+ f.t.Helper()
+ var mu sync.Mutex
+ var got []jumioDelivery
+ sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ mu.Lock()
+ got = append(got, jumioDelivery{body: b, sig: r.Header.Get("X-Jumio-Webhook-Signature")})
+ mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ }))
+ f.t.Cleanup(sink.Close)
+ f.emitter.Register("test", sink.URL)
+ return func() []jumioDelivery {
+ mu.Lock()
+ defer mu.Unlock()
+ return append([]jumioDelivery(nil), got...)
+ }
+}
+
+// jumioVerifyDelivery checks X-Jumio-Webhook-Signature against Jumio's
+// scheme — hex(HMAC-SHA256(secret, raw_body)) — over the exact bytes the
+// sink received.
+func jumioVerifyDelivery(t *testing.T, d jumioDelivery) {
+ t.Helper()
+ mac := hmac.New(sha256.New, []byte(jumioWebhookSecret))
+ mac.Write(d.body)
+ want := hex.EncodeToString(mac.Sum(nil))
+ if !hmac.Equal([]byte(want), []byte(d.sig)) {
+ t.Fatalf("X-Jumio-Webhook-Signature = %q, want %q over body %s", d.sig, want, d.body)
+ }
+}
+
+// TestJumioScanCreateAndGate: the Bearer gate's Jumio error envelope, the
+// create response shape with its synthetic decimal scan references, and the
+// merchantScanReference requirement.
+func TestJumioScanCreateAndGate(t *testing.T) {
+ f := newJumioFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ create := func(body map[string]any, auth string) starlark.Response {
+ return f.call("scans", "on_create_scan", "POST", "/netverify/v2/scans", nil, body, auth)
+ }
+
+ // ===== a missing, bare or wrong-scheme token is a 401 in the Jumio error envelope =====
+ // {httpStatus: 401, message: "Unauthorized"}.
+ for _, auth := range []string{"", "jumio_stunt_test_token", "Basic abc"} {
+ r := create(map[string]any{"merchantScanReference": "ref-1"}, auth)
+ if r.Status != 401 {
+ t.Fatalf("auth %q -> %d, want 401", auth, r.Status)
+ }
+ if !jumioNum(r.Body["httpStatus"], 401) || r.Body["message"] != "Unauthorized" {
+ t.Fatalf("401 envelope = %v, want httpStatus 401 Unauthorized", r.Body)
+ }
+ }
+ // Any Bearer is accepted (as-is): real Jumio uses HTTP Basic auth against
+ // a server-token store; this gate checks Bearer presence only.
+ if r := create(map[string]any{"merchantScanReference": "ref-1"}, "Bearer totally-unknown"); r.Status != 200 {
+ t.Fatalf("any bearer -> %d, want 200 (presence-only gate)", r.Status)
+ }
+
+ // ===== a scan create answers PENDING with a synthetic decimal scan reference =====
+ r := create(map[string]any{
+ "merchantScanReference": "merchant-ref-001", "country": "USA", "type": "DRIVING_LICENSE",
+ }, jumioAuth)
+ if r.Status != 200 {
+ t.Fatalf("create scan -> %d: %v", r.Status, r.Body)
+ }
+ ref, _ := r.Body["scanReference"].(string)
+ if !jumioScanRefRE.MatchString(ref) {
+ t.Fatalf("scanReference = %q, want the decimal-group synthetic shape", ref)
+ }
+ if r.Body["status"] != "PENDING" || r.Body["merchantScanReference"] != "merchant-ref-001" {
+ t.Fatalf("create response = %v, want PENDING + the merchant reference echoed", r.Body)
+ }
+ if _, err := time.Parse(time.RFC3339, r.Body["timestamp"].(string)); err != nil {
+ t.Fatalf("timestamp = %v, want an RFC3339 timestamp", r.Body["timestamp"])
+ }
+
+ // ===== sequential creates advance the reference sequence =====
+ second, _ := create(map[string]any{"merchantScanReference": "merchant-ref-002"}, jumioAuth).Body["scanReference"].(string)
+ if a, b := jumioSeq(t, ref), jumioSeq(t, second); b <= a {
+ t.Fatalf("scan sequence %d then %d, want monotonically increasing", a, b)
+ }
+
+ // ===== a create without merchantScanReference is a 400 =====
+ for name, body := range map[string]map[string]any{"no ref": {}, "nil body": nil} {
+ r := create(body, jumioAuth)
+ if r.Status != 400 {
+ t.Fatalf("create %s -> %d, want 400", name, r.Status)
+ }
+ if !jumioNum(r.Body["httpStatus"], 400) || r.Body["message"] != "merchantScanReference is required" {
+ t.Fatalf("create %s error = %v, want the required-field message", name, r.Body)
+ }
+ }
+}
+
+// TestJumioScanStatusLifecycle: the derive-on-read status machine on the
+// virtual clock — PENDING through the whole processing window, DONE at +3s
+// (persisted), FAILED with real reject-reason codes, and the 404s.
+func TestJumioScanStatusLifecycle(t *testing.T) {
+ f := newJumioFixture(t, time.Unix(1_750_000_000, 0).UTC())
+
+ // ===== PENDING holds through the processing window then flips to DONE =====
+ // Jumio reports no distinct in-flight state: 0..3s is all PENDING.
+ ref := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-001"})
+ if s := f.jumioStatus(ref); s != "PENDING" {
+ t.Fatalf("fresh status = %q, want PENDING", s)
+ }
+ f.vc.Advance(2 * time.Second)
+ if s := f.jumioStatus(ref); s != "PENDING" {
+ t.Fatalf("status at +2s = %q, want still PENDING", s)
+ }
+ f.vc.Advance(2 * time.Second)
+ if s := f.jumioStatus(ref); s != "DONE" {
+ t.Fatalf("status at +4s = %q, want DONE", s)
+ }
+ r := f.call("scans", "on_get_scan", "GET", "/netverify/v2/scans/"+ref,
+ map[string]string{"scan_reference": ref}, nil, jumioAuth)
+ if r.Body["status"] != "DONE" || r.Body["scanReference"] != ref ||
+ r.Body["merchantScanReference"] != "merchant-ref-001" {
+ t.Fatalf("DONE scan = %v, want the retrieval shape with the reference echoed", r.Body)
+ }
+ if _, has := r.Body["rejectionReason"]; has {
+ t.Fatalf("DONE scan carries rejectionReason = %v, want none", r.Body["rejectionReason"])
+ }
+
+ // ===== a FAILED scan carries a real reject reason and its description =====
+ // simulate_reject_reason implies failure; simulate_fail alone defaults to
+ // MANIPULATED_DOCUMENT (both stunt-only create fields).
+ rejectRef := f.jumioCreate(map[string]any{
+ "merchantScanReference": "merchant-ref-002", "simulate_reject_reason": "DOCUMENT_EXPIRED",
+ })
+ defaultRef := f.jumioCreate(map[string]any{
+ "merchantScanReference": "merchant-ref-003", "simulate_fail": true,
+ })
+ f.vc.Advance(4 * time.Second)
+ r = f.call("scans", "on_get_scan", "GET", "/netverify/v2/scans/"+rejectRef,
+ map[string]string{"scan_reference": rejectRef}, nil, jumioAuth)
+ if r.Body["status"] != "FAILED" || r.Body["rejectionReason"] != "DOCUMENT_EXPIRED" ||
+ r.Body["rejectReasonDescription"] != "The document has expired." {
+ t.Fatalf("rejected scan = %v, want FAILED DOCUMENT_EXPIRED with its description", r.Body)
+ }
+ r = f.call("scans", "on_get_scan", "GET", "/netverify/v2/scans/"+defaultRef,
+ map[string]string{"scan_reference": defaultRef}, nil, jumioAuth)
+ if r.Body["status"] != "FAILED" || r.Body["rejectionReason"] != "MANIPULATED_DOCUMENT" {
+ t.Fatalf("default-fail scan = %v, want FAILED MANIPULATED_DOCUMENT", r.Body)
+ }
+ if desc, _ := r.Body["rejectReasonDescription"].(string); desc == "" {
+ t.Fatalf("default-fail description = %v, want non-empty", r.Body["rejectReasonDescription"])
+ }
+
+ // ===== unknown scans are 404 on every parameterized route =====
+ for _, rt := range []struct {
+ handler, method, path string
+ }{
+ {"on_get_scan", "GET", "/netverify/v2/scans/nope-0000-4000-8000-000000000"},
+ {"on_delete_scan", "DELETE", "/netverify/v2/scans/nope-0000-4000-8000-000000000"},
+ {"on_get_scan_data", "GET", "/netverify/v2/scans/nope-0000-4000-8000-000000000/data"},
+ } {
+ r := f.call("scans", rt.handler, rt.method, rt.path,
+ map[string]string{"scan_reference": "nope-0000-4000-8000-000000000"}, nil, jumioAuth)
+ if r.Status != 404 || !jumioNum(r.Body["httpStatus"], 404) {
+ t.Fatalf("%s %s -> %d %v, want the 404 error envelope", rt.method, rt.path, r.Status, r.Body)
+ }
+ }
+}
+
+// TestJumioScanExtractedData: the retrieval-v2 data shapes — extractedData is
+// absent while PENDING, the synthetic document fields once DONE, and the 409
+// (with the rejection reason repeated) when FAILED.
+func TestJumioScanExtractedData(t *testing.T) {
+ f := newJumioFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ scanData := func(ref string) starlark.Response {
+ f.t.Helper()
+ return f.call("scans", "on_get_scan_data", "GET", "/netverify/v2/scans/"+ref+"/data",
+ map[string]string{"scan_reference": ref}, nil, jumioAuth)
+ }
+
+ // ===== extracted data is None while the scan is PENDING =====
+ ref := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-001", "country": "GBR"})
+ r := scanData(ref)
+ if r.Status != 200 || r.Body["status"] != "PENDING" {
+ t.Fatalf("pending scan data -> %d %v, want 200 PENDING", r.Status, r.Body)
+ }
+ if got := r.Body["extractedData"]; got != nil {
+ t.Fatalf("pending extractedData = %v, want null", got)
+ }
+
+ // ===== DONE exposes the synthetic document extraction =====
+ // Fixed synthetic PII (JOHN DOE); the document number embeds the scan's
+ // read count at extraction time.
+ f.vc.Advance(4 * time.Second)
+ if s := f.jumioStatus(ref); s != "DONE" {
+ t.Fatalf("status = %q, want DONE", s)
+ }
+ r = scanData(ref)
+ if r.Status != 200 || r.Body["status"] != "DONE" || r.Body["scanReference"] != ref {
+ t.Fatalf("done scan data -> %d %v, want the DONE retrieval shape", r.Status, r.Body)
+ }
+ ext, ok := r.Body["extractedData"].(map[string]any)
+ if !ok {
+ t.Fatalf("extractedData = %v, want an object", r.Body["extractedData"])
+ }
+ docNum, _ := ext["documentNumber"].(string)
+ if ext["firstName"] != "JOHN" || ext["lastName"] != "DOE" || ext["dob"] != "1990-01-15" ||
+ ext["expiry"] != "2030-06-20" || !strings.HasPrefix(docNum, "D1234567") {
+ t.Fatalf("extractedData = %v, want the synthetic document fields", ext)
+ }
+ if ext["country"] != "GBR" || ext["usState"] != "CA" ||
+ ext["address"] != "123 MAIN ST, ANYTOWN, CA 90210" {
+ t.Fatalf("extractedData = %v, want the country echoed + the fixed locale fields", ext)
+ }
+
+ // ===== FAILED scans answer data with a 409 repeating the reason =====
+ failRef := f.jumioCreate(map[string]any{
+ "merchantScanReference": "merchant-ref-002", "simulate_reject_reason": "DOCUMENT_EXPIRED",
+ })
+ f.vc.Advance(4 * time.Second)
+ r = scanData(failRef)
+ if r.Status != 409 || !jumioNum(r.Body["httpStatus"], 409) {
+ t.Fatalf("failed scan data -> %d %v, want the 409 error envelope", r.Status, r.Body)
+ }
+ if r.Body["rejectionReason"] != "DOCUMENT_EXPIRED" {
+ t.Fatalf("failed scan data reason = %v, want DOCUMENT_EXPIRED repeated", r.Body["rejectionReason"])
+ }
+ if _, has := r.Body["extractedData"]; has {
+ t.Fatalf("failed scan data carries extractedData = %v, want none", r.Body["extractedData"])
+ }
+}
+
+// TestJumioScanDelete: the deletion route — 200 with the deleted receipt,
+// 404s for every later read, deletion before the terminal window skips the
+// terminal side effects, and deletion after it still fires them.
+func TestJumioScanDelete(t *testing.T) {
+ f := newJumioFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ del := func(ref string) starlark.Response {
+ f.t.Helper()
+ return f.call("scans", "on_delete_scan", "DELETE", "/netverify/v2/scans/"+ref,
+ map[string]string{"scan_reference": ref}, nil, jumioAuth)
+ }
+
+ // ===== delete removes the scan and later reads are 404s =====
+ ref := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-001"})
+ r := del(ref)
+ if r.Status != 200 || r.Body["deleted"] != true || r.Body["scanReference"] != ref {
+ t.Fatalf("delete -> %d %v, want 200 {scanReference, deleted:true}", r.Status, r.Body)
+ }
+ if _, err := time.Parse(time.RFC3339, r.Body["timestamp"].(string)); err != nil {
+ t.Fatalf("delete timestamp = %v, want RFC3339", r.Body["timestamp"])
+ }
+ for _, rt := range []struct {
+ handler, method, suffix string
+ }{
+ {"on_get_scan", "GET", ""},
+ {"on_delete_scan", "DELETE", ""},
+ {"on_get_scan_data", "GET", "/data"},
+ } {
+ if r := f.call("scans", rt.handler, rt.method, "/netverify/v2/scans/"+ref+rt.suffix,
+ map[string]string{"scan_reference": ref}, nil, jumioAuth); r.Status != 404 {
+ t.Fatalf("%s deleted scan -> %d, want 404", rt.method, r.Status)
+ }
+ }
+
+ // ===== deleting after the terminal window still advances the lifecycle =====
+ // Terminal side effects (webhook emission) fire before the scan disappears.
+ lateRef := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-002"})
+ f.vc.Advance(4 * time.Second)
+ if r := del(lateRef); r.Status != 200 {
+ t.Fatalf("late delete -> %d: %v", r.Status, r.Body)
+ }
+ if r := f.call("scans", "on_get_scan", "GET", "/netverify/v2/scans/"+lateRef,
+ map[string]string{"scan_reference": lateRef}, nil, jumioAuth); r.Status != 404 {
+ t.Fatalf("late-deleted scan -> %d, want 404", r.Status)
+ }
+}
+
+// TestJumioWebhookReceiverAndEvents: the X-Jumio-Webhook-Signature scheme in
+// both directions — the inbound receiver MACs the exact request bytes, and
+// the outbound scan.completed / scan.failed deliveries are signed over the
+// exact delivered bytes and fire exactly once per terminal transition.
+func TestJumioWebhookReceiverAndEvents(t *testing.T) {
+ f := newJumioFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ delivered := f.captureWebhooks()
+
+ // ===== a correctly MACed webhook body is accepted =====
+ raw := `{"scanReference":"268435457-0000-4000-8000-100001000","status":"DONE"}`
+ mac := hmac.New(sha256.New, []byte(jumioWebhookSecret))
+ mac.Write([]byte(raw))
+ goodSig := hex.EncodeToString(mac.Sum(nil))
+ post := func(sig, body string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if sig != "" {
+ headers["X-Jumio-Webhook-Signature"] = sig
+ }
+ resp, err := f.vms["hooks"].Call("on_webhook", starlark.Request{
+ Method: "POST", Path: "/netverify/v2/webhooks", Host: f.host, Headers: headers, RawBody: body,
+ })
+ if err != nil {
+ f.t.Fatalf("on_webhook: %v", err)
+ }
+ return resp
+ }
+ if r := post(goodSig, raw); r.Status != 200 || r.Body["received"] != true {
+ t.Fatalf("signed webhook -> %d %v, want 200 {received:true}", r.Status, r.Body)
+ }
+
+ // ===== a tampered body, wrong MAC or missing header is a 401 =====
+ if r := post(goodSig, raw+" "); r.Status != 401 {
+ t.Fatalf("tampered body -> %d, want 401", r.Status)
+ } else if !jumioNum(r.Body["httpStatus"], 401) {
+ t.Fatalf("tampered body envelope = %v, want the Jumio 401 shape", r.Body)
+ }
+ wrongMAC := goodSig[:8] + strings.Repeat("0", 8) + goodSig[16:]
+ if r := post(wrongMAC, raw); r.Status != 401 {
+ t.Fatalf("wrong MAC -> %d, want 401", r.Status)
+ }
+ if r := post("", "{}"); r.Status != 401 {
+ t.Fatalf("missing header -> %d, want 401", r.Status)
+ }
+
+ // ===== the terminal transition emits exactly one signed scan.completed =====
+ ref := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-001"})
+ f.vc.Advance(2 * time.Second)
+ if s := f.jumioStatus(ref); s != "PENDING" {
+ t.Fatalf("status at +2s = %q, want PENDING (no delivery yet)", s)
+ }
+ if n := len(delivered()); n != 0 {
+ t.Fatalf("%d deliveries before the terminal window, want 0", n)
+ }
+ f.vc.Advance(2 * time.Second)
+ if s := f.jumioStatus(ref); s != "DONE" {
+ t.Fatalf("status at +4s = %q, want DONE", s)
+ }
+ all := delivered()
+ if len(all) != 1 {
+ t.Fatalf("%d deliveries after completion, want exactly 1", len(all))
+ }
+ jumioVerifyDelivery(t, all[0])
+ eventType, payload := all[0].envelope(t)
+ if eventType != "scan.completed" || payload["scanReference"] != ref || payload["status"] != "DONE" {
+ t.Fatalf("webhook = %s %v, want scan.completed for the DONE scan", eventType, payload)
+ }
+ f.jumioStatus(ref)
+ if n := len(delivered()); n != 1 {
+ t.Fatalf("%d deliveries after a re-read, want still 1 (emit exactly once)", n)
+ }
+
+ // ===== failed scans emit scan.failed carrying the rejection reason =====
+ failRef := f.jumioCreate(map[string]any{
+ "merchantScanReference": "merchant-ref-002", "simulate_reject_reason": "DOCUMENT_EXPIRED",
+ })
+ f.vc.Advance(4 * time.Second)
+ if s := f.jumioStatus(failRef); s != "FAILED" {
+ t.Fatalf("simulate_reject_reason status = %q, want FAILED", s)
+ }
+ all = delivered()
+ if len(all) != 2 {
+ t.Fatalf("%d deliveries after the failed transition, want 2", len(all))
+ }
+ jumioVerifyDelivery(t, all[1])
+ eventType, payload = all[1].envelope(t)
+ if eventType != "scan.failed" || payload["scanReference"] != failRef ||
+ payload["rejectionReason"] != "DOCUMENT_EXPIRED" {
+ t.Fatalf("webhook = %s %v, want scan.failed with the reject reason", eventType, payload)
+ }
+
+ // ===== a delete-driven terminal transition also emits, then nothing more =====
+ // Deleting a not-yet-read completed scan advances it first.
+ unreadRef := f.jumioCreate(map[string]any{"merchantScanReference": "merchant-ref-003"})
+ f.vc.Advance(4 * time.Second)
+ if r := f.call("scans", "on_delete_scan", "DELETE", "/netverify/v2/scans/"+unreadRef,
+ map[string]string{"scan_reference": unreadRef}, nil, jumioAuth); r.Status != 200 {
+ t.Fatalf("delete unread scan -> %d: %v", r.Status, r.Body)
+ }
+ all = delivered()
+ if len(all) != 3 {
+ t.Fatalf("%d deliveries after the delete-driven transition, want 3", len(all))
+ }
+ jumioVerifyDelivery(t, all[2])
+ f.jumioStatus(ref)
+ if n := len(delivered()); n != 3 {
+ t.Fatalf("%d deliveries after re-reads, want still 3", n)
+ }
+}
diff --git a/adapters/linkedin-style/scripts/comments.star b/adapters/linkedin-style/scripts/comments.star
index 2d9a8033..c10e0a1e 100644
--- a/adapters/linkedin-style/scripts/comments.star
+++ b/adapters/linkedin-style/scripts/comments.star
@@ -39,7 +39,13 @@ def on_list_comments(req):
start = _to_int(req["query"].get("start", ""))
links = []
if next_cursor != None:
- links.append({"rel": "next", "href": "/rest/comments?start=" + next_cursor})
+ # Real LinkedIn next links carry the whole query; a bare start link
+ # would 400 on the q=author check as soon as it is followed.
+ count = _to_int(req["query"].get("count", ""))
+ links.append({
+ "rel": "next",
+ "href": "/rest/comments?q=author&count=" + str(count) + "&start=" + next_cursor,
+ })
return respond(200, {
"elements": page,
@@ -64,6 +70,14 @@ def on_post_comment(req):
if actor == "urn:li:person:me":
actor = "urn:li:person:" + member["sub"]
+ # A comment is authored by the caller, like ugcPosts' author check.
+ if actor != "urn:li:person:" + member["sub"]:
+ return respond(403, {
+ "status": 403,
+ "code": "FIELDS_DATA_VALIDATION_EXCEPTION",
+ "message": "actor " + actor + " is not the authenticated member",
+ })
+
# Verify the target post exists.
pc = store_collection("posts")
post = pc.get(object_urn)
@@ -79,7 +93,8 @@ def on_post_comment(req):
"actor": actor,
"object": object_urn,
"text": text,
- "ts_ms": 1700000000000,
+ # Engine clock, not a frozen stamp: createdOn tracks when it posted.
+ "ts_ms": clock.now_unix() * 1000,
})
return respond(201, {"id": comment_urn})
diff --git a/adapters/linkedin-style/scripts/oauth.star b/adapters/linkedin-style/scripts/oauth.star
index 237aaac4..e7819072 100644
--- a/adapters/linkedin-style/scripts/oauth.star
+++ b/adapters/linkedin-style/scripts/oauth.star
@@ -119,13 +119,15 @@ def on_access_token(req):
if code_doc == None:
return respond(400, {"error": "invalid_grant", "error_description": "invalid/used code"})
- cc.delete(code)
-
want_cid = code_doc.get("client_id", "")
want_uri = code_doc.get("redirect_uri", "")
if client_id != want_cid or redirect_uri != want_uri or client_secret == "":
+ # Consume only on the matched path: a wrong client must not burn the
+ # code for the right one.
return respond(400, {"error": "invalid_client", "error_description": "client mismatch"})
+ cc.delete(code)
+
return respond(200, _issue_tokens(_mint_member()))
def _contains(s, substr):
diff --git a/adapters/linkedin-style/scripts/posts.star b/adapters/linkedin-style/scripts/posts.star
index c32eec7a..0beb1c44 100644
--- a/adapters/linkedin-style/scripts/posts.star
+++ b/adapters/linkedin-style/scripts/posts.star
@@ -67,9 +67,11 @@ def on_resolve_post(req):
return respond(404, {"status": 404, "message": "post not found"})
seq = doc.get("seq", 0)
+ # The post's own author, not the caller: any member may resolve any post.
+ # Collections round-trip ints as floats; the URN needs bare digits.
return respond(200, {
- "id": "urn:li:share:" + str(seq),
- "author": "urn:li:person:" + member["sub"],
+ "id": "urn:li:share:" + str(int(seq)),
+ "author": doc.get("author", ""),
})
def _extract_text(body):
diff --git a/adapters/linkedin_style_test.go b/adapters/linkedin_style_test.go
new file mode 100644
index 00000000..9a262d45
--- /dev/null
+++ b/adapters/linkedin_style_test.go
@@ -0,0 +1,936 @@
+package adapters
+
+import (
+ "net/url"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// These tests drive the linkedin-style adapter scripts directly (lib.star
+// preloaded) over a shared store and a VIRTUAL clock: the Arctic-style OAuth2
+// authorization-code flow with body-param client credentials, single-use
+// refresh-token rotation, the Bearer gate (LinkedIn's flat {status, code,
+// message} service error envelope, 60-day clock-derived expiry), the
+// token-bound /v2/userinfo profile, ugcPosts publishing with its author
+// authorization and KV-armed rate-limit injection, post resolution (ugcPost
+// -> share URN), the comments ingest/reply pair with q=author scoping and
+// count/start paging whose next link round-trips the query, and the
+// memberCreatorPostAnalytics metric buckets.
+
+// One OAuth client for the whole suite; the adapter accepts any values.
+const (
+ liRedirectURI = "http://localhost:3000/callback"
+ liState = "vm-suite-state"
+ liClientID = "li-vm-client-id"
+ liClientSecret = "li-vm-client-secret"
+ liShareContent = "com.linkedin.ugc.ShareContent"
+ liMetricTypeKey = "com.linkedin.adsexternalapi.memberanalytics.v1.CreatorPostAnalyticsMetricTypeV1"
+)
+
+// liFixture is one shared store + virtual clock with a loaded VM per handler
+// script (oauth, userinfo, posts, comments and analytics each get their own
+// VM, but they observe the same collections/kv state, like the engine).
+type liFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ kv *kv.KV
+ vms map[string]*starlark.VM
+}
+
+func newLinkedinFixture(t *testing.T, start time.Time) *liFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "linkedin-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &liFixture{t: t, vc: vc, kv: kvStore, vms: map[string]*starlark.VM{
+ "oauth": load("oauth.star"), "userinfo": load("userinfo.star"),
+ "posts": load("posts.star"), "comments": load("comments.star"),
+ "analytics": load("analytics.star"),
+ }}
+}
+
+// call invokes handler on the named script VM; auth is the full
+// Authorization header value ("" = header absent).
+func (f *liFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: "api.linkedin.test",
+ Headers: headers, Body: body, Params: params, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// liNum compares a response number against want whether it arrives as an
+// int (handler literal) or a float (round-tripped through a collection).
+func liNum(got any, want int64) bool {
+ switch n := got.(type) {
+ case int64:
+ return n == want
+ case float64:
+ return n == float64(want)
+ }
+ return false
+}
+
+// liInt coerces a response number (int or round-tripped float) to int64.
+func liInt(got any) (int64, bool) {
+ switch n := got.(type) {
+ case int64:
+ return n, true
+ case float64:
+ return int64(n), true
+ }
+ return 0, false
+}
+
+// liAuthorize runs the authorization redirect against redirectURI and
+// returns the single-use code it mints.
+func (f *liFixture) liAuthorize(redirectURI string) string {
+ f.t.Helper()
+ r := f.call("oauth", "on_authorize", "GET", "/oauth/v2/authorization", nil, map[string]string{
+ "client_id": liClientID, "redirect_uri": redirectURI, "state": liState,
+ "response_type": "code", "scope": "openid profile w_member_social email",
+ }, nil, "")
+ if r.Status != 302 {
+ f.t.Fatalf("authorize -> %d: %v", r.Status, r.Body)
+ }
+ loc, err := url.Parse(r.Headers["Location"])
+ if err != nil {
+ f.t.Fatalf("authorize Location %q: %v", r.Headers["Location"], err)
+ }
+ code := loc.Query().Get("code")
+ if code == "" {
+ f.t.Fatalf("authorize Location %q carries no code", r.Headers["Location"])
+ }
+ return code
+}
+
+// liExchangeCode trades an authorization code (body-param client creds, the
+// Arctic convention — no HTTP Basic Auth).
+func (f *liFixture) liExchangeCode(code, clientID, secret, redirectURI string) starlark.Response {
+ f.t.Helper()
+ body := map[string]any{"grant_type": "authorization_code", "code": code}
+ if clientID != "" {
+ body["client_id"] = clientID
+ }
+ if secret != "" {
+ body["client_secret"] = secret
+ }
+ if redirectURI != "" {
+ body["redirect_uri"] = redirectURI
+ }
+ return f.call("oauth", "on_access_token", "POST", "/oauth/v2/accessToken", nil, nil, body, "")
+}
+
+// liRefresh presents a refresh token for rotation.
+func (f *liFixture) liRefresh(token string, withCreds bool) starlark.Response {
+ f.t.Helper()
+ body := map[string]any{"grant_type": "refresh_token", "refresh_token": token}
+ if withCreds {
+ body["client_id"] = liClientID
+ body["client_secret"] = liClientSecret
+ }
+ return f.call("oauth", "on_access_token", "POST", "/oauth/v2/accessToken", nil, nil, body, "")
+}
+
+// liUserinfo resolves a bearer to its member document.
+func (f *liFixture) liUserinfo(token string) starlark.Response {
+ f.t.Helper()
+ return f.call("userinfo", "on_userinfo", "GET", "/v2/userinfo", nil, nil, nil, "Bearer "+token)
+}
+
+// liMint runs the authorization-code flow end to end and returns the minted
+// (token, member sub).
+func (f *liFixture) liMint() (string, string) {
+ f.t.Helper()
+ code := f.liAuthorize(liRedirectURI)
+ r := f.liExchangeCode(code, liClientID, liClientSecret, liRedirectURI)
+ if r.Status != 200 {
+ f.t.Fatalf("accessToken -> %d: %v", r.Status, r.Body)
+ }
+ token, _ := r.Body["access_token"].(string)
+ if token == "" {
+ f.t.Fatalf("accessToken response = %v, want an access_token", r.Body)
+ }
+ u := f.liUserinfo(token)
+ if u.Status != 200 {
+ f.t.Fatalf("userinfo after exchange -> %d: %v", u.Status, u.Body)
+ }
+ sub, _ := u.Body["sub"].(string)
+ return token, sub
+}
+
+// liPublish posts ugcPosts content as author; author may deliberately
+// mismatch the token's member.
+func (f *liFixture) liPublish(token, author, text string) starlark.Response {
+ f.t.Helper()
+ return f.call("posts", "on_ugc_posts", "POST", "/v2/ugcPosts", nil, nil, map[string]any{
+ "author": author,
+ "lifecycleState": "PUBLISHED",
+ "specificContent": map[string]any{
+ liShareContent: map[string]any{
+ "shareCommentary": map[string]any{"text": text},
+ "shareMediaCategory": "NONE",
+ },
+ },
+ "visibility": map[string]any{"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
+ }, "Bearer "+token)
+}
+
+// liResolvePost GETs /rest/posts/{urlencoded urn}.
+func (f *liFixture) liResolvePost(token, urn string) starlark.Response {
+ f.t.Helper()
+ return f.call("posts", "on_resolve_post", "GET", "/rest/posts/"+url.PathEscape(urn),
+ map[string]string{"urn": urn}, nil, nil, "Bearer "+token)
+}
+
+// liComment posts a comment on objectUrn; actor may deliberately mismatch.
+func (f *liFixture) liComment(token, actor, objectUrn, text string) starlark.Response {
+ f.t.Helper()
+ return f.call("comments", "on_post_comment", "POST", "/rest/comments", nil, nil, map[string]any{
+ "actor": actor,
+ "object": objectUrn,
+ "message": map[string]any{"text": text},
+ }, "Bearer "+token)
+}
+
+// liIngest lists the token member's comments (q=author by default).
+func (f *liFixture) liIngest(token string, query map[string]string) starlark.Response {
+ f.t.Helper()
+ if query == nil {
+ query = map[string]string{"q": "author"}
+ }
+ return f.call("comments", "on_list_comments", "GET", "/rest/comments", nil, query, nil, "Bearer "+token)
+}
+
+// liElements returns the response's elements array.
+func liElements(t *testing.T, r starlark.Response) []any {
+ t.Helper()
+ elements, ok := r.Body["elements"].([]any)
+ if !ok {
+ t.Fatalf("response %d elements = %v, want an array", r.Status, r.Body["elements"])
+ }
+ return elements
+}
+
+// liPaging returns the response's paging block.
+func liPaging(t *testing.T, r starlark.Response) map[string]any {
+ t.Helper()
+ paging, ok := r.Body["paging"].(map[string]any)
+ if !ok {
+ t.Fatalf("response %d carries no paging block: %v", r.Status, r.Body)
+ }
+ return paging
+}
+
+// TestLinkedinOAuthAuthorizationCodeFlow: the authorize redirect demands its
+// three params and redirects with a fresh code + state echo, the exchange is
+// grant-type checked with body-param client credentials, codes are
+// single-use, and client mismatches are rejected without burning the code.
+func TestLinkedinOAuthAuthorizationCodeFlow(t *testing.T) {
+ f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ authorize := func(query map[string]string) starlark.Response {
+ return f.call("oauth", "on_authorize", "GET", "/oauth/v2/authorization", nil, query, nil, "")
+ }
+
+ // ===== authorize without redirect_uri, state or client_id is invalid_request =====
+ // Each missing param alone is a 400.
+ for name, query := range map[string]map[string]string{
+ "no redirect_uri": {"client_id": liClientID, "state": liState},
+ "no state": {"client_id": liClientID, "redirect_uri": liRedirectURI},
+ "no client_id": {"state": liState, "redirect_uri": liRedirectURI},
+ } {
+ if r := authorize(query); r.Status != 400 || r.Body["error"] != "invalid_request" {
+ t.Fatalf("authorize %s -> %d %v, want 400 invalid_request", name, r.Status, r.Body)
+ }
+ }
+
+ // ===== authorize redirects back with a fresh code and the state echoed =====
+ code := f.liAuthorize(liRedirectURI)
+ if !strings.HasPrefix(code, "mock_code_") {
+ t.Fatalf("authorize code = %q, want a mock_code_* mint", code)
+ }
+ r := authorize(map[string]string{
+ "client_id": liClientID, "redirect_uri": liRedirectURI, "state": liState,
+ })
+ loc, err := url.Parse(r.Headers["Location"])
+ if err != nil {
+ t.Fatalf("authorize Location %q: %v", r.Headers["Location"], err)
+ }
+ if loc.Query().Get("state") != liState {
+ t.Fatalf("authorize Location %q, want the caller's state echoed", r.Headers["Location"])
+ }
+
+ // ===== a redirect_uri that already carries a query is joined with & =====
+ r = authorize(map[string]string{
+ "client_id": liClientID, "redirect_uri": "http://localhost:3000/cb?from=vm", "state": liState,
+ })
+ if loc := r.Headers["Location"]; !strings.Contains(loc, "cb?from=vm&code=") {
+ t.Fatalf("authorize Location = %q, want the existing query joined with &", loc)
+ }
+
+ // ===== the exchange demands grant_type=authorization_code =====
+ // Wrong grant types are 400 unsupported_grant_type (RFC 6749 shape).
+ if r := f.call("oauth", "on_access_token", "POST", "/oauth/v2/accessToken", nil, nil,
+ map[string]any{"grant_type": "client_credentials", "code": code}, ""); r.Status != 400 ||
+ r.Body["error"] != "unsupported_grant_type" {
+ t.Fatalf("client_credentials grant -> %d %v, want 400 unsupported_grant_type", r.Status, r.Body)
+ }
+
+ // ===== an unknown code is 400 invalid_grant =====
+ if r := f.liExchangeCode("mock_code_nope", liClientID, liClientSecret, liRedirectURI); r.Status != 400 ||
+ r.Body["error"] != "invalid_grant" {
+ t.Fatalf("unknown code -> %d %v, want 400 invalid_grant", r.Status, r.Body)
+ }
+
+ // ===== a good exchange mints a 60-day token pair for a fresh member =====
+ // Client creds ride the body (Arctic), never HTTP Basic Auth.
+ fresh := f.liAuthorize(liRedirectURI)
+ r = f.liExchangeCode(fresh, liClientID, liClientSecret, liRedirectURI)
+ if r.Status != 200 {
+ t.Fatalf("access_token -> %d: %v", r.Status, r.Body)
+ }
+ access, _ := r.Body["access_token"].(string)
+ refresh, _ := r.Body["refresh_token"].(string)
+ if !strings.HasPrefix(access, "mock_access_") || !strings.HasPrefix(refresh, "mock_refresh_") {
+ t.Fatalf("exchange minted %q / %q, want mock_access_* / mock_refresh_*", access, refresh)
+ }
+ if !liNum(r.Body["expires_in"], 5184000) {
+ t.Fatalf("expires_in = %v (%T), want 5184000 (60 days)", r.Body["expires_in"], r.Body["expires_in"])
+ }
+ if r.Body["scope"] != "openid profile w_member_social email" {
+ t.Fatalf("scope = %v, want the member/social scope set", r.Body["scope"])
+ }
+
+ // ===== the code is single-use: a replay is invalid_grant =====
+ if r := f.liExchangeCode(fresh, liClientID, liClientSecret, liRedirectURI); r.Status != 400 ||
+ r.Body["error"] != "invalid_grant" {
+ t.Fatalf("code replay -> %d %v, want 400 invalid_grant (single-use)", r.Status, r.Body)
+ }
+
+ // ===== client mismatches are 400 invalid_client =====
+ // client_id, redirect_uri and client_secret must all match the authorize;
+ // each case gets a fresh code (codes are single-use).
+ for _, tc := range []struct{ name, clientID, secret, redirectURI string }{
+ {"wrong client_id", "attacker-app", liClientSecret, liRedirectURI},
+ {"wrong redirect_uri", liClientID, liClientSecret, "http://evil.test/cb"},
+ {"missing secret", liClientID, "", liRedirectURI},
+ } {
+ r := f.liExchangeCode(f.liAuthorize(liRedirectURI), tc.clientID, tc.secret, tc.redirectURI)
+ if r.Status != 400 || r.Body["error"] != "invalid_client" {
+ t.Fatalf("exchange %s -> %d %v, want 400 invalid_client", tc.name, r.Status, r.Body)
+ }
+ }
+
+ // ===== a mismatched attempt must not burn the code =====
+ // The wrong client's failed exchange leaves the code redeemable by the
+ // right one (the delete happens on the matched path only).
+ code3 := f.liAuthorize(liRedirectURI)
+ f.liExchangeCode(code3, "attacker-app", liClientSecret, liRedirectURI) // 400, above
+ if r := f.liExchangeCode(code3, liClientID, liClientSecret, liRedirectURI); r.Status != 200 {
+ t.Fatalf("right client after a mismatched attempt -> %d %v, want 200 (code not burned)", r.Status, r.Body)
+ }
+
+ // ===== a second flow mints a distinct member =====
+ _, sub1 := f.liMint()
+ _, sub2 := f.liMint()
+ if sub1 == "" || sub2 == "" || sub1 == sub2 {
+ t.Fatalf("flows minted subs %q / %q, want distinct non-empty members", sub1, sub2)
+ }
+}
+
+// TestLinkedinRefreshTokenRotation: the refresh grant demands body-param
+// client creds, rotates the pair single-use while keeping the same member,
+// leaves the old access token valid until its own expiry, and chains.
+func TestLinkedinRefreshTokenRotation(t *testing.T) {
+ f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ code := f.liAuthorize(liRedirectURI)
+ r := f.liExchangeCode(code, liClientID, liClientSecret, liRedirectURI)
+ if r.Status != 200 {
+ t.Fatalf("access_token -> %d: %v", r.Status, r.Body)
+ }
+ access1, _ := r.Body["access_token"].(string)
+ refresh1, _ := r.Body["refresh_token"].(string)
+ sub1, _ := f.liUserinfo(access1).Body["sub"].(string)
+
+ // ===== the refresh grant demands client creds =====
+ if r := f.liRefresh(refresh1, false); r.Status != 400 || r.Body["error"] != "invalid_client" {
+ t.Fatalf("refresh without client creds -> %d %v, want 400 invalid_client", r.Status, r.Body)
+ }
+
+ // ===== an unknown refresh token is invalid_grant =====
+ if r := f.liRefresh("mock_refresh_nope", true); r.Status != 400 || r.Body["error"] != "invalid_grant" {
+ t.Fatalf("unknown refresh token -> %d %v, want 400 invalid_grant", r.Status, r.Body)
+ }
+
+ // ===== refresh rotates the pair and keeps the member =====
+ r = f.liRefresh(refresh1, true)
+ if r.Status != 200 {
+ t.Fatalf("refresh -> %d: %v", r.Status, r.Body)
+ }
+ access2, _ := r.Body["access_token"].(string)
+ refresh2, _ := r.Body["refresh_token"].(string)
+ if access2 == "" || access2 == access1 || refresh2 == "" || refresh2 == refresh1 {
+ t.Fatalf("refresh minted %q / %q, want a fresh pair distinct from %q / %q", access2, refresh2, access1, refresh1)
+ }
+ if !liNum(r.Body["expires_in"], 5184000) {
+ t.Fatalf("refresh expires_in = %v, want 5184000", r.Body["expires_in"])
+ }
+ u := f.liUserinfo(access2)
+ if u.Status != 200 || u.Body["sub"] != sub1 {
+ t.Fatalf("userinfo with rotated token -> %d %v, want the same member %q", u.Status, u.Body, sub1)
+ }
+ // Rotation consumes only the refresh token: the old access token stays
+ // valid until its own expiry, like real LinkedIn.
+ if u := f.liUserinfo(access1); u.Status != 200 {
+ t.Fatalf("original access token after refresh -> %d, want still valid", u.Status)
+ }
+
+ // ===== the presented refresh token is single-use =====
+ if r := f.liRefresh(refresh1, true); r.Status != 400 || r.Body["error"] != "invalid_grant" {
+ t.Fatalf("refresh replay -> %d %v, want 400 invalid_grant (single-use rotation)", r.Status, r.Body)
+ }
+
+ // ===== rotation chains: the new refresh token refreshes again =====
+ if r := f.liRefresh(refresh2, true); r.Status != 200 {
+ t.Fatalf("second-generation refresh -> %d %v, want 200", r.Status, r.Body)
+ }
+}
+
+// TestLinkedinBearerGate: every API route validates the Bearer against the
+// tokens collection — missing, wrong-scheme, unknown and expired tokens all
+// answer 401 in LinkedIn's service error envelope {status, code: AUTHORIZED}.
+func TestLinkedinBearerGate(t *testing.T) {
+ f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ token, _ := f.liMint()
+
+ // ===== a missing bearer is 401 in the service error envelope =====
+ // The envelope is flat {status, code, message} (no nested "error").
+ r := f.liUserinfo("")
+ if r.Status != 401 {
+ t.Fatalf("no bearer -> %d, want 401", r.Status)
+ }
+ if !liNum(r.Body["status"], 401) || r.Body["code"] != "AUTHORIZED" || r.Body["message"] == nil {
+ t.Fatalf("401 envelope = %v, want status 401 code AUTHORIZED with a message", r.Body)
+ }
+ if _, nested := r.Body["error"]; nested {
+ t.Fatalf("401 envelope nests an error object = %v, want the flat shape", r.Body)
+ }
+
+ // ===== wrong schemes and unknown bearers answer the same 401 =====
+ for _, auth := range []string{
+ "Token " + token, // wrong scheme
+ token, // bare token, no scheme
+ "Bearer totally-fake-token",
+ } {
+ r := f.call("userinfo", "on_userinfo", "GET", "/v2/userinfo", nil, nil, nil, auth)
+ if r.Status != 401 || r.Body["code"] != "AUTHORIZED" {
+ t.Fatalf("auth %q -> %d %v, want 401 AUTHORIZED", auth, r.Status, r.Body)
+ }
+ }
+
+ // ===== every API route enforces the same gate =====
+ // All six API handlers reject a missing and an unknown bearer. The 401
+ // message string varies by route (userinfo's longer text vs "token") —
+ // asserted as-is.
+ routes := []struct {
+ group, handler, method, path string
+ params, query map[string]string
+ body map[string]any
+ }{
+ {"userinfo", "on_userinfo", "GET", "/v2/userinfo", nil, nil, nil},
+ {"posts", "on_ugc_posts", "POST", "/v2/ugcPosts", nil, nil, nil},
+ {"posts", "on_resolve_post", "GET", "/rest/posts/urn:li:ugcPost:1",
+ map[string]string{"urn": "urn:li:ugcPost:1"}, nil, nil},
+ {"comments", "on_list_comments", "GET", "/rest/comments", nil, map[string]string{"q": "author"}, nil},
+ {"comments", "on_post_comment", "POST", "/rest/comments", nil, nil, nil},
+ {"analytics", "on_analytics", "GET", "/rest/memberCreatorPostAnalytics",
+ nil, map[string]string{"q": "entity", "entity": "(ugcPost:urn:li:ugcPost:1)", "queryType": "REACTION"}, nil},
+ }
+ for _, rt := range routes {
+ for _, auth := range []string{"", "Bearer unknown-token"} {
+ r := f.call(rt.group, rt.handler, rt.method, rt.path, rt.params, rt.query, rt.body, auth)
+ if r.Status != 401 {
+ t.Fatalf("%s (auth %q) -> %d, want 401", rt.path, auth, r.Status)
+ }
+ if r.Body["code"] != "AUTHORIZED" || !liNum(r.Body["status"], 401) {
+ t.Fatalf("%s 401 envelope = %v, want the AUTHORIZED shape", rt.path, r.Body)
+ }
+ }
+ }
+
+ // ===== a bearer dies at its clock-derived 60-day expiry =====
+ // expires_at is minted from the engine clock; the clock is virtual.
+ f.vc.Advance(61 * 24 * time.Hour)
+ if r := f.liUserinfo(token); r.Status != 401 || r.Body["code"] != "AUTHORIZED" {
+ t.Fatalf("expired bearer -> %d %v, want 401 AUTHORIZED", r.Status, r.Body)
+ }
+}
+
+// TestLinkedinUserinfoAndPublish: /v2/userinfo answers the member bound to
+// the bearer, ugcPosts authorizes the author against that member, mints a
+// urn:li:ugcPost echoed in the x-linkedin-id header, and /rest/posts/{urn}
+// resolves it to a share URN carrying the post's own author.
+func TestLinkedinUserinfoAndPublish(t *testing.T) {
+ f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ token, sub := f.liMint()
+ person := "urn:li:person:" + sub
+
+ // ===== userinfo returns the OAuth member profile =====
+ u := f.liUserinfo(token)
+ if u.Status != 200 {
+ t.Fatalf("userinfo -> %d: %v", u.Status, u.Body)
+ }
+ if u.Body["sub"] != sub || !strings.HasPrefix(sub, "mock-member-") {
+ t.Fatalf("userinfo sub = %v, want the minted mock-member-*", u.Body["sub"])
+ }
+ name, _ := u.Body["name"].(string)
+ email, _ := u.Body["email"].(string)
+ picture, _ := u.Body["picture"].(string)
+ if !strings.HasPrefix(name, "Mock Member ") || !strings.Contains(email, "@example.test") ||
+ !strings.Contains(picture, sub) {
+ t.Fatalf("userinfo profile = %v, want the minted name/email/picture", u.Body)
+ }
+
+ // ===== publishing as anyone but the token's member is a 403 =====
+ for _, author := range []string{"urn:li:person:someone-else", ""} {
+ r := f.liPublish(token, author, "forged")
+ if r.Status != 403 {
+ t.Fatalf("publish as %q -> %d, want 403", author, r.Status)
+ }
+ if r.Body["code"] != "FIELDS_DATA_VALIDATION_EXCEPTION" || !liNum(r.Body["status"], 403) {
+ t.Fatalf("publish 403 envelope = %v, want FIELDS_DATA_VALIDATION_EXCEPTION", r.Body)
+ }
+ }
+
+ // ===== a good publish mints a ugcPost urn echoed in x-linkedin-id =====
+ r := f.liPublish(token, person, "hello from the VM suite")
+ if r.Status != 201 {
+ t.Fatalf("publish -> %d: %v", r.Status, r.Body)
+ }
+ urn, _ := r.Body["id"].(string)
+ if !strings.HasPrefix(urn, "urn:li:ugcPost:") {
+ t.Fatalf("publish id = %v, want a urn:li:ugcPost:* mint", r.Body["id"])
+ }
+ if r.Headers["x-linkedin-id"] != urn {
+ t.Fatalf("x-linkedin-id = %q, want the created urn %q", r.Headers["x-linkedin-id"], urn)
+ }
+
+ // ===== the post resolves to a share urn carrying its own author =====
+ // Any member may resolve any post; the author is the post's, not the
+ // caller's.
+ seq := strings.TrimPrefix(urn, "urn:li:ugcPost:")
+ res := f.liResolvePost(token, urn)
+ if res.Status != 200 {
+ t.Fatalf("resolve -> %d: %v", res.Status, res.Body)
+ }
+ if res.Body["id"] != "urn:li:share:"+seq || res.Body["author"] != person {
+ t.Fatalf("resolve = %v, want share urn for seq %s authored by %s", res.Body, seq, person)
+ }
+ token2, _ := f.liMint()
+ if res := f.liResolvePost(token2, urn); res.Status != 200 || res.Body["author"] != person {
+ t.Fatalf("resolve by a second member = %v, want the post's own author %s", res.Body, person)
+ }
+
+ // ===== resolving an unknown urn is a 404 =====
+ if r := f.liResolvePost(token, "urn:li:ugcPost:999999"); r.Status != 404 ||
+ !liNum(r.Body["status"], 404) || r.Body["message"] != "post not found" {
+ t.Fatalf("resolve unknown urn -> %d %v, want 404 post not found", r.Status, r.Body)
+ }
+}
+
+// TestLinkedinPublishRateLimit: the publish path counts posts per member URN
+// and, once the linkedin/fail_after KV knob is armed, answers 429
+// REQUEST_LIMIT_EXCEEDED past the threshold without consuming a post seq.
+func TestLinkedinPublishRateLimit(t *testing.T) {
+ f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ token, sub := f.liMint()
+ person := "urn:li:person:" + sub
+
+ seqOf := func(r starlark.Response) int64 {
+ f.t.Helper()
+ urn, _ := r.Body["id"].(string)
+ n, err := strconv.ParseInt(strings.TrimPrefix(urn, "urn:li:ugcPost:"), 10, 64)
+ if err != nil {
+ f.t.Fatalf("post urn %q carries no numeric seq: %v", urn, err)
+ }
+ return n
+ }
+
+ // ===== unconfigured, publishing is unthrottled =====
+ lastSeq := int64(0)
+ for i := 0; i < 5; i++ {
+ r := f.liPublish(token, person, "burst")
+ if r.Status != 201 {
+ t.Fatalf("publish %d with no fail_after -> %d %v, want 201", i+1, r.Status, r.Body)
+ }
+ lastSeq = seqOf(r)
+ }
+ if lastSeq < 5 {
+ t.Fatalf("five publishes advanced the seq to %d, want >= 5", lastSeq)
+ }
+
+ // ===== arming fail_after injects 429 REQUEST_LIMIT_EXCEEDED =====
+ if err := f.kv.Set("linkedin", "fail_after", "1"); err != nil {
+ t.Fatalf("arm fail_after: %v", err)
+ }
+ for i := 0; i < 2; i++ {
+ r := f.liPublish(token, person, "over the limit")
+ if r.Status != 429 {
+ t.Fatalf("publish past fail_after=1 (#%d) -> %d %v, want 429", i+1, r.Status, r.Body)
+ }
+ if r.Body["code"] != "REQUEST_LIMIT_EXCEEDED" || !liNum(r.Body["status"], 429) {
+ t.Fatalf("429 envelope = %v, want REQUEST_LIMIT_EXCEEDED", r.Body)
+ }
+ }
+
+ // ===== the limit is per member =====
+ token2, sub2 := f.liMint()
+ r2 := f.liPublish(token2, "urn:li:person:"+sub2, "unaffected")
+ if r2.Status != 201 {
+ t.Fatalf("second member publish while the first is throttled -> %d %v, want 201", r2.Status, r2.Body)
+ }
+ bSeq := seqOf(r2)
+
+ // ===== a throttled attempt creates no post =====
+ // The 429 path returns before post_seq is consumed, so the next accepted
+ // post (after disarming) takes the very next seq after B's.
+ if err := f.kv.Delete("linkedin", "fail_after"); err != nil {
+ t.Fatalf("disarm fail_after: %v", err)
+ }
+ next := seqOf(f.liPublish(token, person, "back online"))
+ if next != bSeq+1 {
+ t.Fatalf("first post after the 429s has seq %d, want %d (no seqs burned)", next, bSeq+1)
+ }
+ if r := f.liResolvePost(token, "urn:li:ugcPost:"+strconv.FormatInt(next+1, 10)); r.Status != 404 {
+ t.Fatalf("resolve the seq after the last created -> %d, want 404 (throttled posts were never stored)", r.Status)
+ }
+}
+
+// TestLinkedinCommentsIngestReply: POST /rest/comments resolves
+// urn:li:person:me to the authenticated member (and refuses any other actor),
+// 404s unknown objects, and GET /rest/comments?q=author lists only the token
+// member's comments with clock-stamped createdOn and count/start paging whose
+// next link round-trips the query.
+func TestLinkedinCommentsIngestReply(t *testing.T) {
+ f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ tokenA, subA := f.liMint()
+ personA := "urn:li:person:" + subA
+ tokenB, subB := f.liMint()
+ personB := "urn:li:person:" + subB
+
+ post, _ := f.liPublish(tokenA, personA, "the commented post").Body["id"].(string)
+ if post == "" {
+ t.Fatal("setup publish failed")
+ }
+
+ // ===== q must be author =====
+ for _, q := range []string{"", "reader"} {
+ query := map[string]string{}
+ if q != "" {
+ query["q"] = q
+ }
+ if r := f.liIngest(tokenA, query); r.Status != 400 || r.Body["message"] != "unsupported query" {
+ t.Fatalf("q=%q -> %d %v, want 400 unsupported query", q, r.Status, r.Body)
+ }
+ }
+
+ // ===== reply resolves urn:li:person:me to the authenticated member =====
+ // Three comments for A an hour apart (virtual clock); the ids round-trip
+ // through ingest below.
+ var idsA []string
+ for i, text := range []string{"first", "second", "third"} {
+ if i > 0 {
+ f.vc.Advance(time.Hour)
+ }
+ r := f.liComment(tokenA, "urn:li:person:me", post, text)
+ if r.Status != 201 {
+ t.Fatalf("comment %d -> %d: %v", i+1, r.Status, r.Body)
+ }
+ id, _ := r.Body["id"].(string)
+ if !strings.HasPrefix(id, "urn:li:comment:") {
+ t.Fatalf("comment id = %v, want a urn:li:comment:* mint", r.Body["id"])
+ }
+ idsA = append(idsA, id)
+ }
+ rB := f.liComment(tokenB, "urn:li:person:me", post, "from member B")
+ if rB.Status != 201 {
+ t.Fatalf("member B comment -> %d: %v", rB.Status, rB.Body)
+ }
+ idB, _ := rB.Body["id"].(string)
+
+ // ===== commenting as anyone but the caller is a 403 =====
+ for _, actor := range []string{personB, ""} {
+ r := f.liComment(tokenA, actor, post, "forged")
+ if r.Status != 403 || r.Body["code"] != "FIELDS_DATA_VALIDATION_EXCEPTION" {
+ t.Fatalf("comment as %q -> %d %v, want 403 FIELDS_DATA_VALIDATION_EXCEPTION", actor, r.Status, r.Body)
+ }
+ }
+
+ // ===== replying to an unknown object is a 404 =====
+ if r := f.liComment(tokenA, "urn:li:person:me", "urn:li:ugcPost:999999", "orphan"); r.Status != 404 ||
+ r.Body["message"] != "object not found" {
+ t.Fatalf("comment on unknown object -> %d %v, want 404 object not found", r.Status, r.Body)
+ }
+
+ // ===== ingest lists only the token member's comments =====
+ r := f.liIngest(tokenA, nil)
+ if r.Status != 200 {
+ t.Fatalf("ingest -> %d: %v", r.Status, r.Body)
+ }
+ elements := liElements(t, r)
+ if len(elements) != 3 {
+ t.Fatalf("member A ingest has %d elements, want its 3", len(elements))
+ }
+ for i, item := range elements {
+ c := item.(map[string]any)
+ if c["id"] != idsA[i] {
+ t.Fatalf("elements[%d].id = %v, want %s (insertion order)", i, c["id"], idsA[i])
+ }
+ if c["actor"] != personA || c["object"] != post {
+ t.Fatalf("elements[%d] actor/object = %v / %v, want %s / %s", i, c["actor"], c["object"], personA, post)
+ }
+ msg, _ := c["message"].(map[string]any)
+ if msg["text"] == "" {
+ t.Fatalf("elements[%d].message.text = %v, want non-empty", i, msg["text"])
+ }
+ if _, has := c["lastModified"]; !has {
+ t.Fatalf("elements[%d] carries no lastModified: %v", i, c)
+ }
+ }
+
+ // ===== member B's comment resolved me and lists only under B =====
+ elementsB := liElements(t, f.liIngest(tokenB, nil))
+ if len(elementsB) != 1 || elementsB[0].(map[string]any)["id"] != idB {
+ t.Fatalf("member B ingest = %v, want only its comment %s", elementsB, idB)
+ }
+ if got := elementsB[0].(map[string]any)["actor"]; got != personB {
+ t.Fatalf("member B comment actor = %v, want %s (me resolved to the member)", got, personB)
+ }
+
+ // ===== createdOn is clock-stamped and monotonic =====
+ // The stamps advance with the virtual clock (one hour per comment), not a
+ // frozen constant.
+ stamps := make([]int64, 0, len(elements))
+ for i, item := range elements {
+ lm := item.(map[string]any)["lastModified"].(map[string]any)
+ n, ok := liInt(lm["createdOn"])
+ if !ok {
+ t.Fatalf("createdOn = %v (%T), want an epoch-ms number", lm["createdOn"], lm["createdOn"])
+ }
+ stamps = append(stamps, n)
+ if stamps[0] < 1_750_000_000_000 {
+ t.Fatalf("createdOn[%d] = %d, want at least the virtual clock start in ms", i, n)
+ }
+ }
+ for i := 1; i < len(stamps); i++ {
+ if stamps[i] <= stamps[i-1] {
+ t.Fatalf("createdOn not monotonic: %v", stamps)
+ }
+ }
+ if stamps[2]-stamps[0] < 2*3600*1000 {
+ t.Fatalf("createdOn spread = %d ms, want at least the two 1h advances", stamps[2]-stamps[0])
+ }
+
+ // ===== count pages with a next link that round-trips the query =====
+ r = f.liIngest(tokenA, map[string]string{"q": "author", "count": "2"})
+ page1 := liElements(t, r)
+ if len(page1) != 2 || page1[0].(map[string]any)["id"] != idsA[0] {
+ t.Fatalf("page 1 = %v, want the first two comments", page1)
+ }
+ paging := liPaging(t, r)
+ if !liNum(paging["count"], 2) || !liNum(paging["start"], 0) {
+ t.Fatalf("page 1 paging = %v, want count 2 start 0", paging)
+ }
+ links, _ := paging["links"].([]any)
+ if len(links) != 1 {
+ t.Fatalf("page 1 links = %v, want exactly the next link", paging["links"])
+ }
+ link := links[0].(map[string]any)
+ wantHref := "/rest/comments?q=author&count=2&start=2"
+ if link["rel"] != "next" || link["href"] != wantHref {
+ t.Fatalf("next link = %v, want %q (the query survives the hop)", link, wantHref)
+ }
+ // Following the advertised href verbatim must not 400 on the q check.
+ r = f.liIngest(tokenA, map[string]string{"q": "author", "count": "2", "start": "2"})
+ page2 := liElements(t, r)
+ if len(page2) != 1 || page2[0].(map[string]any)["id"] != idsA[2] {
+ t.Fatalf("page 2 = %v, want the last comment", page2)
+ }
+ paging = liPaging(t, r)
+ if !liNum(paging["count"], 1) || !liNum(paging["start"], 2) {
+ t.Fatalf("page 2 paging = %v, want count 1 start 2", paging)
+ }
+ if links, _ := paging["links"].([]any); len(links) != 0 {
+ t.Fatalf("final page links = %v, want none", paging["links"])
+ }
+
+ // ===== without count the whole list returns unpaged =====
+ r = f.liIngest(tokenA, nil)
+ if got := len(liElements(t, r)); got != 3 {
+ t.Fatalf("unpaged ingest has %d elements, want all 3", got)
+ }
+ if links, _ := liPaging(t, r)["links"].([]any); len(links) != 0 {
+ t.Fatalf("unpaged ingest links = %v, want none", liPaging(t, r)["links"])
+ }
+
+ // ===== a malformed start cursor is a 400 =====
+ if r := f.liIngest(tokenA, map[string]string{"q": "author", "count": "2", "start": "zzz"}); r.Status != 400 ||
+ r.Body["message"] != "Invalid start parameter." {
+ t.Fatalf("bad start -> %d %v, want 400 Invalid start parameter.", r.Status, r.Body)
+ }
+}
+
+// TestLinkedinMemberCreatorPostAnalytics: the analytics endpoint verifies the
+// ugcPost entity, totals per queryType off the post seq (REACTION +3,
+// COMMENT +5, RESHARE +7, IMPRESSION +11) split across two daily buckets,
+// accepts the parenthesized and bare entity forms, and pages to empty past
+// the data.
+func TestLinkedinMemberCreatorPostAnalytics(t *testing.T) {
+ f := newLinkedinFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ token, sub := f.liMint()
+ post, _ := f.liPublish(token, "urn:li:person:"+sub, "the measured post").Body["id"].(string)
+ if post == "" {
+ t.Fatal("setup publish failed")
+ }
+ base, err := strconv.ParseInt(strings.TrimPrefix(post, "urn:li:ugcPost:"), 10, 64)
+ if err != nil {
+ t.Fatalf("post urn %q carries no numeric seq: %v", post, err)
+ }
+ analytics := func(entity, queryType, start string) starlark.Response {
+ query := map[string]string{
+ "q": "entity", "entity": entity, "queryType": queryType,
+ "timeGranularity": "DAY", "dateRange": "(start:20250601,end:20250630)",
+ }
+ if start != "" {
+ query["start"] = start
+ }
+ return f.call("analytics", "on_analytics", "GET", "/rest/memberCreatorPostAnalytics",
+ nil, query, nil, "Bearer "+token)
+ }
+ bucketTotal := func(r starlark.Response) int64 {
+ f.t.Helper()
+ total := int64(0)
+ for _, item := range liElements(t, r) {
+ n, ok := liInt(item.(map[string]any)["count"])
+ if !ok {
+ f.t.Fatalf("bucket count = %v, want a number", item.(map[string]any)["count"])
+ }
+ total += n
+ }
+ return total
+ }
+
+ // ===== an unknown entity is a 404 =====
+ if r := analytics("(ugcPost:urn:li:ugcPost:999999)", "REACTION", ""); r.Status != 404 ||
+ r.Body["message"] != "entity not found" {
+ t.Fatalf("unknown entity -> %d %v, want 404 entity not found", r.Status, r.Body)
+ }
+
+ // ===== each queryType totals base+3/5/7/11 split across two daily buckets =====
+ for queryType, delta := range map[string]int64{"REACTION": 3, "COMMENT": 5, "RESHARE": 7, "IMPRESSION": 11} {
+ r := analytics("(ugcPost:"+post+")", queryType, "")
+ if r.Status != 200 {
+ t.Fatalf("%s -> %d: %v", queryType, r.Status, r.Body)
+ }
+ elements := liElements(t, r)
+ if len(elements) != 2 {
+ t.Fatalf("%s returned %d buckets, want 2 daily buckets", queryType, len(elements))
+ }
+ for i, item := range elements {
+ b := item.(map[string]any)
+ metric := b["metricType"].(map[string]any)[liMetricTypeKey]
+ if metric != queryType {
+ t.Fatalf("%s bucket[%d].metricType = %v, want %q under the long key", queryType, i, metric, queryType)
+ }
+ target := b["targetEntity"].(map[string]any)
+ if target["ugcPost"] != post {
+ t.Fatalf("%s bucket[%d].targetEntity = %v, want the post %s", queryType, i, target, post)
+ }
+ // Synthetic daily ranges: consecutive June-2026 days.
+ dr := b["dateRange"].(map[string]any)
+ if day, ok := liInt(dr["start"].(map[string]any)["day"]); !ok || day != int64(1+i) {
+ t.Fatalf("%s bucket[%d].dateRange.start.day = %v, want %d", queryType, i, dr["start"], 1+i)
+ }
+ }
+ if total := bucketTotal(r); total != base+delta {
+ t.Fatalf("%s buckets total %d, want base %d + %d", queryType, total, base, delta)
+ }
+ paging := liPaging(t, r)
+ if !liNum(paging["count"], 2) || !liNum(paging["start"], 0) {
+ t.Fatalf("%s paging = %v, want count 2 start 0", queryType, paging)
+ }
+ }
+
+ // ===== entity accepts both the parenthesized and bare urn forms =====
+ paren := analytics("(ugcPost:"+post+")", "REACTION", "").Body["elements"]
+ bare := analytics(post, "REACTION", "").Body["elements"]
+ if !reflect.DeepEqual(paren, bare) {
+ t.Fatalf("parenthesized vs bare entity disagree: %v vs %v", paren, bare)
+ }
+
+ // ===== start past the data returns an empty page =====
+ r := analytics("(ugcPost:"+post+")", "REACTION", "1")
+ if got := len(liElements(t, r)); got != 0 {
+ t.Fatalf("start=1 -> %d elements, want an empty page", got)
+ }
+ if paging := liPaging(t, r); !liNum(paging["count"], 0) || !liNum(paging["start"], 1) {
+ t.Fatalf("empty page paging = %v, want count 0 start 1", paging)
+ }
+
+ // ===== an unknown queryType falls back to the base total (deviation, as-is) =====
+ // Real LinkedIn rejects an invalid queryType; the adapter totals base.
+ r = analytics("(ugcPost:"+post+")", "BOGUS", "")
+ if r.Status != 200 {
+ t.Fatalf("queryType=BOGUS -> %d %v, want 200 (lenient fallback)", r.Status, r.Body)
+ }
+ if total := bucketTotal(r); total != base {
+ t.Fatalf("queryType=BOGUS total = %d, want the bare base %d", total, base)
+ }
+}
diff --git a/adapters/oneinch_style_test.go b/adapters/oneinch_style_test.go
new file mode 100644
index 00000000..272bdeab
--- /dev/null
+++ b/adapters/oneinch_style_test.go
@@ -0,0 +1,398 @@
+package adapters
+
+import (
+ "math/big"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the oneinch-style adapter scripts directly (lib.star preloaded)
+// over a shared store: the v6.0 quote/swap response shapes (token pairs,
+// decimal toAmount strings, the 100-point protocol split, router-addressed
+// calldata), the approve spender/calldata flow, the address-keyed token
+// list, and the 400 error envelopes for missing params and unknown tokens.
+// The API is public: no auth gate to exercise.
+const (
+ oiETHAddr = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEe"
+ oiUSDCAddr = "0xA0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48"
+ oiUSDTAddr = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
+ oiRouter = "0x1111111254EEB25477B68fb85Ed929f73A960582"
+ oiTrader = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
+ oiOneETH = "1000000000000000000"
+ oiOneUSDC = "1000000"
+ oiHost = "api.1inch.test"
+)
+
+// oiCalldataRE pins the synthetic calldata shape: 0x + lowercase hex.
+var oiCalldataRE = regexp.MustCompile(`^0x[0-9a-f]+$`)
+
+type oneinchFixture struct {
+ t *testing.T
+ vms map[string]*starlark.VM
+}
+
+func newOneinchFixture(t *testing.T) *oneinchFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "oneinch-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(time.Unix(1_750_000_000, 0).UTC())
+ em := events.NewEmitter()
+ t.Cleanup(em.Close)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: em,
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &oneinchFixture{t: t, vms: map[string]*starlark.VM{
+ "swap": load("swap.star"), "approve": load("approve.star"), "tokens": load("tokens.star"),
+ }}
+}
+
+// call invokes handler on the named script VM with query parameters.
+func (f *oneinchFixture) call(group, handler, method, path string, query map[string]string) starlark.Response {
+ f.t.Helper()
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: oiHost, Headers: map[string]string{}, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// oiQuote fetches GET /v6.0/1/quote.
+func (f *oneinchFixture) oiQuote(src, dst, amount string) starlark.Response {
+ f.t.Helper()
+ return f.call("swap", "on_quote", "GET", "/v6.0/1/quote",
+ map[string]string{"src": src, "dst": dst, "amount": amount})
+}
+
+// oiAmount parses a decimal-string amount into a big.Int.
+func oiAmount(t *testing.T, s any) *big.Int {
+ t.Helper()
+ str, ok := s.(string)
+ if !ok {
+ t.Fatalf("amount = %v (%T), want a decimal string", s, s)
+ }
+ n, ok := new(big.Int).SetString(str, 10)
+ if !ok {
+ t.Fatalf("amount %q is not a plain decimal integer", str)
+ }
+ return n
+}
+
+// oiNum compares a response number against want whether it arrives as an
+// int (handler literal) or a float (round-tripped through a collection).
+func oiNum(got any, want int64) bool {
+ switch n := got.(type) {
+ case int64:
+ return n == want
+ case float64:
+ return n == float64(want)
+ }
+ return false
+}
+
+// oiDesc asserts the 1inch error envelope and returns its description.
+func oiDesc(t *testing.T, r starlark.Response) string {
+ t.Helper()
+ if !oiNum(r.Body["error"], 400) {
+ t.Fatalf("error envelope = %v, want {error:400, description}", r.Body)
+ }
+ desc, ok := r.Body["description"].(string)
+ if !ok || desc == "" {
+ t.Fatalf("error description = %v, want a non-empty string", r.Body["description"])
+ }
+ return desc
+}
+
+// TestOneinchQuoteShapes: the quote envelope — token pairs, the decimal
+// toAmount string, the protocol split — plus determinism, case-insensitive
+// address matching, amount linearity, and the 400 error envelope.
+func TestOneinchQuoteShapes(t *testing.T) {
+ f := newOneinchFixture(t)
+
+ // ===== a quote returns token pairs, a decimal toAmount and a 100-point split =====
+ // toAmount (as-is): real 1inch v6.0 names the field dstAmount.
+ r := f.oiQuote(oiETHAddr, oiUSDCAddr, oiOneETH)
+ if r.Status != 200 {
+ t.Fatalf("quote -> %d: %v", r.Status, r.Body)
+ }
+ from, _ := r.Body["fromToken"].(map[string]any)
+ to, _ := r.Body["toToken"].(map[string]any)
+ if from["symbol"] != "ETH" || from["name"] != "Ether" || from["address"] != oiETHAddr ||
+ !oiNum(from["decimals"], 18) {
+ t.Fatalf("fromToken = %v, want the full ETH metadata", from)
+ }
+ if to["symbol"] != "USDC" || to["name"] != "USD Coin" || to["address"] != oiUSDCAddr ||
+ !oiNum(to["decimals"], 6) {
+ t.Fatalf("toToken = %v, want the full USDC metadata", to)
+ }
+ toAmount := oiAmount(t, r.Body["toAmount"])
+ if toAmount.Sign() <= 0 {
+ t.Fatalf("toAmount = %v, want a positive integer", r.Body["toAmount"])
+ }
+ protocols, ok := r.Body["protocols"].([]any)
+ if !ok || len(protocols) != 2 {
+ t.Fatalf("protocols = %v, want exactly two venues", r.Body["protocols"])
+ }
+ var parts int64
+ for _, p := range protocols {
+ pm, _ := p.(map[string]any)
+ name, _ := pm["name"].(string)
+ if name != "UNISWAP_V3" && name != "SUSHISWAP" {
+ t.Fatalf("protocol name = %v, want a known venue", pm["name"])
+ }
+ part, err := strconv.ParseInt(pm["part"].(string), 10, 64)
+ if err != nil {
+ t.Fatalf("protocol part = %v, want a numeric string", pm["part"])
+ }
+ parts += part
+ }
+ if parts != 100 {
+ t.Fatalf("protocol parts sum to %d, want 100", parts)
+ }
+
+ // ===== quotes are deterministic and address matching is case-insensitive =====
+ if again := f.oiQuote(oiETHAddr, oiUSDCAddr, oiOneETH); oiAmount(t, again.Body["toAmount"]).Cmp(toAmount) != 0 {
+ t.Fatalf("repeat quote toAmount = %v, want %v (deterministic)", again.Body["toAmount"], toAmount)
+ }
+ if lower := f.oiQuote(strings.ToLower(oiETHAddr), strings.ToLower(oiUSDCAddr), oiOneETH); lower.Status != 200 ||
+ oiAmount(t, lower.Body["toAmount"]).Cmp(toAmount) != 0 {
+ t.Fatalf("lowercase quote = %d %v, want the same toAmount", lower.Status, lower.Body["toAmount"])
+ }
+
+ // ===== the toAmount scales linearly with the input amount =====
+ // Same pair, 2 ETH in -> exactly 2x out (the pseudo-rate is per pair).
+ doubled := f.oiQuote(oiETHAddr, oiUSDCAddr, "2000000000000000000")
+ if want := new(big.Int).Lsh(toAmount, 1); oiAmount(t, doubled.Body["toAmount"]).Cmp(want) != 0 {
+ t.Fatalf("2 ETH quote = %v, want %v (linear)", doubled.Body["toAmount"], want)
+ }
+
+ // ===== a same-token quote scales the amount by the pseudo-rate (as-is) =====
+ // Real 1inch rejects src == dst; the simulator quotes a non-identity rate.
+ same := f.oiQuote(oiUSDCAddr, oiUSDCAddr, oiOneUSDC)
+ if same.Status != 200 {
+ t.Fatalf("same-token quote -> %d: %v", same.Status, same.Body)
+ }
+ if got := oiAmount(t, same.Body["toAmount"]); got.Cmp(big.NewInt(1000000)) == 0 {
+ t.Fatalf("same-token toAmount = %v, want the pseudo-rate multiple (as-is)", got)
+ }
+
+ // ===== missing params and unknown tokens are 400 error envelopes =====
+ if r := f.call("swap", "on_quote", "GET", "/v6.0/1/quote", map[string]string{}); r.Status != 400 {
+ t.Fatalf("quote without params -> %d, want 400", r.Status)
+ } else if !strings.Contains(oiDesc(t, r), "required") {
+ t.Fatalf("quote error = %q, want the required-params message", r.Body["description"])
+ }
+ unknown := "0x000000000000000000000000000000000000dead"
+ if r := f.oiQuote(unknown, oiUSDCAddr, oiOneETH); r.Status != 400 ||
+ !strings.HasPrefix(oiDesc(t, r), "Unknown src token: ") {
+ t.Fatalf("unknown src -> %d %v, want the 400 unknown-src envelope", r.Status, r.Body)
+ }
+ if r := f.oiQuote(oiETHAddr, unknown, oiOneETH); r.Status != 400 ||
+ !strings.HasPrefix(oiDesc(t, r), "Unknown dst token: ") {
+ t.Fatalf("unknown dst -> %d %v, want the 400 unknown-dst envelope", r.Status, r.Body)
+ }
+}
+
+// TestOneinchSwapCalldata: the swap envelope — router-addressed unsigned
+// calldata with gas/gasPrice, amount consistency with the quote, the
+// optional (ignored) slippage parameter, and its 400s.
+func TestOneinchSwapCalldata(t *testing.T) {
+ f := newOneinchFixture(t)
+ swap := func(query map[string]string) starlark.Response {
+ f.t.Helper()
+ return f.call("swap", "on_swap", "GET", "/v6.0/1/swap", query)
+ }
+ base := map[string]string{"src": oiETHAddr, "dst": oiUSDCAddr, "amount": oiOneETH, "fromAddress": oiTrader}
+
+ // ===== a swap returns router-addressed calldata with gas and gasPrice =====
+ // The tx targets the 1inch router (same address the approve flow returns).
+ r := swap(base)
+ if r.Status != 200 {
+ t.Fatalf("swap -> %d: %v", r.Status, r.Body)
+ }
+ tx, ok := r.Body["tx"].(map[string]any)
+ if !ok {
+ t.Fatalf("swap tx = %v, want an object", r.Body["tx"])
+ }
+ if tx["to"] != oiRouter || tx["from"] != oiTrader || tx["value"] != "0" {
+ t.Fatalf("tx routing = %v, want router + trader + zero value", tx)
+ }
+ data, _ := tx["data"].(string)
+ if !oiCalldataRE.MatchString(data) || !strings.HasPrefix(data, "0x12e7c2a0") {
+ t.Fatalf("tx.data = %q, want the synthetic hex calldata prefix", data)
+ }
+ if tx["gasPrice"] != "15000000000" {
+ t.Fatalf("tx.gasPrice = %v, want the fixed wei string", tx["gasPrice"])
+ }
+ if gas, ok := tx["gas"].(int64); !ok || gas < 180000 || gas >= 280000 {
+ t.Fatalf("tx.gas = %v (%T), want the bounded int estimate", tx["gas"], tx["gas"])
+ }
+ from, _ := r.Body["fromToken"].(map[string]any)
+ to, _ := r.Body["toToken"].(map[string]any)
+ if from["symbol"] != "ETH" || to["symbol"] != "USDC" || !oiNum(to["decimals"], 6) {
+ t.Fatalf("swap tokens = %v / %v, want the pair metadata", from, to)
+ }
+ if _, has := from["name"]; has {
+ t.Fatalf("swap fromToken = %v, want no name field (as-is: slimmer than the quote shape)", from)
+ }
+
+ // ===== the swap toAmount matches the quote for the same input =====
+ quote := f.oiQuote(oiETHAddr, oiUSDCAddr, oiOneETH)
+ if oiAmount(t, r.Body["toAmount"]).Cmp(oiAmount(t, quote.Body["toAmount"])) != 0 {
+ t.Fatalf("swap toAmount = %v, want the quote's %v", r.Body["toAmount"], quote.Body["toAmount"])
+ }
+
+ // ===== slippage is optional and ignored (as-is) =====
+ // Real v6.0 requires slippage on /swap; the simulator accepts any or none
+ // and returns the same routing.
+ omit := swap(map[string]string{"src": oiETHAddr, "dst": oiUSDCAddr, "amount": oiOneETH, "fromAddress": oiTrader})
+ if omit.Status != 200 || oiAmount(t, omit.Body["toAmount"]).Cmp(oiAmount(t, r.Body["toAmount"])) != 0 {
+ t.Fatalf("swap without slippage -> %d %v, want the same toAmount", omit.Status, omit.Body["toAmount"])
+ }
+ wide := swap(map[string]string{
+ "src": oiETHAddr, "dst": oiUSDCAddr, "amount": oiOneETH, "fromAddress": oiTrader, "slippage": "50",
+ })
+ if wide.Status != 200 || oiAmount(t, wide.Body["toAmount"]).Cmp(oiAmount(t, r.Body["toAmount"])) != 0 {
+ t.Fatalf("swap with slippage 50 -> %d %v, want the same toAmount (ignored)", wide.Status, wide.Body["toAmount"])
+ }
+
+ // ===== missing params and unknown tokens are 400s =====
+ if r := swap(map[string]string{"src": oiETHAddr, "dst": oiUSDCAddr, "amount": oiOneETH}); r.Status != 400 ||
+ !oiNum(r.Body["error"], 400) {
+ t.Fatalf("swap without fromAddress -> %d %v, want the 400 envelope", r.Status, r.Body)
+ }
+ unknown := "0x000000000000000000000000000000000000dead"
+ if r := swap(map[string]string{"src": unknown, "dst": oiUSDCAddr, "amount": oiOneETH, "fromAddress": oiTrader}); r.Status != 400 {
+ t.Fatalf("swap unknown src -> %d, want 400", r.Status)
+ }
+ if r := swap(map[string]string{"src": oiETHAddr, "dst": unknown, "amount": oiOneETH, "fromAddress": oiTrader}); r.Status != 400 {
+ t.Fatalf("swap unknown dst -> %d, want 400", r.Status)
+ }
+}
+
+// TestOneinchApproveFlow: the spender address is the router, and the approve
+// calldata targets the token with the max-uint256 allowance.
+func TestOneinchApproveFlow(t *testing.T) {
+ f := newOneinchFixture(t)
+
+ // ===== the spender is the router contract address =====
+ r := f.call("approve", "on_get_spender", "GET", "/v6.0/1/approve/spender", nil)
+ if r.Status != 200 || r.Body["address"] != oiRouter {
+ t.Fatalf("spender -> %d %v, want the router address", r.Status, r.Body)
+ }
+
+ // ===== approve calldata targets the token with the max allowance =====
+ // The selector 095ea7b3 (ERC20 approve) follows the synthetic prefix, and
+ // the allowance is 2^256-1 in decimal.
+ r = f.call("approve", "on_get_approve_calldata", "GET", "/v6.0/1/approve/calldata",
+ map[string]string{"token": oiUSDCAddr})
+ if r.Status != 200 {
+ t.Fatalf("approve calldata -> %d: %v", r.Status, r.Body)
+ }
+ if r.Body["to"] != oiUSDCAddr {
+ t.Fatalf("approve to = %v, want the token's own address", r.Body["to"])
+ }
+ data, _ := r.Body["data"].(string)
+ if !oiCalldataRE.MatchString(data) || !strings.HasPrefix(data, "0x12e7c2a095ea7b3") {
+ t.Fatalf("approve data = %q, want the synthetic prefix + approve selector", data)
+ }
+ max := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
+ if r.Body["allowance"] != max.String() {
+ t.Fatalf("allowance = %v, want 2^256-1 (%s)", r.Body["allowance"], max.String())
+ }
+
+ // ===== a missing or unknown token is a 400 =====
+ if r := f.call("approve", "on_get_approve_calldata", "GET", "/v6.0/1/approve/calldata",
+ map[string]string{}); r.Status != 400 || !oiNum(r.Body["error"], 400) {
+ t.Fatalf("calldata without token -> %d %v, want the 400 envelope", r.Status, r.Body)
+ }
+ if r := f.call("approve", "on_get_approve_calldata", "GET", "/v6.0/1/approve/calldata",
+ map[string]string{"token": "0x000000000000000000000000000000000000dead"}); r.Status != 400 ||
+ !strings.HasPrefix(oiDesc(t, r), "Unknown token: ") {
+ t.Fatalf("calldata unknown token -> %d %v, want the unknown-token envelope", r.Status, r.Body)
+ }
+}
+
+// TestOneinchTokenList: the address-keyed token map with its six seeded
+// tokens, and that every listed token is quotable as a source.
+func TestOneinchTokenList(t *testing.T) {
+ f := newOneinchFixture(t)
+
+ // ===== the token list is an address-keyed map of six tokens =====
+ // logoURI is null (as-is): the real list carries logo URIs.
+ r := f.call("tokens", "on_get_tokens", "GET", "/v6.0/1/tokens", nil)
+ if r.Status != 200 {
+ t.Fatalf("tokens -> %d: %v", r.Status, r.Body)
+ }
+ tokens, ok := r.Body["tokens"].(map[string]any)
+ if !ok {
+ t.Fatalf("tokens = %v, want an address-keyed map", r.Body["tokens"])
+ }
+ if len(tokens) != 6 {
+ t.Fatalf("token count = %d, want the six seeded tokens", len(tokens))
+ }
+ usdc, ok := tokens[oiUSDCAddr].(map[string]any)
+ if !ok {
+ t.Fatalf("no USDC entry keyed by %s", oiUSDCAddr)
+ }
+ if usdc["symbol"] != "USDC" || usdc["name"] != "USD Coin" || !oiNum(usdc["decimals"], 6) ||
+ usdc["address"] != oiUSDCAddr {
+ t.Fatalf("USDC entry = %v, want the full metadata", usdc)
+ }
+ if usdc["logoURI"] != nil || usdc["eip2612"] != false {
+ t.Fatalf("USDC entry = %v, want null logoURI and eip2612 false", usdc)
+ }
+ if _, has := tokens[oiETHAddr]; !has {
+ t.Fatalf("no ETH sentinel entry keyed by %s", oiETHAddr)
+ }
+
+ // ===== every token in the list is quotable as a source =====
+ // Whole-unit amounts: a dust amount underflows to 0 when the source has
+ // more decimals than the destination (integer scaling).
+ for addr := range tokens {
+ q := f.oiQuote(addr, oiUSDTAddr, oiOneETH)
+ if q.Status != 200 {
+ t.Fatalf("quote from %s -> %d: %v", addr, q.Status, q.Body)
+ }
+ if oiAmount(t, q.Body["toAmount"]).Sign() <= 0 {
+ t.Fatalf("quote from %s toAmount = %v, want positive", addr, q.Body["toAmount"])
+ }
+ }
+}
diff --git a/adapters/onfido-style/scripts/applicants.star b/adapters/onfido-style/scripts/applicants.star
index 947239eb..57f9fe91 100644
--- a/adapters/onfido-style/scripts/applicants.star
+++ b/adapters/onfido-style/scripts/applicants.star
@@ -18,9 +18,13 @@ def on_create_applicant(req):
dob = body.get("dob", "")
if first_name == "" or last_name == "":
- return respond(422, _err("validation_error", "first_name and last_name are required", {
- "first_name": ["can't be blank"],
- }))
+ # Real Onfido flags exactly the blank fields, not a fixed list.
+ fields = {}
+ if first_name == "":
+ fields["first_name"] = ["can't be blank"]
+ if last_name == "":
+ fields["last_name"] = ["can't be blank"]
+ return respond(422, _err("validation_error", "first_name and last_name are required", fields))
seq = store_kv_incr("onfido", "applicant_seq")
applicant_id = _gen_id("app", seq)
diff --git a/adapters/onfido-style/scripts/documents.star b/adapters/onfido-style/scripts/documents.star
index 8361073c..791d2ad1 100644
--- a/adapters/onfido-style/scripts/documents.star
+++ b/adapters/onfido-style/scripts/documents.star
@@ -26,7 +26,12 @@ def on_upload_document(req):
if ac.get(applicant_id) == None:
return respond(404, _err("not_found", "Applicant not found", None))
- doc_type = body.get("type", "passport")
+ # v3.6 requires type on document uploads; only side defaults to front.
+ doc_type = body.get("type", "")
+ if doc_type == "":
+ return respond(422, _err("validation_error", "type is required", {
+ "type": ["can't be blank"],
+ }))
side = body.get("side", "front")
file_name = body.get("file_name", "document.jpg")
diff --git a/adapters/onfido_style_test.go b/adapters/onfido_style_test.go
new file mode 100644
index 00000000..a661e807
--- /dev/null
+++ b/adapters/onfido_style_test.go
@@ -0,0 +1,456 @@
+package adapters
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the onfido-style adapter scripts directly (lib.star preloaded) over
+// a shared store and a VIRTUAL clock: the Token-scheme credential gate, the
+// applicant -> document -> live photo -> check KYC flow, the derive-on-read
+// check machine (in_progress until +3s, then complete clear|consider, with
+// the check.completed webhook MACed Onfido-style over the delivered bytes and
+// fired exactly once), and the /webhooks receiver that re-verifies
+// X-SHA2-Signature over the exact raw bytes — all against Onfido's
+// {error:{type,message,fields}} envelope.
+const (
+ onfidoAuth = "Token token-onfido-vm"
+ onfidoWebhookKey = "stunt_onfido_mock_signing_key"
+ onfidoSHA2SigWarn = "X-SHA2-Signature header is required"
+ onfidoSHA2SigMatch = "X-SHA2-Signature does not match the request body"
+)
+
+type onfidoFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ emitter *events.Emitter
+ host string
+}
+
+func newOnfidoFixture(t *testing.T, start time.Time) *onfidoFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "onfido-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ em := events.NewEmitter()
+ t.Cleanup(em.Close)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: em,
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &onfidoFixture{t: t, vc: vc, vms: map[string]*starlark.VM{
+ "applicants": load("applicants.star"), "documents": load("documents.star"),
+ "checks": load("checks.star"), "webhooks": load("webhooks.star"),
+ }, emitter: em, host: "api.onfido.test"}
+}
+
+// call invokes handler on the named script VM; auth is the full Authorization
+// header value ("" = header absent).
+func (f *onfidoFixture) call(group, handler, method, path string, params map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// callWebhook drives the receiver with the exact bytes a signed Onfido
+// delivery would carry: raw body on the wire plus the X-SHA2-Signature header
+// ("" = header absent). No API token — Onfido's webhook deliveries never
+// carry one; the MAC is the credential.
+func (f *onfidoFixture) callWebhook(raw string, sig string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{"Content-Type": "application/json"}
+ if sig != "" {
+ headers["X-SHA2-Signature"] = sig
+ }
+ resp, err := f.vms["webhooks"].Call("on_webhook", starlark.Request{
+ Method: "POST", Path: "/v3.6/webhooks", Host: f.host, Headers: headers, RawBody: raw,
+ })
+ if err != nil {
+ f.t.Fatalf("on_webhook: %v", err)
+ }
+ return resp
+}
+
+// onfidoDelivery is one webhook POST captured by the sink.
+type onfidoDelivery struct {
+ body []byte
+ sig string
+}
+
+// captureWebhooks registers a sink for the fixture's event namespace and
+// returns a collector over the raw deliveries (the emitter's {type, payload}
+// envelopes — Onfido's bare {"payload": ...} body rides inside payload).
+func (f *onfidoFixture) captureWebhooks() func() []onfidoDelivery {
+ f.t.Helper()
+ var mu sync.Mutex
+ var got []onfidoDelivery
+ sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ mu.Lock()
+ got = append(got, onfidoDelivery{body: b, sig: r.Header.Get("X-SHA2-Signature")})
+ mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ }))
+ f.t.Cleanup(sink.Close)
+ f.emitter.Register("test", sink.URL)
+ return func() []onfidoDelivery {
+ mu.Lock()
+ defer mu.Unlock()
+ return append([]onfidoDelivery(nil), got...)
+ }
+}
+
+// onfidoMAC computes Onfido's webhook signature: hex(HMAC-SHA256(key, body)).
+func onfidoMAC(body []byte) string {
+ mac := hmac.New(sha256.New, []byte(onfidoWebhookKey))
+ mac.Write(body)
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+// onfidoVerifySig checks X-SHA2-Signature against the exact delivered bytes
+// per the README scheme; a wrong key must not match either.
+func onfidoVerifySig(t *testing.T, d onfidoDelivery) {
+ t.Helper()
+ if want := onfidoMAC(d.body); !hmac.Equal([]byte(want), []byte(d.sig)) {
+ t.Fatalf("X-SHA2-Signature = %q, want %q (HMAC-SHA256(%q, %s))", d.sig, want, onfidoWebhookKey, d.body)
+ }
+}
+
+// onfidoErr returns the {type, message, fields} error object from a response.
+func onfidoErr(t *testing.T, r starlark.Response) map[string]any {
+ t.Helper()
+ e, ok := r.Body["error"].(map[string]any)
+ if !ok {
+ t.Fatalf("status %d body %v has no Onfido error object", r.Status, r.Body)
+ }
+ return e
+}
+
+// onfidoFields returns the error object's fields map (may be nil).
+func onfidoFields(e map[string]any) map[string]any {
+ fields, _ := e["fields"].(map[string]any)
+ return fields
+}
+
+func TestOnfidoApplicantCheckLifecycle(t *testing.T) {
+ base := time.Date(2026, 8, 14, 9, 0, 0, 0, time.UTC)
+ f := newOnfidoFixture(t, base)
+
+ // ===== a missing or non-Token Authorization header is 401 authorization_error =====
+ // Onfido gates on the "Token" scheme, not Bearer; an empty token is no token.
+ for _, auth := range []string{"", "Bearer token-onfido-vm", "Token "} {
+ r := f.call("applicants", "on_create_applicant", "POST", "/v3.6/applicants", nil,
+ map[string]any{"first_name": "Jane", "last_name": "Doe"}, auth)
+ if r.Status != 401 {
+ t.Fatalf("auth %q -> %d, want 401", auth, r.Status)
+ }
+ if e := onfidoErr(t, r); e["type"] != "authorization_error" {
+ t.Fatalf("auth %q error type = %v, want authorization_error", auth, e["type"])
+ }
+ }
+
+ // ===== applicant create flags exactly the blank names and reads back by id =====
+ // Real Onfido's fields object names the blank params, not a fixed list.
+ blank := f.call("applicants", "on_create_applicant", "POST", "/v3.6/applicants", nil,
+ map[string]any{"first_name": "Jane"}, onfidoAuth)
+ if blank.Status != 422 {
+ t.Fatalf("blank last_name -> %d, want 422", blank.Status)
+ }
+ fields := onfidoFields(onfidoErr(t, blank))
+ if got, ok := fields["last_name"].([]any); !ok || len(got) != 1 || got[0] != "can't be blank" {
+ t.Fatalf("blank last_name fields = %v, want last_name: [can't be blank]", fields)
+ }
+ if _, has := fields["first_name"]; has {
+ t.Fatalf("blank last_name flags first_name too: %v", fields)
+ }
+
+ created := f.call("applicants", "on_create_applicant", "POST", "/v3.6/applicants", nil,
+ map[string]any{"first_name": "Jane", "last_name": "Doe", "dob": "1990-05-15", "email": "jane@example.test"}, onfidoAuth)
+ if created.Status != 201 {
+ t.Fatalf("create applicant -> %d: %v", created.Status, created.Body)
+ }
+ applicantID, _ := created.Body["id"].(string)
+ if applicantID != "app-000001" {
+ t.Fatalf("first applicant id = %q, want app-000001", applicantID)
+ }
+ if created.Body["href"] != "/v3.6/applicants/"+applicantID {
+ t.Fatalf("applicant href = %v", created.Body["href"])
+ }
+ if created.Body["created_at"] == "" || created.Body["created_at"] == nil {
+ t.Fatalf("applicant created_at missing: %v", created.Body)
+ }
+
+ got := f.call("applicants", "on_get_applicant", "GET", "/v3.6/applicants/"+applicantID,
+ map[string]string{"applicant_id": applicantID}, nil, onfidoAuth)
+ if got.Status != 200 || got.Body["first_name"] != "Jane" || got.Body["last_name"] != "Doe" ||
+ got.Body["dob"] != "1990-05-15" || got.Body["email"] != "jane@example.test" {
+ t.Fatalf("get applicant -> %d %v", got.Status, got.Body)
+ }
+ missing := f.call("applicants", "on_get_applicant", "GET", "/v3.6/applicants/app-999999",
+ map[string]string{"applicant_id": "app-999999"}, nil, onfidoAuth)
+ if missing.Status != 404 || onfidoErr(t, missing)["type"] != "not_found" {
+ t.Fatalf("unknown applicant -> %d %v, want 404 not_found", missing.Status, missing.Body)
+ }
+
+ // ===== document and live photo uploads bind to a real applicant and default side =====
+ // v3.6 requires type; side defaults to front; an unknown applicant is a 404.
+ noType := f.call("documents", "on_upload_document", "POST", "/v3.6/documents", nil,
+ map[string]any{"applicant_id": applicantID}, onfidoAuth)
+ if noType.Status != 422 {
+ t.Fatalf("document without type -> %d, want 422", noType.Status)
+ }
+ if got := onfidoFields(onfidoErr(t, noType))["type"]; got == nil {
+ t.Fatalf("document without type fields = %v, want type flagged", onfidoErr(t, noType))
+ }
+ noApplicant := f.call("documents", "on_upload_document", "POST", "/v3.6/documents", nil,
+ map[string]any{"applicant_id": "app-999999", "type": "passport"}, onfidoAuth)
+ if noApplicant.Status != 404 {
+ t.Fatalf("document for unknown applicant -> %d, want 404", noApplicant.Status)
+ }
+
+ doc := f.call("documents", "on_upload_document", "POST", "/v3.6/documents", nil,
+ map[string]any{"applicant_id": applicantID, "type": "driving_licence"}, onfidoAuth)
+ if doc.Status != 201 || doc.Body["id"] != "doc-000001" || doc.Body["side"] != "front" {
+ t.Fatalf("upload document -> %d %v, want 201 doc-000001 side front", doc.Status, doc.Body)
+ }
+ photo := f.call("documents", "on_upload_live_photo", "POST", "/v3.6/live_photos", nil,
+ map[string]any{"applicant_id": applicantID}, onfidoAuth)
+ if photo.Status != 201 || photo.Body["id"] != "lph-000001" {
+ t.Fatalf("upload live photo -> %d %v, want 201 lph-000001", photo.Status, photo.Body)
+ }
+
+ // ===== check create demands report_names and a known applicant =====
+ noReports := f.call("checks", "on_create_check", "POST", "/v3.6/checks", nil,
+ map[string]any{"applicant_id": applicantID}, onfidoAuth)
+ if noReports.Status != 422 || onfidoFields(onfidoErr(t, noReports))["report_names"] == nil {
+ t.Fatalf("check without report_names -> %d %v, want 422 flagging report_names", noReports.Status, noReports.Body)
+ }
+ badApplicant := f.call("checks", "on_create_check", "POST", "/v3.6/checks", nil,
+ map[string]any{"applicant_id": "app-999999", "report_names": []any{"document"}}, onfidoAuth)
+ if badApplicant.Status != 404 {
+ t.Fatalf("check for unknown applicant -> %d, want 404", badApplicant.Status)
+ }
+
+ check := f.call("checks", "on_create_check", "POST", "/v3.6/checks", nil,
+ map[string]any{"applicant_id": applicantID, "report_names": []any{"document", "facial_similarity_photo"}}, onfidoAuth)
+ if check.Status != 201 {
+ t.Fatalf("create check -> %d: %v", check.Status, check.Body)
+ }
+ checkID, _ := check.Body["id"].(string)
+ if checkID != "chk-000001" || check.Body["status"] != "in_progress" || check.Body["result"] != nil {
+ t.Fatalf("created check = %v, want chk-000001 in_progress result null", check.Body)
+ }
+ if _, hasBreakdown := check.Body["breakdown"]; hasBreakdown {
+ t.Fatalf("in_progress check already has a breakdown: %v", check.Body)
+ }
+
+ // ===== the check completes from the clock and emits check.completed exactly once =====
+ // The sink must be live before the transition fires.
+ deliveries := f.captureWebhooks()
+
+ // t0 and t0+2s: still inside the +3s window -> in_progress.
+ for _, hop := range []time.Duration{0, 2 * time.Second} {
+ f.vc.Advance(hop)
+ early := f.call("checks", "on_get_check", "GET", "/v3.6/checks/"+checkID,
+ map[string]string{"check_id": checkID}, nil, onfidoAuth)
+ if early.Status != 200 || early.Body["status"] != "in_progress" || early.Body["result"] != nil {
+ t.Fatalf("check at +%v -> %d %v, want in_progress result null", hop, early.Status, early.Body)
+ }
+ if len(deliveries()) != 0 {
+ t.Fatalf("webhook emitted before completion at +%v", hop)
+ }
+ }
+
+ // t0+4s: past _done_at -> complete with result clear and a per-report
+ // breakdown (documents on file, so awaiting_applicant is skipped).
+ f.vc.Advance(2 * time.Second)
+ done := f.call("checks", "on_get_check", "GET", "/v3.6/checks/"+checkID,
+ map[string]string{"check_id": checkID}, nil, onfidoAuth)
+ if done.Status != 200 || done.Body["status"] != "complete" || done.Body["result"] != "clear" {
+ t.Fatalf("check at +4s -> %d %v, want complete clear", done.Status, done.Body)
+ }
+ breakdown, ok := done.Body["breakdown"].(map[string]any)
+ if !ok {
+ t.Fatalf("complete check breakdown = %v, want object", done.Body["breakdown"])
+ }
+ for _, name := range []string{"document", "facial_similarity_photo"} {
+ per, ok := breakdown[name].(map[string]any)
+ if !ok || per["result"] != "clear" {
+ t.Fatalf("breakdown[%s] = %v, want result clear", name, breakdown[name])
+ }
+ }
+
+ // The transition delivered exactly one signed check.completed.
+ dv := deliveries()
+ if len(dv) != 1 {
+ t.Fatalf("got %d deliveries after completion, want exactly 1", len(dv))
+ }
+ var env struct {
+ Type string `json:"type"`
+ Payload map[string]any `json:"payload"`
+ }
+ if err := json.Unmarshal(dv[0].body, &env); err != nil {
+ t.Fatalf("delivery body %s unparsable: %v", dv[0].body, err)
+ }
+ if env.Type != "check.completed" {
+ t.Fatalf("delivery type = %q, want check.completed", env.Type)
+ }
+ // Onfido's webhook object rides inside the emitter envelope's payload.
+ wh, ok := env.Payload["payload"].(map[string]any)
+ if !ok {
+ t.Fatalf("delivery payload = %v, want the Onfido {resource_type, action, object} object", env.Payload)
+ }
+ if wh["resource_type"] != "check" || wh["action"] != "check.completed" {
+ t.Fatalf("webhook resource/action = %v/%v, want check/check.completed", wh["resource_type"], wh["action"])
+ }
+ obj, _ := wh["object"].(map[string]any)
+ if obj["id"] != checkID || obj["status"] != "complete" || obj["result"] != "clear" {
+ t.Fatalf("webhook object = %v, want the completing %s", obj, checkID)
+ }
+ onfidoVerifySig(t, dv[0])
+
+ // Later polls read the persisted terminal state and never re-emit.
+ for i := 0; i < 2; i++ {
+ again := f.call("checks", "on_get_check", "GET", "/v3.6/checks/"+checkID,
+ map[string]string{"check_id": checkID}, nil, onfidoAuth)
+ if again.Body["status"] != "complete" || again.Body["result"] != "clear" {
+ t.Fatalf("repeat poll -> %v, want complete clear", again.Body)
+ }
+ }
+ if n := len(deliveries()); n != 1 {
+ t.Fatalf("repeat polls emitted %d more deliveries, want 0", n-1)
+ }
+
+ // ===== simulate_fail completes with consider and consider breakdowns =====
+ // Simulator extension: the body flag replaces Onfido's special sandbox
+ // documents as the consider driver; timing is unchanged.
+ fail := f.call("checks", "on_create_check", "POST", "/v3.6/checks", nil,
+ map[string]any{"applicant_id": applicantID, "report_names": []any{"document"}, "simulate_fail": true}, onfidoAuth)
+ if fail.Status != 201 || fail.Body["status"] != "in_progress" {
+ t.Fatalf("create failing check -> %d %v", fail.Status, fail.Body)
+ }
+ failID, _ := fail.Body["id"].(string)
+ f.vc.Advance(4 * time.Second)
+ flagged := f.call("checks", "on_get_check", "GET", "/v3.6/checks/"+failID,
+ map[string]string{"check_id": failID}, nil, onfidoAuth)
+ if flagged.Status != 200 || flagged.Body["status"] != "complete" || flagged.Body["result"] != "consider" {
+ t.Fatalf("failing check -> %d %v, want complete consider", flagged.Status, flagged.Body)
+ }
+ if bd, _ := flagged.Body["breakdown"].(map[string]any); bd == nil {
+ t.Fatalf("failing breakdown = %v, want object", flagged.Body["breakdown"])
+ } else if per, _ := bd["document"].(map[string]any); per == nil || per["result"] != "consider" {
+ t.Fatalf("failing breakdown[document] = %v, want result consider", bd["document"])
+ }
+ // Its completion is its own signed delivery (2 total, one per check).
+ if n := len(deliveries()); n != 2 {
+ t.Fatalf("deliveries after both checks = %d, want 2", n)
+ }
+ last := deliveries()[1]
+ onfidoVerifySig(t, last)
+ var failEnv struct {
+ Payload struct {
+ Payload struct {
+ Object struct {
+ Result string `json:"result"`
+ } `json:"object"`
+ } `json:"payload"`
+ } `json:"payload"`
+ }
+ if err := json.Unmarshal(last.body, &failEnv); err != nil || failEnv.Payload.Payload.Object.Result != "consider" {
+ t.Fatalf("failing delivery = %s, want object.result consider", last.body)
+ }
+}
+
+// TestOnfidoWebhookReceiverMAC: POST /v3.6/webhooks is the local stand-in
+// for the user's own Onfido webhook endpoint, so it re-verifies
+// X-SHA2-Signature over the exact bytes on the wire — the same MAC the
+// adapter produces outbound.
+func TestOnfidoWebhookReceiverMAC(t *testing.T) {
+ base := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
+ f := newOnfidoFixture(t, base)
+
+ body := `{"payload":{"resource_type":"check","action":"check.completed","object":{"id":"chk-000042","status":"complete","result":"clear"}}}`
+ goodSig := onfidoMAC([]byte(body))
+
+ // ===== the webhook receiver MACs the exact raw bytes =====
+ // No header, a tampered signature, and a signature over different bytes
+ // are all 401 authorization_error; only the MAC over the verbatim body
+ // passes and is acknowledged.
+ if r := f.callWebhook(body, ""); r.Status != 401 {
+ t.Fatalf("webhook without signature -> %d, want 401", r.Status)
+ } else if e := onfidoErr(t, r); e["type"] != "authorization_error" || e["message"] != onfidoSHA2SigWarn {
+ t.Fatalf("missing-signature error = %v, want %q", e, onfidoSHA2SigWarn)
+ }
+
+ if r := f.callWebhook(body, onfidoMAC([]byte(body+" "))); r.Status != 401 {
+ t.Fatalf("webhook with signature over other bytes -> %d, want 401", r.Status)
+ } else if e := onfidoErr(t, r); e["type"] != "authorization_error" || e["message"] != onfidoSHA2SigMatch {
+ t.Fatalf("mismatched-signature error = %v, want %q", e, onfidoSHA2SigMatch)
+ }
+
+ if r := f.callWebhook(body+" ", goodSig); r.Status != 401 {
+ t.Fatalf("tampered body -> %d, want 401", r.Status)
+ }
+
+ ok := f.callWebhook(body, goodSig)
+ if ok.Status != 200 || ok.Body["received"] != true {
+ t.Fatalf("correctly signed webhook -> %d %v, want 200 {received: true}", ok.Status, ok.Body)
+ }
+
+ // Header names are case-insensitive but the hex digest is compared as a
+ // string: uppercase hex is a different signature, not the same MAC.
+ if r := f.callWebhook(body, strings.ToUpper(goodSig)); r.Status != 401 {
+ t.Fatalf("uppercase hex signature -> %d, want 401 (MACs are case-sensitive)", r.Status)
+ }
+}
diff --git a/adapters/opensea-style/scripts/lib.star b/adapters/opensea-style/scripts/lib.star
index 84d883a8..d31c5f04 100644
--- a/adapters/opensea-style/scripts/lib.star
+++ b/adapters/opensea-style/scripts/lib.star
@@ -14,7 +14,8 @@ def _require_xapikey(req):
# Go canonicalizes header keys: X-API-KEY becomes X-Api-Key.
apikey = headers.get("X-Api-Key", headers.get("X-API-KEY", headers.get("x-api-key", "")))
if apikey == None or apikey == "":
- return respond(401, {"error": "X-API-KEY header is required"})
+ # Real API 401s use the V1ErrorWrapper envelope ({"errors": [...]}).
+ return respond(401, {"errors": ["X-API-KEY header is required"]})
return None
# --- deterministic hashing (consistent with eth-jsonrpc / etherscan adapters) ---
@@ -181,7 +182,7 @@ def _make_listing(slug, nft_addr, nft_id, price_wei, offerer):
"parameters": {
"offerer": offerer,
"zone": "0x0000000000000000000000000000000000000000",
- "zone_hash": "0x" + _hex32(0) * 2,
+ "zone_hash": "0x" + _hex32(0) * 8, # Seaport zone_hash is bytes32 (64 hex).
"offer": [{
"itemType": _ITEM_ERC721,
"token": nft_addr,
@@ -220,7 +221,7 @@ def _make_offer(slug, nft_addr, nft_id, offer_amount, offerer):
"parameters": {
"offerer": offerer,
"zone": "0x0000000000000000000000000000000000000000",
- "zone_hash": "0x" + _hex32(0) * 2,
+ "zone_hash": "0x" + _hex32(0) * 8, # Seaport zone_hash is bytes32 (64 hex).
"offer": [{
"itemType": _ITEM_NATIVE,
"token": "0x0000000000000000000000000000000000000000",
diff --git a/adapters/opensea_style_test.go b/adapters/opensea_style_test.go
new file mode 100644
index 00000000..25f9697e
--- /dev/null
+++ b/adapters/opensea_style_test.go
@@ -0,0 +1,512 @@
+package adapters
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the opensea-style adapter scripts directly (lib.star preloaded) over
+// a shared store: the X-API-KEY gate on every surface, the seeded NFT/
+// collection/event reads with their chain+address shapes, limit/next cursor
+// pagination, the Seaport listing/offer order shapes, and the stateful
+// create-offer flow.
+const (
+ openseaKey = "opensea-test-key"
+ openseaHost = "api.opensea.test"
+ openseaPunks = "0x0000000000000000000000000000000000000100"
+ openseaZero = "0x0000000000000000000000000000000000000000"
+ openseaMaker = "0x0000000000000000000000000000000000000001"
+ openseaOfferer = "0x0000000000000000000000000000000000000002"
+ openseaSeaport = "0x0000000000000068F116a894984e2DB1123eB395"
+ openseaWei05 = "50000000000000000" // 0.05 ETH, the seeded listing price
+ openseaWei03 = "30000000000000000" // 0.03 ETH, the seeded offer amount
+)
+
+type openseaFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ host string
+}
+
+func newOpenseaFixture(t *testing.T, start time.Time) *openseaFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "opensea-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &openseaFixture{t: t, vc: vc, host: openseaHost, vms: map[string]*starlark.VM{
+ "assets": load("assets.star"), "collections": load("collections.star"),
+ "events": load("events.star"), "orders": load("orders.star"),
+ }}
+}
+
+func (f *openseaFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, apikey string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if apikey != "" {
+ headers["X-API-KEY"] = apikey
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// openseaAssets fetches the asset page list (or fails the test).
+func (f *openseaFixture) openseaAssets(query map[string]string) []map[string]any {
+ f.t.Helper()
+ r := f.call("assets", "on_list_assets", "GET", "/api/v2/assets", nil, query, nil, openseaKey)
+ if r.Status != 200 {
+ f.t.Fatalf("list assets %v -> %d: %v", query, r.Status, r.Body)
+ }
+ out := []map[string]any{}
+ for i, a := range openseaList(f.t, r.Body["assets"], "assets") {
+ out = append(out, openseaMap(f.t, a, "assets["+strconv.Itoa(i)+"]"))
+ }
+ return out
+}
+
+func openseaMap(t *testing.T, v any, what string) map[string]any {
+ t.Helper()
+ m, ok := v.(map[string]any)
+ if !ok {
+ t.Fatalf("%s is %T(%v), want object", what, v, v)
+ }
+ return m
+}
+
+func openseaList(t *testing.T, v any, what string) []any {
+ t.Helper()
+ l, ok := v.([]any)
+ if !ok {
+ t.Fatalf("%s is %T(%v), want array", what, v, v)
+ }
+ return l
+}
+
+// openseaHash32 asserts a 0x-prefixed 64-char lowercase hex value (the mock's
+// deterministic pseudo-keccak width) and returns it.
+func openseaHash32(t *testing.T, v any, what string) string {
+ t.Helper()
+ s, ok := v.(string)
+ if !ok || !strings.HasPrefix(s, "0x") || len(s) != 66 {
+ t.Fatalf("%s = %v, want 0x + 64 hex chars", what, v)
+ }
+ for i := 2; i < len(s); i++ {
+ if !strings.ContainsRune("0123456789abcdef", rune(s[i])) {
+ t.Fatalf("%s has non-hex char %q at %d", what, s[i], i)
+ }
+ }
+ return s
+}
+
+func TestOpenSeaReadsGateAndPaging(t *testing.T) {
+ f := newOpenseaFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC))
+
+ // ===== the X-API-KEY gate 401s every surface with the V1ErrorWrapper envelope =====
+ // Every route requires the header; the real API answers 401 {"errors": [...]}.
+ surfaces := []struct{ group, handler, method, path string }{
+ {"assets", "on_list_assets", "GET", "/api/v2/assets"},
+ {"assets", "on_get_asset", "GET", "/api/v2/assets/ethereum/" + openseaPunks + "/1"},
+ {"collections", "on_get_collection", "GET", "/api/v2/collections/mock-punks"},
+ {"events", "on_list_events", "GET", "/api/v2/events"},
+ {"orders", "on_list_listings", "GET", "/api/v2/orders/ethereum/seaport/listings"},
+ {"orders", "on_list_offers", "GET", "/api/v2/orders/ethereum/seaport/offers"},
+ {"orders", "on_create_offer", "POST", "/api/v2/offers"},
+ }
+ for _, s := range surfaces {
+ r := f.call(s.group, s.handler, s.method, s.path, nil, nil, nil, "")
+ if r.Status != 401 {
+ t.Fatalf("%s without X-API-KEY -> %d, want 401", s.path, r.Status)
+ }
+ errs := openseaList(t, r.Body["errors"], s.path+" 401 errors")
+ if len(errs) != 1 || errs[0] != "X-API-KEY header is required" {
+ t.Fatalf("%s 401 body = %v, want {errors: [X-API-KEY header is required]}", s.path, r.Body)
+ }
+ }
+ // Any non-empty key passes (documented deviation: no real key validation),
+ // and header names are case-insensitive per RFC 9110.
+ if r := f.call("assets", "on_list_assets", "GET", "/api/v2/assets", nil, nil, nil, "literally-any-value"); r.Status != 200 {
+ t.Fatalf("any non-empty key -> %d, want 200", r.Status)
+ }
+ lower, err := f.vms["assets"].Call("on_list_assets", starlark.Request{
+ Method: "GET", Path: "/api/v2/assets", Host: f.host,
+ Headers: map[string]string{"x-api-key": openseaKey},
+ })
+ if err != nil || lower.Status != 200 {
+ t.Fatalf("lowercase x-api-key -> %d (%v), want 200", lower.Status, err)
+ }
+
+ // ===== the asset list seeds five mock-punks NFTs and filters by collection_slug =====
+ all := f.openseaAssets(nil)
+ if len(all) != 5 {
+ t.Fatalf("seeded assets = %d, want 5", len(all))
+ }
+ if _, has := f.call("assets", "on_list_assets", "GET", "/api/v2/assets", nil, nil, nil, openseaKey).Body["next"]; has {
+ t.Fatalf("unpaged asset list carries a next cursor")
+ }
+ first := all[0]
+ if first["id"] != "1" || first["token_id"] != "1" || first["token_address"] != openseaPunks {
+ t.Fatalf("asset[0] identity = %v", first)
+ }
+ if first["name"] != "Mock Punk #1" || first["chain"] != "ethereum" || !strings.HasSuffix(first["image_url"].(string), "/1.png") {
+ t.Fatalf("asset[0] metadata = %v", first)
+ }
+ if openseaMap(t, first["collection"], "asset[0].collection")["slug"] != "mock-punks" {
+ t.Fatalf("asset[0].collection = %v", first["collection"])
+ }
+ for _, slug := range []string{"mock-punks", "unknown-slug", "mock-apes"} {
+ want := 5
+ if slug != "mock-punks" {
+ want = 0 // mock-apes has no seeded assets; unknown slugs answer 200 empty
+ }
+ if got := len(f.openseaAssets(map[string]string{"collection_slug": slug})); got != want {
+ t.Fatalf("collection_slug=%s = %d assets, want %d", slug, got, want)
+ }
+ }
+
+ // ===== single-asset reads match the address case-insensitively and 404 unknown shapes =====
+ params := map[string]string{"chain": "ethereum", "address": openseaPunks, "identifier": "1"}
+ got := f.call("assets", "on_get_asset", "GET", "/api/v2/assets/ethereum/"+openseaPunks+"/1", params, nil, nil, openseaKey)
+ if got.Status != 200 || got.Body["token_id"] != "1" || got.Body["name"] != "Mock Punk #1" {
+ t.Fatalf("get asset -> %d %v", got.Status, got.Body)
+ }
+ if openseaMap(t, got.Body["collection"], "asset.collection")["name"] != "Mock Punks" {
+ t.Fatalf("asset collection = %v", got.Body["collection"])
+ }
+ // EVM addresses are case-insensitive hex: checksummed casing still hits.
+ mixed := f.call("assets", "on_get_asset", "GET", "/api/v2/assets/ethereum/"+strings.ToUpper(openseaPunks)+"/1",
+ map[string]string{"chain": "ethereum", "address": strings.ToUpper(openseaPunks), "identifier": "1"}, nil, nil, openseaKey)
+ if mixed.Status != 200 {
+ t.Fatalf("uppercase address -> %d, want 200", mixed.Status)
+ }
+ for name, p := range map[string]map[string]string{
+ "unknown address": {"chain": "ethereum", "address": "0x0000000000000000000000000000000000000999", "identifier": "1"},
+ "unknown identifier": {"chain": "ethereum", "address": openseaPunks, "identifier": "999"},
+ } {
+ if r := f.call("assets", "on_get_asset", "GET", "/api/v2/assets/ethereum/x/y/z", p, nil, nil, openseaKey); r.Status != 404 || r.Body["error"] != "Asset not found" {
+ t.Fatalf("%s -> %d %v, want 404 {error: Asset not found}", name, r.Status, r.Body)
+ }
+ }
+ // The chain segment is ignored (asserted as-is; deviation vs the chain-scoped real API).
+ if r := f.call("assets", "on_get_asset", "GET", "/api/v2/assets/matic/"+openseaPunks+"/1",
+ map[string]string{"chain": "matic", "address": openseaPunks, "identifier": "1"}, nil, nil, openseaKey); r.Status != 200 || r.Body["chain"] != "ethereum" {
+ t.Fatalf("wrong-chain asset read -> %d %v, want the ethereum asset as-is", r.Status, r.Body)
+ }
+
+ // ===== collections read back contracts and string-typed stats; unknown slugs 404 =====
+ coll := f.call("collections", "on_get_collection", "GET", "/api/v2/collections/mock-punks",
+ map[string]string{"slug": "mock-punks"}, nil, nil, openseaKey)
+ if coll.Status != 200 || coll.Body["slug"] != "mock-punks" || coll.Body["name"] != "Mock Punks" {
+ t.Fatalf("get collection -> %d %v", coll.Status, coll.Body)
+ }
+ contract := openseaMap(t, openseaList(t, coll.Body["primary_asset_contracts"], "contracts")[0], "contracts[0]")
+ if contract["address"] != openseaPunks || contract["chain"] != "ethereum" || contract["schema_name"] != "ERC721" {
+ t.Fatalf("primary_asset_contracts[0] = %v", contract)
+ }
+ stats := openseaMap(t, coll.Body["stats"], "stats")
+ for k, want := range map[string]string{
+ "total_supply": "10000", "count": "10000", "num_owners": "5000",
+ "total_volume": "1000.5", "floor_price": "0.05",
+ } {
+ if stats[k] != want {
+ t.Fatalf("stats.%s = %v (%T), want string %q", k, stats[k], stats[k], want)
+ }
+ }
+ apes := f.call("collections", "on_get_collection", "GET", "/api/v2/collections/mock-apes",
+ map[string]string{"slug": "mock-apes"}, nil, nil, openseaKey)
+ if openseaMap(t, apes.Body["stats"], "apes stats")["floor_price"] != "10.5" {
+ t.Fatalf("mock-apes floor_price = %v", apes.Body["stats"])
+ }
+ if r := f.call("collections", "on_get_collection", "GET", "/api/v2/collections/nope",
+ map[string]string{"slug": "nope"}, nil, nil, openseaKey); r.Status != 404 || r.Body["error"] != "Collection not found" {
+ t.Fatalf("unknown collection -> %d %v, want 404 {error: Collection not found}", r.Status, r.Body)
+ }
+
+ // ===== limit/next cursor pagination walks the pages and 400s a malformed cursor =====
+ var walked []string
+ query := map[string]string{"limit": "2"}
+ pages := 0
+ for {
+ r := f.call("assets", "on_list_assets", "GET", "/api/v2/assets", nil, query, nil, openseaKey)
+ if r.Status != 200 {
+ t.Fatalf("page %d -> %d: %v", pages, r.Status, r.Body)
+ }
+ page := openseaList(t, r.Body["assets"], "assets page")
+ if len(page) != 2 && pages < 2 {
+ t.Fatalf("page %d has %d assets, want 2", pages, len(page))
+ }
+ for _, a := range page {
+ walked = append(walked, openseaMap(t, a, "asset")["token_id"].(string))
+ }
+ pages++
+ next, _ := r.Body["next"].(string)
+ if next == "" {
+ break
+ }
+ query["next"] = next
+ }
+ if strings.Join(walked, ",") != "1,2,3,4,5" || pages != 3 {
+ t.Fatalf("pagination walked %v over %d pages, want 1,2,3,4,5 over 3", walked, pages)
+ }
+ // limit=0 disables paging entirely.
+ if got := len(f.openseaAssets(map[string]string{"limit": "0"})); got != 5 {
+ t.Fatalf("limit=0 = %d assets, want all 5 (paging disabled)", got)
+ }
+ // A malformed cursor is the adapter's own 400.
+ if r := f.call("assets", "on_list_assets", "GET", "/api/v2/assets", nil,
+ map[string]string{"limit": "2", "next": "zzz"}, nil, openseaKey); r.Status != 400 || r.Body["error"] != "Invalid cursor parameter." {
+ t.Fatalf("malformed cursor -> %d %v, want 400 Invalid cursor parameter.", r.Status, r.Body)
+ }
+
+ // ===== events filter by collection_slug and event_type =====
+ ev := f.call("events", "on_list_events", "GET", "/api/v2/events", nil, nil, nil, openseaKey)
+ if ev.Status != 200 {
+ t.Fatalf("events -> %d: %v", ev.Status, ev.Body)
+ }
+ events := openseaList(t, ev.Body["asset_events"], "asset_events")
+ if len(events) != 1 {
+ t.Fatalf("seeded events = %d, want 1", len(events))
+ }
+ e := openseaMap(t, events[0], "asset_events[0]")
+ if e["event_type"] != "sale" || e["collection_slug"] != "mock-punks" || e["quantity"] != "1" {
+ t.Fatalf("event = %v", e)
+ }
+ if openseaMap(t, e["asset"], "event.asset")["token_id"] != "1" {
+ t.Fatalf("event asset = %v", e["asset"])
+ }
+ if openseaMap(t, e["from_account"], "from_account")["address"] != openseaMaker ||
+ openseaMap(t, e["to_account"], "to_account")["address"] != openseaOfferer {
+ t.Fatalf("event accounts = %v -> %v", e["from_account"], e["to_account"])
+ }
+ pay := openseaMap(t, e["payment"], "payment")
+ if pay["quantity"] != openseaWei05 || pay["decimals"] != "18" {
+ t.Fatalf("event payment = %v", pay)
+ }
+ if r := f.call("events", "on_list_events", "GET", "/api/v2/events", nil,
+ map[string]string{"event_type": "sale"}, nil, openseaKey); len(openseaList(t, r.Body["asset_events"], "sale events")) != 1 {
+ t.Fatalf("event_type=sale = %v", r.Body)
+ }
+ if r := f.call("events", "on_list_events", "GET", "/api/v2/events", nil,
+ map[string]string{"event_type": "offer"}, nil, openseaKey); len(openseaList(t, r.Body["asset_events"], "offer events")) != 0 {
+ t.Fatalf("event_type=offer = %v, want empty", r.Body)
+ }
+ if r := f.call("events", "on_list_events", "GET", "/api/v2/events", nil,
+ map[string]string{"collection_slug": "nope"}, nil, openseaKey); len(openseaList(t, r.Body["asset_events"], "unknown slug events")) != 0 {
+ t.Fatalf("collection_slug=nope = %v, want empty", r.Body)
+ }
+}
+
+func TestOpenSeaSeaportOrders(t *testing.T) {
+ f := newOpenseaFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC))
+ orderParams := map[string]string{"chain": "ethereum", "protocol": "seaport"}
+
+ // ===== listings carry the Seaport ask shape: the NFT in offer, payment in consideration =====
+ lr := f.call("orders", "on_list_listings", "GET", "/api/v2/orders/ethereum/seaport/listings", orderParams, nil, nil, openseaKey)
+ if lr.Status != 200 {
+ t.Fatalf("listings -> %d: %v", lr.Status, lr.Body)
+ }
+ listings := openseaList(t, lr.Body["orders"], "listings")
+ if len(listings) != 1 {
+ t.Fatalf("seeded listings = %d, want 1", len(listings))
+ }
+ l := openseaMap(t, listings[0], "listing")
+ listingHash := openseaHash32(t, l["order_hash"], "listing order_hash")
+ if l["protocol_address"] != openseaSeaport || l["chain"] != "ethereum" || l["side"] != "ask" {
+ t.Fatalf("listing envelope fields = %v", l)
+ }
+ if l["maker"] != openseaMaker || l["taker"] != nil || l["current_price"] != openseaWei05 {
+ t.Fatalf("listing maker/taker/price = %v", l)
+ }
+ if _, has := l["id"]; has {
+ t.Fatalf("listed order leaks the internal store id: %v", l["id"])
+ }
+ lp := openseaMap(t, l["parameters"], "listing parameters")
+ if lp["offerer"] != openseaMaker || lp["zone"] != openseaZero || lp["startTime"] != "1700000000" || lp["endTime"] != "1700086400" {
+ t.Fatalf("listing parameters scalars = %v", lp)
+ }
+ if zoneHash := openseaHash32(t, lp["zone_hash"], "listing zone_hash"); zoneHash != "0x"+strings.Repeat("0", 64) {
+ t.Fatalf("listing zone_hash = %s, want the zero bytes32", zoneHash)
+ }
+ if lp["totalOriginalConsiderationItems"] != "1" || lp["counter"] != "0" {
+ t.Fatalf("listing counter fields = %v", lp)
+ }
+ openseaHash32(t, lp["salt"], "listing salt")
+ if sig, _ := l["signature"].(string); !strings.HasPrefix(sig, "0x") || !strings.HasSuffix(sig, "1b") {
+ t.Fatalf("listing signature = %v, want 0x-prefixed ending in the 1b v-byte", l["signature"])
+ }
+ // Ask side: the ERC721 is offered...
+ offer := openseaMap(t, openseaList(t, lp["offer"], "listing offer")[0], "listing offer[0]")
+ if toFloat(t, offer["itemType"]) != 2 || offer["token"] != openseaPunks || offer["identifierOrCriteria"] != "1" ||
+ offer["startAmount"] != "1" || offer["endAmount"] != "1" {
+ t.Fatalf("listing offer[0] = %v, want the ERC721 item", offer)
+ }
+ // ...for native payment to the offerer.
+ cons := openseaMap(t, openseaList(t, lp["consideration"], "listing consideration")[0], "listing consideration[0]")
+ if toFloat(t, cons["itemType"]) != 0 || cons["token"] != openseaZero ||
+ cons["startAmount"] != openseaWei05 || cons["endAmount"] != openseaWei05 || cons["recipient"] != openseaMaker {
+ t.Fatalf("listing consideration[0] = %v, want the 0.05 ETH native item", cons)
+ }
+ // chain/protocol path segments are ignored (asserted as-is; deviation vs
+ // the chain-scoped real route).
+ if r := f.call("orders", "on_list_listings", "GET", "/api/v2/orders/matic/whatever/listings",
+ map[string]string{"chain": "matic", "protocol": "whatever"}, nil, nil, openseaKey); r.Status != 200 ||
+ openseaMap(t, openseaList(t, r.Body["orders"], "orders")[0], "order")["chain"] != "ethereum" {
+ t.Fatalf("wrong-chain listings -> %d %v, want the ethereum orders as-is", r.Status, r.Body)
+ }
+
+ // ===== offers invert the shape: payment in offer, the NFT in consideration =====
+ or := f.call("orders", "on_list_offers", "GET", "/api/v2/orders/ethereum/seaport/offers", orderParams, nil, nil, openseaKey)
+ if or.Status != 200 {
+ t.Fatalf("offers -> %d: %v", or.Status, or.Body)
+ }
+ offers := openseaList(t, or.Body["orders"], "offers")
+ if len(offers) != 1 {
+ t.Fatalf("seeded offers = %d, want 1", len(offers))
+ }
+ o := openseaMap(t, offers[0], "offer")
+ offerHash := openseaHash32(t, o["order_hash"], "offer order_hash")
+ if offerHash == listingHash || o["side"] != "bid" || o["maker"] != openseaOfferer || o["current_price"] != openseaWei03 {
+ t.Fatalf("offer envelope = %v", o)
+ }
+ op := openseaMap(t, o["parameters"], "offer parameters")
+ payItem := openseaMap(t, openseaList(t, op["offer"], "offer offer")[0], "offer offer[0]")
+ if toFloat(t, payItem["itemType"]) != 0 || payItem["startAmount"] != openseaWei03 || payItem["endAmount"] != openseaWei03 {
+ t.Fatalf("offer offer[0] = %v, want the 0.03 ETH native payment", payItem)
+ }
+ nftItem := openseaMap(t, openseaList(t, op["consideration"], "offer consideration")[0], "offer consideration[0]")
+ if toFloat(t, nftItem["itemType"]) != 2 || nftItem["token"] != openseaPunks || nftItem["identifierOrCriteria"] != "1" ||
+ nftItem["recipient"] != openseaOfferer {
+ t.Fatalf("offer consideration[0] = %v, want the ERC721 item", nftItem)
+ }
+
+ // ===== created offers are stateful, deterministic, and defaulted =====
+ created := f.call("orders", "on_create_offer", "POST", "/api/v2/offers", nil, nil, map[string]any{
+ "criteria": map[string]any{
+ "data": map[string]any{"token": openseaPunks, "identifier": "3"},
+ },
+ "maker": "0x0000000000000000000000000000000000000009",
+ "consideration": map[string]any{"price": "7770000000000000"},
+ }, openseaKey)
+ if created.Status != 200 {
+ t.Fatalf("create offer -> %d: %v", created.Status, created.Body)
+ }
+ createdHash := openseaHash32(t, created.Body["order_hash"], "created order_hash")
+ if created.Body["protocol_address"] != openseaSeaport || created.Body["chain"] != "ethereum" {
+ t.Fatalf("create offer response = %v", created.Body)
+ }
+ // STATEFUL: the created order joins the offers list with its parameters.
+ after := openseaList(t, f.call("orders", "on_list_offers", "GET", "/api/v2/orders/ethereum/seaport/offers",
+ orderParams, nil, nil, openseaKey).Body["orders"], "offers after create")
+ var createdOrder map[string]any
+ for _, oo := range after {
+ m := openseaMap(t, oo, "offer")
+ if m["order_hash"] == createdHash {
+ createdOrder = m
+ }
+ }
+ if createdOrder == nil {
+ t.Fatalf("created offer %s missing from the offers list", createdHash)
+ }
+ cp := openseaMap(t, createdOrder["parameters"], "created parameters")
+ if cp["offerer"] != "0x0000000000000000000000000000000000000009" {
+ t.Fatalf("created offerer = %v, want the posted maker", cp["offerer"])
+ }
+ if item := openseaMap(t, openseaList(t, cp["offer"], "created offer")[0], "created offer[0]"); item["startAmount"] != "7770000000000000" {
+ t.Fatalf("created payment = %v", item)
+ }
+ if item := openseaMap(t, openseaList(t, cp["consideration"], "created consideration")[0], "created consideration[0]"); item["identifierOrCriteria"] != "3" {
+ t.Fatalf("created NFT = %v", item)
+ }
+ // Deterministic: the same parameters mint the same hash — and a second
+ // create inserts a duplicate (no dedupe by order_hash; asserted as-is,
+ // the real API would not return duplicate orders).
+ again := f.call("orders", "on_create_offer", "POST", "/api/v2/offers", nil, nil, map[string]any{
+ "criteria": map[string]any{
+ "data": map[string]any{"token": openseaPunks, "identifier": "3"},
+ },
+ "maker": "0x0000000000000000000000000000000000000009",
+ "consideration": map[string]any{"price": "7770000000000000"},
+ }, openseaKey)
+ if again.Body["order_hash"] != createdHash {
+ t.Fatalf("re-created hash = %v, want the deterministic %s", again.Body["order_hash"], createdHash)
+ }
+ final := openseaList(t, f.call("orders", "on_list_offers", "GET", "/api/v2/orders/ethereum/seaport/offers",
+ orderParams, nil, nil, openseaKey).Body["orders"], "offers final")
+ dupes := 0
+ for _, oo := range final {
+ if openseaMap(t, oo, "offer")["order_hash"] == createdHash {
+ dupes++
+ }
+ }
+ if len(final) != 3 || dupes != 2 {
+ t.Fatalf("offers after re-create = %d total, %d with the created hash; want 3 total, 2 (seeded + duplicated)", len(final), dupes)
+ }
+ // Defaults when the body omits everything: the mock-punks token, id 1,
+ // maker 0x…03, and a 0.01 ETH price.
+ def := f.call("orders", "on_create_offer", "POST", "/api/v2/offers", nil, nil, map[string]any{}, openseaKey)
+ if def.Status != 200 {
+ t.Fatalf("empty create -> %d: %v", def.Status, def.Body)
+ }
+ var defaulted map[string]any
+ for _, oo := range openseaList(t, f.call("orders", "on_list_offers", "GET", "/api/v2/orders/ethereum/seaport/offers",
+ orderParams, nil, nil, openseaKey).Body["orders"], "offers with default") {
+ if m := openseaMap(t, oo, "offer"); m["order_hash"] == def.Body["order_hash"] {
+ defaulted = m
+ }
+ }
+ if defaulted == nil {
+ t.Fatalf("defaulted create %v missing from the offers list", def.Body["order_hash"])
+ }
+ dp := openseaMap(t, defaulted["parameters"], "defaulted parameters")
+ if dp["offerer"] != "0x0000000000000000000000000000000000000003" {
+ t.Fatalf("defaulted maker = %v", dp["offerer"])
+ }
+ if item := openseaMap(t, openseaList(t, dp["offer"], "defaulted offer")[0], "defaulted offer[0]"); item["startAmount"] != "10000000000000000" {
+ t.Fatalf("defaulted price = %v", item)
+ }
+ if item := openseaMap(t, openseaList(t, dp["consideration"], "defaulted consideration")[0], "defaulted consideration[0]"); item["identifierOrCriteria"] != "1" || item["token"] != openseaPunks {
+ t.Fatalf("defaulted NFT = %v", item)
+ }
+}
diff --git a/adapters/persona-style/scripts/inquiries.star b/adapters/persona-style/scripts/inquiries.star
index d3ef8a26..6d313ed4 100644
--- a/adapters/persona-style/scripts/inquiries.star
+++ b/adapters/persona-style/scripts/inquiries.star
@@ -74,9 +74,12 @@ def _advance_inquiry(inquiry_id):
new_status = _derive_inquiry_status(doc)
if new_status != doc["status"]:
doc["status"] = new_status
- ic.update(inquiry_id, doc)
already_terminal = doc.get("_ever_terminal", False)
- doc["_ever_terminal"] = True
+ # Stamp _ever_terminal on terminal transitions ONLY: the created ->
+ # pending hop must not spend the inquiry's one webhook (real Persona
+ # notifies the terminal outcome whether or not the client polled).
+ if new_status == "completed" or new_status == "declined":
+ doc["_ever_terminal"] = True
ic.update(inquiry_id, doc)
if new_status == "completed":
_seed_verifications(inquiry_id, doc["reference_id"])
diff --git a/adapters/persona_style_test.go b/adapters/persona_style_test.go
new file mode 100644
index 00000000..cb72aa48
--- /dev/null
+++ b/adapters/persona_style_test.go
@@ -0,0 +1,629 @@
+package adapters
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the persona-style adapter scripts directly (lib.star preloaded)
+// over a shared store, virtual clock and webhook sink: the Bearer gate and
+// the /api/inquiry/v1 JSON:API shapes, the clock-derived inquiry lifecycle
+// (created -> pending -> completed | declined) with its auto-seeded
+// verifications and resume clock-restart, and the Persona-Signature HMAC
+// scheme in both directions (inbound receiver verification + signed
+// outbound deliveries).
+const (
+ personaAuth = "Bearer persona_stunt_test_key"
+ personaHost = "withpersona.test"
+ personaWebhookSecret = "stunt_persona_mock_signing_key"
+)
+
+// personaIDRE / personaVerRE pin the synthetic id shapes: inq_/ver_ + a
+// zero-padded 6-digit sequence.
+var (
+ personaIDRE = regexp.MustCompile(`^inq_\d{6}$`)
+ personaVerRE = regexp.MustCompile(`^ver_\d{6}$`)
+)
+
+type personaFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ emitter *events.Emitter
+ host string
+}
+
+func newPersonaFixture(t *testing.T, start time.Time) *personaFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "persona-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ em := events.NewEmitter()
+ t.Cleanup(em.Close)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: em,
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &personaFixture{t: t, vc: vc, vms: map[string]*starlark.VM{
+ "inquiries": load("inquiries.star"), "hooks": load("webhooks.star"),
+ }, emitter: em, host: personaHost}
+}
+
+// call invokes handler on the named script VM; auth is the full Authorization
+// header value ("" = header absent).
+func (f *personaFixture) call(group, handler, method, path string, params map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// personaCreate posts an inquiry create and returns the minted id.
+func (f *personaFixture) personaCreate(body map[string]any) string {
+ f.t.Helper()
+ r := f.call("inquiries", "on_create_inquiry", "POST", "/api/inquiry/v1/inquiries", nil, body, personaAuth)
+ if r.Status != 201 {
+ f.t.Fatalf("create inquiry -> %d: %v", r.Status, r.Body)
+ }
+ return personaDataID(f.t, r)
+}
+
+// personaGet reads GET /api/inquiry/v1/inquiries/{id}.
+func (f *personaFixture) personaGet(id string) starlark.Response {
+ f.t.Helper()
+ return f.call("inquiries", "on_get_inquiry", "GET", "/api/inquiry/v1/inquiries/"+id,
+ map[string]string{"inquiry_id": id}, nil, personaAuth)
+}
+
+// personaStatus returns the inquiry's current derived status.
+func (f *personaFixture) personaStatus(id string) string {
+ f.t.Helper()
+ r := f.personaGet(id)
+ if r.Status != 200 {
+ f.t.Fatalf("get inquiry %s -> %d: %v", id, r.Status, r.Body)
+ }
+ return personaAttrs(f.t, r)["status"].(string)
+}
+
+// personaVerifications reads GET /api/inquiry/v1/inquiries/{id}/verifications.
+func (f *personaFixture) personaVerifications(id string) starlark.Response {
+ f.t.Helper()
+ return f.call("inquiries", "on_get_verifications", "GET", "/api/inquiry/v1/inquiries/"+id+"/verifications",
+ map[string]string{"inquiry_id": id}, nil, personaAuth)
+}
+
+// personaDataID extracts data.id from a JSON:API response.
+func personaDataID(t *testing.T, r starlark.Response) string {
+ t.Helper()
+ data, ok := r.Body["data"].(map[string]any)
+ if !ok {
+ t.Fatalf("response %d data = %v, want the JSON:API data object", r.Status, r.Body["data"])
+ }
+ id, _ := data["id"].(string)
+ return id
+}
+
+// personaAttrs extracts data.attributes from a JSON:API response.
+func personaAttrs(t *testing.T, r starlark.Response) map[string]any {
+ t.Helper()
+ data, ok := r.Body["data"].(map[string]any)
+ if !ok {
+ t.Fatalf("response %d data = %v, want the JSON:API data object", r.Status, r.Body["data"])
+ }
+ attrs, ok := data["attributes"].(map[string]any)
+ if !ok {
+ t.Fatalf("response data.attributes = %v, want an object", data["attributes"])
+ }
+ return attrs
+}
+
+// personaErr returns errors[0] from a JSON:API error envelope.
+func personaErr(t *testing.T, r starlark.Response) map[string]any {
+ t.Helper()
+ errs, ok := r.Body["errors"].([]any)
+ if !ok || len(errs) == 0 {
+ t.Fatalf("response %d errors = %v, want a JSON:API error envelope", r.Status, r.Body)
+ }
+ e, ok := errs[0].(map[string]any)
+ if !ok {
+ t.Fatalf("errors[0] = %v, want an object", errs[0])
+ }
+ return e
+}
+
+// personaSign builds a Persona-Signature header over the exact bytes:
+// t=,v1=.
+func personaSign(t string, raw []byte) string {
+ mac := hmac.New(sha256.New, []byte(personaWebhookSecret))
+ mac.Write([]byte(t + "." + string(raw)))
+ return "t=" + t + ",v1=" + hex.EncodeToString(mac.Sum(nil))
+}
+
+// personaDelivery is one webhook POST captured by the sink.
+type personaDelivery struct {
+ body []byte
+ sig string
+}
+
+// envelope returns the delivery's parsed {type, payload} envelope.
+func (d personaDelivery) envelope(t *testing.T) (string, map[string]any) {
+ t.Helper()
+ var env struct {
+ Type string `json:"type"`
+ Payload map[string]any `json:"payload"`
+ }
+ if err := json.Unmarshal(d.body, &env); err != nil {
+ t.Fatalf("webhook body %s unparsable: %v", d.body, err)
+ }
+ if env.Type == "" || env.Payload == nil {
+ t.Fatalf("webhook body %s, want a {type, payload} envelope", d.body)
+ }
+ return env.Type, env.Payload
+}
+
+// captureWebhooks registers a sink and returns a collector over the raw
+// deliveries (body + Persona-Signature header).
+func (f *personaFixture) captureWebhooks() func() []personaDelivery {
+ f.t.Helper()
+ var mu sync.Mutex
+ var got []personaDelivery
+ sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ mu.Lock()
+ got = append(got, personaDelivery{body: b, sig: r.Header.Get("Persona-Signature")})
+ mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ }))
+ f.t.Cleanup(sink.Close)
+ f.emitter.Register("test", sink.URL)
+ return func() []personaDelivery {
+ mu.Lock()
+ defer mu.Unlock()
+ return append([]personaDelivery(nil), got...)
+ }
+}
+
+// personaVerifyDelivery checks Persona-Signature against Persona's scheme —
+// v1 == hex(HMAC-SHA256(secret, t + "." + raw_body)) — over the exact bytes
+// the sink received, with t taken verbatim from the header.
+func personaVerifyDelivery(t *testing.T, d personaDelivery) {
+ t.Helper()
+ var ts, v1 string
+ for _, part := range strings.Split(d.sig, ",") {
+ switch {
+ case strings.HasPrefix(part, "t="):
+ ts = strings.TrimPrefix(part, "t=")
+ case strings.HasPrefix(part, "v1="):
+ v1 = strings.TrimPrefix(part, "v1=")
+ }
+ }
+ if ts == "" || v1 == "" {
+ t.Fatalf("Persona-Signature = %q, want t= and v1= components", d.sig)
+ }
+ mac := hmac.New(sha256.New, []byte(personaWebhookSecret))
+ mac.Write([]byte(ts + "." + string(d.body)))
+ want := hex.EncodeToString(mac.Sum(nil))
+ if !hmac.Equal([]byte(want), []byte(v1)) {
+ t.Fatalf("Persona-Signature v1 = %q, want %q over body %s", v1, want, d.body)
+ }
+}
+
+// TestPersonaInquiryCreateAndAuthGate: the Bearer gate's JSON:API 401s, the
+// create envelope with its zero-padded inq_ sequence, and the required-field
+// validation.
+func TestPersonaInquiryCreateAndAuthGate(t *testing.T) {
+ f := newPersonaFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ create := func(body map[string]any, auth string) starlark.Response {
+ return f.call("inquiries", "on_create_inquiry", "POST", "/api/inquiry/v1/inquiries", nil, body, auth)
+ }
+
+ // ===== a missing, bare or wrong-scheme token is a 401 in the JSON:API error envelope =====
+ // Persona gates every endpoint behind a Bearer API key.
+ for _, auth := range []string{"", "persona_stunt_test_key", "Basic abc"} {
+ r := create(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"}, auth)
+ if r.Status != 401 {
+ t.Fatalf("auth %q -> %d, want 401", auth, r.Status)
+ }
+ if e := personaErr(t, r); e["status"] != "401" || e["code"] != "unauthorized" {
+ t.Fatalf("401 envelope = %v, want status 401 / unauthorized", e)
+ }
+ }
+
+ // ===== a create mints a zero-padded inq_ id in the JSON:API envelope =====
+ // Attributes are snake_case (as-is): real Persona serializes JSON:API
+ // attributes in kebab-case (reference-id, created-at).
+ r := create(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"}, personaAuth)
+ if r.Status != 201 {
+ t.Fatalf("create inquiry -> %d: %v", r.Status, r.Body)
+ }
+ id := personaDataID(t, r)
+ if !personaIDRE.MatchString(id) || id != "inq_000001" {
+ t.Fatalf("first inquiry id = %q, want inq_000001 (fresh store)", id)
+ }
+ attrs := personaAttrs(t, r)
+ if attrs["status"] != "created" || attrs["reference_id"] != "user-42" ||
+ attrs["template_id"] != "itmpl_abc123" {
+ t.Fatalf("create attributes = %v, want created echoing the payload", attrs)
+ }
+ createdAt, _ := attrs["created_at"].(string)
+ if _, err := time.Parse(time.RFC3339, createdAt); err != nil {
+ t.Fatalf("created_at = %q, want an RFC3339 timestamp", createdAt)
+ }
+
+ // ===== sequential creates advance the id sequence =====
+ second := create(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-43"}, personaAuth)
+ if got := personaDataID(t, second); got != "inq_000002" {
+ t.Fatalf("second inquiry id = %q, want inq_000002", got)
+ }
+
+ // ===== any Bearer is accepted: the gate checks presence, not a store (as-is) =====
+ // Real Persona validates the API key; any non-empty Bearer mints here.
+ if r := create(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-44"},
+ "Bearer totally-unknown-key"); r.Status != 201 {
+ t.Fatalf("any bearer -> %d, want 201 (presence-only gate)", r.Status)
+ }
+
+ // ===== a create missing template_id or reference_id is a 400 invalid_request =====
+ for name, body := range map[string]map[string]any{
+ "no template": {"reference_id": "user-42"},
+ "no reference": {"template_id": "itmpl_abc123"},
+ "empty body": {},
+ "missing body": nil,
+ } {
+ r := create(body, personaAuth)
+ if r.Status != 400 {
+ t.Fatalf("create %s -> %d, want 400", name, r.Status)
+ }
+ if e := personaErr(t, r); e["code"] != "invalid_request" {
+ t.Fatalf("create %s error = %v, want invalid_request", name, e)
+ }
+ }
+}
+
+// TestPersonaInquiryLifecycleStates: the derive-on-read status machine on the
+// virtual clock — created -> pending -> completed (persisted), declined via
+// the simulate_fail simulator extension, resume restarting the clock without
+// duplicating verifications, and the 404s.
+func TestPersonaInquiryLifecycleStates(t *testing.T) {
+ f := newPersonaFixture(t, time.Unix(1_750_000_000, 0).UTC())
+
+ // ===== the status derives from the clock created to pending to completed =====
+ // created (0-1s), pending (1-3s), completed (+3s); reads persist transitions.
+ id := f.personaCreate(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"})
+ if s := f.personaStatus(id); s != "created" {
+ t.Fatalf("fresh status = %q, want created", s)
+ }
+ f.vc.Advance(2 * time.Second)
+ if s := f.personaStatus(id); s != "pending" {
+ t.Fatalf("status at +2s = %q, want pending", s)
+ }
+ f.vc.Advance(2 * time.Second)
+ if s := f.personaStatus(id); s != "completed" {
+ t.Fatalf("status at +4s = %q, want completed", s)
+ }
+ if s := f.personaStatus(id); s != "completed" {
+ t.Fatalf("re-read status = %q, want completed (transition persisted)", s)
+ }
+ ver := f.personaVerifications(id)
+ if ver.Status != 200 {
+ t.Fatalf("verifications -> %d: %v", ver.Status, ver.Body)
+ }
+ if data, ok := ver.Body["data"].([]any); !ok || len(data) != 2 {
+ t.Fatalf("completed verifications = %v, want the seeded pair", ver.Body["data"])
+ }
+
+ // ===== resume restarts the clock at pending without duplicating verifications =====
+ // Resume answers pending immediately and re-completes 3s later; the
+ // already-seeded verifications are not duplicated.
+ res := f.call("inquiries", "on_resume_inquiry", "POST", "/api/inquiry/v1/inquiries/"+id+"/resume",
+ map[string]string{"inquiry_id": id}, nil, personaAuth)
+ if res.Status != 200 {
+ t.Fatalf("resume -> %d: %v", res.Status, res.Body)
+ }
+ if rAttrs := personaAttrs(t, res); rAttrs["status"] != "pending" || rAttrs["reference_id"] != "user-42" {
+ t.Fatalf("resume attributes = %v, want pending + the reference echoed", rAttrs)
+ }
+ f.vc.Advance(4 * time.Second)
+ if s := f.personaStatus(id); s != "completed" {
+ t.Fatalf("status after resume +4s = %q, want completed again", s)
+ }
+ if ver := f.personaVerifications(id); ver.Status != 200 {
+ t.Fatalf("verifications after resume -> %d", ver.Status)
+ } else if data, _ := ver.Body["data"].([]any); len(data) != 2 {
+ t.Fatalf("verifications after resume = %d entries, want still 2 (no dupes)", len(data))
+ }
+
+ // ===== simulate_fail declines at the terminal transition and seeds nothing =====
+ // Stunt-only flag: the review outcome is declined, not completed.
+ failID := f.personaCreate(map[string]any{
+ "template_id": "itmpl_abc123", "reference_id": "user-43", "simulate_fail": true,
+ })
+ f.vc.Advance(4 * time.Second)
+ if s := f.personaStatus(failID); s != "declined" {
+ t.Fatalf("simulate_fail status = %q, want declined", s)
+ }
+ if ver := f.personaVerifications(failID); ver.Status != 200 {
+ t.Fatalf("declined verifications -> %d", ver.Status)
+ } else if data, _ := ver.Body["data"].([]any); len(data) != 0 {
+ t.Fatalf("declined verifications = %v, want none seeded", data)
+ }
+
+ // ===== unknown inquiries are JSON:API 404s on every parameterized route =====
+ for _, rt := range []struct {
+ handler string
+ path string
+ method string
+ }{
+ {"on_get_inquiry", "/api/inquiry/v1/inquiries/inq_nope", "GET"},
+ {"on_resume_inquiry", "/api/inquiry/v1/inquiries/inq_nope/resume", "POST"},
+ {"on_get_verifications", "/api/inquiry/v1/inquiries/inq_nope/verifications", "GET"},
+ } {
+ r := f.call("inquiries", rt.handler, rt.method, rt.path,
+ map[string]string{"inquiry_id": "inq_nope"}, nil, personaAuth)
+ if r.Status != 404 {
+ t.Fatalf("%s %s -> %d, want 404", rt.method, rt.path, r.Status)
+ }
+ if e := personaErr(t, r); e["code"] != "not_found" {
+ t.Fatalf("%s 404 envelope = %v, want not_found", rt.path, e)
+ }
+ }
+}
+
+// TestPersonaVerificationsShape: the government-id + selfie pair auto-seeded
+// at the completion transition, its JSON:API list shape, and that nothing is
+// seeded before the terminal state.
+func TestPersonaVerificationsShape(t *testing.T) {
+ f := newPersonaFixture(t, time.Unix(1_750_000_000, 0).UTC())
+
+ // ===== verifications are empty until the terminal transition fires =====
+ // The lifecycle is derive-on-read: side effects land only when a read
+ // observes the terminal state.
+ earlyID := f.personaCreate(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"})
+ f.vc.Advance(2 * time.Second)
+ if s := f.personaStatus(earlyID); s != "pending" {
+ t.Fatalf("early status = %q, want pending", s)
+ }
+ if ver := f.personaVerifications(earlyID); ver.Status != 200 {
+ t.Fatalf("pending verifications -> %d", ver.Status)
+ } else if data, _ := ver.Body["data"].([]any); len(data) != 0 {
+ t.Fatalf("pending verifications = %v, want none yet", data)
+ }
+
+ // ===== completion seeds the government-id and selfie verifications =====
+ id := f.personaCreate(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"})
+ f.vc.Advance(4 * time.Second)
+ if s := f.personaStatus(id); s != "completed" {
+ t.Fatalf("status = %q, want completed", s)
+ }
+ ver := f.personaVerifications(id)
+ if ver.Status != 200 {
+ t.Fatalf("verifications -> %d: %v", ver.Status, ver.Body)
+ }
+ data, ok := ver.Body["data"].([]any)
+ if !ok || len(data) != 2 {
+ t.Fatalf("verifications data = %v, want exactly two entries", ver.Body["data"])
+ }
+ wantNames := []string{"government-id", "selfie"}
+ for i, item := range data {
+ v, ok := item.(map[string]any)
+ if !ok || v["type"] != "verification" {
+ t.Fatalf("verification[%d] = %v, want type verification", i, item)
+ }
+ if !personaVerRE.MatchString(v["id"].(string)) {
+ t.Fatalf("verification[%d] id = %v, want the ver_ padded shape", i, v["id"])
+ }
+ attrs, _ := v["attributes"].(map[string]any)
+ if attrs["name"] != wantNames[i] || attrs["status"] != "completed" || attrs["result"] != "pass" {
+ t.Fatalf("verification[%d] attributes = %v, want %s completed pass", i, attrs, wantNames[i])
+ }
+ if _, err := time.Parse(time.RFC3339, attrs["created_at"].(string)); err != nil {
+ t.Fatalf("verification[%d] created_at = %v, want RFC3339", i, attrs["created_at"])
+ }
+ }
+}
+
+// TestPersonaWebhookReceiver: the inbound Persona-Signature flow — fresh
+// correctly-MACed deliveries are accepted, tampering and replay are 401s in
+// the JSON:API error envelope.
+func TestPersonaWebhookReceiver(t *testing.T) {
+ f := newPersonaFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ raw := `{"type":"inquiry.completed","data":{"id":"inq_000001"}}`
+ post := func(sig, body string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if sig != "" {
+ headers["Persona-Signature"] = sig
+ }
+ resp, err := f.vms["hooks"].Call("on_webhook", starlark.Request{
+ Method: "POST", Path: "/api/inquiry/v1/webhooks", Host: f.host, Headers: headers, RawBody: body,
+ })
+ if err != nil {
+ f.t.Fatalf("on_webhook: %v", err)
+ }
+ return resp
+ }
+ unixNow := func() string {
+ return strconv.FormatInt(f.vc.Now().Unix(), 10)
+ }
+
+ // ===== a fresh correctly-signed webhook is accepted =====
+ // v1 = HMAC over t + "." + the exact request bytes.
+ r := post(personaSign(unixNow(), []byte(raw)), raw)
+ if r.Status != 200 || r.Body["received"] != true {
+ t.Fatalf("signed webhook -> %d %v, want 200 {received:true}", r.Status, r.Body)
+ }
+
+ // ===== a tampered body or wrong MAC is a 401 invalid_signature =====
+ // The header MACs different bytes than the ones on the wire.
+ if r := post(personaSign(unixNow(), []byte(raw)), raw+" "); r.Status != 401 {
+ t.Fatalf("tampered body -> %d, want 401", r.Status)
+ } else if e := personaErr(t, r); e["code"] != "invalid_signature" {
+ t.Fatalf("tampered body envelope = %v, want invalid_signature", e)
+ }
+ // A structurally valid header whose MAC matches nothing.
+ good := personaSign(unixNow(), []byte(raw))
+ badMAC := good[:strings.Index(good, ",v1=")+4] + strings.Repeat("0", 64)
+ if r := post(badMAC, raw); r.Status != 401 {
+ t.Fatalf("zeroed MAC -> %d, want 401", r.Status)
+ } else if e := personaErr(t, r); e["code"] != "invalid_signature" {
+ t.Fatalf("zeroed MAC envelope = %v, want invalid_signature", e)
+ }
+
+ // ===== a stale or far-future t is a 401 invalid_timestamp =====
+ // Replay protection: |now - t| must stay within 5 minutes.
+ stale := unixNow()
+ f.vc.Advance(6 * time.Minute)
+ if r := post(personaSign(stale, []byte(raw)), raw); r.Status != 401 {
+ t.Fatalf("stale t -> %d, want 401", r.Status)
+ } else if e := personaErr(t, r); e["code"] != "invalid_timestamp" {
+ t.Fatalf("stale t envelope = %v, want invalid_timestamp", e)
+ }
+ future := strconv.FormatInt(f.vc.Now().Add(6*time.Minute).Unix(), 10)
+ if r := post(personaSign(future, []byte(raw)), raw); r.Status != 401 {
+ t.Fatalf("future t -> %d, want 401", r.Status)
+ } else if e := personaErr(t, r); e["code"] != "invalid_timestamp" {
+ t.Fatalf("future t envelope = %v, want invalid_timestamp", e)
+ }
+
+ // ===== a missing header or unparseable signature is a 401 =====
+ // The signature IS the auth on this endpoint.
+ if r := post("", "{}"); r.Status != 401 {
+ t.Fatalf("missing header -> %d, want 401", r.Status)
+ } else if e := personaErr(t, r); e["code"] != "missing_signature" {
+ t.Fatalf("missing header envelope = %v, want missing_signature", e)
+ }
+ for _, sig := range []string{"garbage", "t=" + unixNow() + ",v1=", "t=notadigit,v1=abc"} {
+ if r := post(sig, raw); r.Status != 401 {
+ t.Fatalf("header %q -> %d, want 401", sig, r.Status)
+ } else if e := personaErr(t, r); e["code"] != "invalid_signature" {
+ t.Fatalf("header %q envelope = %v, want invalid_signature", sig, e)
+ }
+ }
+}
+
+// TestPersonaOutboundSignedWebhooks: the emitter side — exactly one signed
+// inquiry.completed at the terminal transition (even when the client polled
+// through pending), no re-emit on re-reads or post-resume re-completion, and
+// inquiry.declined for failing inquiries.
+func TestPersonaOutboundSignedWebhooks(t *testing.T) {
+ f := newPersonaFixture(t, time.Unix(1_750_000_000, 0).UTC())
+ delivered := f.captureWebhooks()
+
+ // ===== polling through pending still emits exactly one inquiry.completed =====
+ // Persona notifies the terminal outcome regardless of polling; each
+ // terminal transition fires its webhook exactly once.
+ id := f.personaCreate(map[string]any{"template_id": "itmpl_abc123", "reference_id": "user-42"})
+ f.vc.Advance(2 * time.Second)
+ if s := f.personaStatus(id); s != "pending" {
+ t.Fatalf("status at +2s = %q, want pending", s)
+ }
+ f.vc.Advance(2 * time.Second)
+ if s := f.personaStatus(id); s != "completed" {
+ t.Fatalf("status at +4s = %q, want completed", s)
+ }
+ all := delivered()
+ if len(all) != 1 {
+ t.Fatalf("%d deliveries after completion, want exactly 1", len(all))
+ }
+ personaVerifyDelivery(t, all[0])
+ eventType, payload := all[0].envelope(t)
+ if eventType != "inquiry.completed" {
+ t.Fatalf("webhook type = %q, want inquiry.completed", eventType)
+ }
+ pData, _ := payload["data"].(map[string]any)
+ if pData == nil || pData["id"] != id || pData["type"] != "inquiry" {
+ t.Fatalf("webhook data = %v, want the completed inquiry resource", payload["data"])
+ }
+ pAttrs, _ := pData["attributes"].(map[string]any)
+ if pAttrs["status"] != "completed" || pAttrs["reference_id"] != "user-42" {
+ t.Fatalf("webhook attributes = %v, want completed + the reference", pAttrs)
+ }
+
+ // ===== re-reads and post-resume re-completions do not re-emit =====
+ // Resume restarts the clock, but the inquiry keeps its one webhook.
+ f.personaStatus(id)
+ res := f.call("inquiries", "on_resume_inquiry", "POST", "/api/inquiry/v1/inquiries/"+id+"/resume",
+ map[string]string{"inquiry_id": id}, nil, personaAuth)
+ if res.Status != 200 || personaAttrs(t, res)["status"] != "pending" {
+ t.Fatalf("resume -> %d %v, want 200 pending", res.Status, res.Body)
+ }
+ f.vc.Advance(4 * time.Second)
+ if s := f.personaStatus(id); s != "completed" {
+ t.Fatalf("status after resume = %q, want completed", s)
+ }
+ if n := len(delivered()); n != 1 {
+ t.Fatalf("%d deliveries after re-completion, want still 1 (emit exactly once)", n)
+ }
+
+ // ===== a declined inquiry emits inquiry.declined signed the same way =====
+ failID := f.personaCreate(map[string]any{
+ "template_id": "itmpl_abc123", "reference_id": "user-43", "simulate_fail": true,
+ })
+ f.vc.Advance(4 * time.Second)
+ if s := f.personaStatus(failID); s != "declined" {
+ t.Fatalf("simulate_fail status = %q, want declined", s)
+ }
+ all = delivered()
+ if len(all) != 2 {
+ t.Fatalf("%d deliveries after the declined transition, want 2", len(all))
+ }
+ personaVerifyDelivery(t, all[1])
+ eventType, payload = all[1].envelope(t)
+ if eventType != "inquiry.declined" {
+ t.Fatalf("webhook type = %q, want inquiry.declined", eventType)
+ }
+ if d, _ := payload["data"].(map[string]any); d == nil || d["id"] != failID {
+ t.Fatalf("declined webhook data = %v, want the failing inquiry", payload["data"])
+ }
+}
diff --git a/adapters/pinata-style/README.md b/adapters/pinata-style/README.md
index aebce3ac..edc2e6fe 100644
--- a/adapters/pinata-style/README.md
+++ b/adapters/pinata-style/README.md
@@ -47,20 +47,27 @@ Requests without auth return `401`.
| Method | Route | Description |
|--------|-------|-------------|
-| POST | `/pinning/pinFileToIPFS` | Pin a file (multipart upload) → CID |
+| POST | `/pinning/pinFileToIPFS` | Pin a file (multipart upload, file part required) → CID |
| POST | `/pinning/pinJSONToIPFS` | Pin a JSON object → CID |
| DELETE | `/pinning/unpin/{cid}` | Unpin by CID |
-| GET | `/data/pinList` | List pins (params: `hashContains`, `pinStart`/`pinEnd`, `pinSizeMin`/`pinSizeMax`, `status`, `metadata` name, `pageLimit`/`pageOffset`) |
+| GET | `/data/pinList` | List pins (params: `hashContains`, `pinStart`/`pinEnd`, `pinSizeMin`/`pinSizeMax`, `status`, `metadata` name, `pageLimit` default 10, `pageOffset`) |
| GET | `/data/testAuthentication` | Verify auth |
-| GET | `/data/pinByHash` | Lookup pin by hash |
+| GET | `/data/pinByHash` | Lookup pin by hash (`hash` query param required) |
## Stateful behavior
-Pins are stored in a local collection. `pinList` shows all previously pinned
-CIDs and honors the real pinList filters (`hashContains`, `pinStart`/`pinEnd`
-date range, `pinSizeMin`/`pinSizeMax`, `status`, `metadata` name) plus
-`pageLimit`/`pageOffset` paging, applied before slicing with `count` still
-reflecting the filtered total. Unpinning removes them.
+Pins are stored in a local collection. CIDs are real CIDv0 values — base58 of
+the sha2-256 multihash of the pinned bytes (the compact, key-sorted JSON
+serialization for `pinJSONToIPFS`, the file part's bytes for
+`pinFileToIPFS`) — so pinning identical content returns the same `IpfsHash`
+with `isDuplicate: true` and adds no row. `PinSize` is that content's byte
+length; `Timestamp`/`date_pinned` are the request time.
+
+`pinList` shows all previously pinned CIDs and honors the real pinList
+filters (`hashContains`, `pinStart`/`pinEnd` date range,
+`pinSizeMin`/`pinSizeMax`, `status`, `metadata` name) plus
+`pageLimit`/`pageOffset` paging at the real default of 10 rows, with `count`
+still reflecting the filtered total before slicing. Unpinning removes them.
## Response shapes
@@ -68,8 +75,8 @@ reflecting the filtered total. Unpinning removes them.
// Pin result (pinFileToIPFS / pinJSONToIPFS)
{
"IpfsHash": "Qm...",
- "PinSize": 1024,
- "Timestamp": "2024-06-15T12:30:00.000Z",
+ "PinSize": 17,
+ "Timestamp": "2026-02-03T12:00:00.000Z",
"isDuplicate": false
}
@@ -79,8 +86,8 @@ reflecting the filtered total. Unpinning removes them.
"rows": [{
"id": "7000000001",
"ipfs_pin_hash": "Qm...",
- "size": 1024,
- "date_pinned": "2024-06-15T12:30:00.000Z",
+ "size": 17,
+ "date_pinned": "2026-02-03T12:00:00.000Z",
"metadata": { "name": "my-pin" }
}]
}
diff --git a/adapters/pinata-style/adapter.yaml b/adapters/pinata-style/adapter.yaml
index 0c4f5a0e..2cfc3523 100644
--- a/adapters/pinata-style/adapter.yaml
+++ b/adapters/pinata-style/adapter.yaml
@@ -26,9 +26,12 @@ endpoints:
- route: /pinning/pinJSONToIPFS
method: POST
handler: scripts/pinning.star#on_pin_json
+ # concurrency_key serialises the read-modify-write (find the pin row,
+ # then delete it) against concurrent unpinners of the same CID.
- route: /pinning/unpin/{cid}
method: DELETE
handler: scripts/pinning.star#on_unpin
+ concurrency_key: cid
# --- Data ---
- route: /data/pinList
diff --git a/adapters/pinata-style/scripts/data.star b/adapters/pinata-style/scripts/data.star
index 5f95655e..1d71a640 100644
--- a/adapters/pinata-style/scripts/data.star
+++ b/adapters/pinata-style/scripts/data.star
@@ -36,22 +36,23 @@ def on_pin_list(req):
"rows": rows,
})
-# on_pin_by_hash looks up a pin by its hash (cid query parameter).
+# on_pin_by_hash looks up a pin by its hash (hash query parameter —
+# required, as in the real API).
def on_pin_by_hash(req):
err = _require_auth(req)
if err != None:
return err
cid = req["query"].get("hash", "")
- if cid == None:
- cid = ""
+ if cid == None or cid == "":
+ return _p_err(400, "BAD_REQUEST", "hash query parameter is required")
c = store_collection("pins")
docs = c.list()
rows = []
for doc in docs:
- if cid == "" or doc.get("ipfs_pin_hash", "") == cid:
+ if doc.get("ipfs_pin_hash", "") == cid:
rows.append(_pin_row(doc))
return respond(200, {
@@ -106,10 +107,11 @@ def _apply_pin_list_query(req, rows):
rows = query_select(rows, f if len(f) > 0 else None, None, "", None, None, None)
count = len(rows)
+ # Real API pages at 10 rows by default; count stays the filtered total.
page_limit = _to_int(_get_query(req, "pageLimit"))
- page_offset = _to_int(_get_query(req, "pageOffset"))
- if page_limit > 0 or page_offset > 0:
- rows = query_select(rows, None, "", "", page_limit if page_limit > 0 else None, page_offset, None)
+ if page_limit <= 0:
+ page_limit = 10
+ rows = query_select(rows, None, "", "", page_limit, _to_int(_get_query(req, "pageOffset")), None)
return count, rows
# _meta_name extracts the "name" value from a metadata query-param string
diff --git a/adapters/pinata-style/scripts/lib.star b/adapters/pinata-style/scripts/lib.star
index 5daf9f03..f4d6a228 100644
--- a/adapters/pinata-style/scripts/lib.star
+++ b/adapters/pinata-style/scripts/lib.star
@@ -53,27 +53,50 @@ def _p_err(status, reason, details):
},
})
-# _cid_gen generates a deterministic-looking CIDv0 string (Qm + 44 base58 chars).
-# Uses a monotonic counter so each pin gets a unique CID.
-def _cid_gen():
- n = store_kv_incr("pinata", "cid_seq")
- # Base58 alphabet (Bitcoin / IPFS flavour)
+# _hex_val maps one lowercase hex digit to its value (find == -1 never
+# happens: crypto.sha256's default encoding is lowercase hex).
+def _hex_val(ch):
+ return "0123456789abcdef".find(ch)
+
+# _base58 encodes a non-negative int in the Bitcoin/IPFS alphabet.
+def _base58(n):
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
- seed = n + 1
- suffix = ""
- for _ in range(44):
- suffix = alphabet[seed % 58] + suffix
- seed = seed // 58 + 1
- return "Qm" + suffix
+ out = ""
+ while n > 0:
+ out = alphabet[n % 58] + out
+ n = n // 58
+ return out
+
+# _cid_for returns a real CIDv0 for content bytes: base58 of the sha2-256
+# multihash (0x12 0x20 || digest), which is why it always starts "Qm".
+# Content addressing means identical bytes pin to the identical CID, so
+# re-pins are detectable (isDuplicate), like the real API.
+def _cid_for(data):
+ n = 0x1220 # multihash prefix: sha2-256, 32-byte digest
+ digest = crypto.sha256(data)
+ for i in range(len(digest)):
+ n = n * 16 + _hex_val(digest[i])
+ return _base58(n)
# _pin_id generates a Pinata pin row id.
def _pin_id():
n = store_kv_incr("pinata", "pin_seq")
return str(7000000000 + n)
-# _timestamp generates a synthetic ISO-8601 timestamp.
+# _timestamp returns the pin time in Pinata's ISO-8601 millisecond form
+# (real API stamps each pin at request time; now_rfc3339 stops at seconds).
def _timestamp():
- return "2024-06-15T12:30:00.000Z"
+ s = clock.now_rfc3339()
+ if s.endswith("Z"):
+ return s[:-1] + ".000Z"
+ return s
+
+# _pin_for returns the stored pin doc with the given CID, or None.
+def _pin_for(cid):
+ for doc in store_collection("pins").list():
+ if doc.get("ipfs_pin_hash", "") == cid:
+ return doc
+ return None
# _pin_public returns the Pinata-shaped pin list row.
def _pin_row(doc):
@@ -111,11 +134,12 @@ def _to_int(s):
return 0
return n
-# _pin_result returns the Pinata-shaped pin result (from pinFileToIPFS / pinJSONToIPFS).
-def _pin_result(doc):
+# _pin_result returns the Pinata-shaped pin result (from pinFileToIPFS /
+# pinJSONToIPFS). is_duplicate is True only on the re-pin response.
+def _pin_result(doc, is_duplicate):
return {
"IpfsHash": doc.get("ipfs_pin_hash", ""),
"PinSize": doc.get("size", 0),
"Timestamp": doc.get("timestamp", ""),
- "isDuplicate": doc.get("is_duplicate", False),
+ "isDuplicate": is_duplicate,
}
diff --git a/adapters/pinata-style/scripts/pinning.star b/adapters/pinata-style/scripts/pinning.star
index 9c86a3fd..cc0f5403 100644
--- a/adapters/pinata-style/scripts/pinning.star
+++ b/adapters/pinata-style/scripts/pinning.star
@@ -6,53 +6,62 @@
# POST /pinning/pinJSONToIPFS ({pinataContent}) → { IpfsHash, PinSize, Timestamp }
# DELETE /pinning/unpin/{cid} → 200 OK
-# on_pin_file handles multipart file upload pinning.
+# on_pin_file handles multipart file upload pinning. The file part's bytes
+# become the pin's size and CID; a pinataMetadata form field names it.
def on_pin_file(req):
err = _require_auth(req)
if err != None:
return err
- cid = _cid_gen()
- row_id = _pin_id()
- ts = _timestamp()
-
- # Determine a synthetic pin size. For multipart uploads the body is not
- # parsed as JSON (body will be nil); we use a deterministic default.
- pin_size = 1024
-
- # Extract a name from pinataMetadata if present in the body.
name = "pin-file"
- body = req.get("body")
- if body != None:
- meta = body.get("pinataMetadata")
- if meta != None:
- mn = meta.get("name")
- if mn != None:
- name = mn
+ data = None
+ raw = req.get("raw_body")
+ if raw == None:
+ raw = ""
+ parts, perr = parse_multipart(_header(req, "Content-Type"), raw)
+ if perr == None:
+ for p in parts:
+ if p["filename"] != None:
+ data = p["data"]
+ elif p["name"] == "pinataMetadata":
+ meta = json_safe_decode(p["data"])
+ if meta != None and meta.get("name") != None:
+ name = meta.get("name")
+
+ # Real API rejects an upload with no file part.
+ if data == None:
+ return _p_err(400, "BAD_REQUEST", "No file was detected in the request")
+
+ cid = _cid_for(data)
+
+ # Identical bytes are already pinned: report the duplicate, no new row.
+ existing = _pin_for(cid)
+ if existing != None:
+ return respond(200, _pin_result(existing, True))
+ ts = _timestamp()
doc = {
- "id": row_id,
+ "id": _pin_id(),
"ipfs_pin_hash": cid,
- "size": pin_size,
+ "size": len(data),
"date_pinned": ts,
"timestamp": ts,
"metadata": {"name": name},
"is_duplicate": False,
}
+ store_collection("pins").insert(doc)
+ return respond(200, _pin_result(doc, False))
- c = store_collection("pins")
- c.insert(doc)
-
- return respond(200, _pin_result(doc))
-
-# on_pin_json handles JSON pinning.
+# on_pin_json handles JSON pinning. PinSize/CID derive from the serialized
+# pinataContent (compact, key-sorted json.encode — deterministic).
def on_pin_json(req):
err = _require_auth(req)
if err != None:
return err
+ # The engine hands a missing JSON body over as an empty dict, not None.
body = req.get("body")
- if body == None:
+ if body == None or len(body) == 0:
return _p_err(400, "BAD_REQUEST", "Request body is required")
# Pinata wraps content in pinataContent; fall back to body itself.
@@ -60,9 +69,6 @@ def on_pin_json(req):
if content == None:
content = body
- # Synthetic pin size based on content.
- pin_size = 512
-
# Extract name from pinataMetadata if present.
name = "pin-json"
meta = body.get("pinataMetadata")
@@ -71,24 +77,26 @@ def on_pin_json(req):
if mn != None:
name = mn
- cid = _cid_gen()
- row_id = _pin_id()
- ts = _timestamp()
+ data = json.encode(content)
+ cid = _cid_for(data)
+
+ # Identical content is already pinned: report the duplicate, no new row.
+ existing = _pin_for(cid)
+ if existing != None:
+ return respond(200, _pin_result(existing, True))
+ ts = _timestamp()
doc = {
- "id": row_id,
+ "id": _pin_id(),
"ipfs_pin_hash": cid,
- "size": pin_size,
+ "size": len(data),
"date_pinned": ts,
"timestamp": ts,
"metadata": {"name": name},
"is_duplicate": False,
}
-
- c = store_collection("pins")
- c.insert(doc)
-
- return respond(200, _pin_result(doc))
+ store_collection("pins").insert(doc)
+ return respond(200, _pin_result(doc, False))
# on_unpin removes a pin by CID.
def on_unpin(req):
@@ -100,12 +108,10 @@ def on_unpin(req):
if cid == None or cid == "":
return _p_err(400, "BAD_REQUEST", "CID path parameter is required")
- c = store_collection("pins")
- docs = c.list()
- for doc in docs:
- if doc.get("ipfs_pin_hash", "") == cid:
- c.delete(doc.get("id", ""))
- return respond(200, {})
+ doc = _pin_for(cid)
+ if doc != None:
+ store_collection("pins").delete(doc.get("id", ""))
+ return respond(200, {})
# Pinata returns 403 when unpinning a CID that isn't pinned.
return _p_err(403, "FORBIDDEN", "CID not pinned to this account")
diff --git a/adapters/pinata_style_test.go b/adapters/pinata_style_test.go
new file mode 100644
index 00000000..660acfa7
--- /dev/null
+++ b/adapters/pinata_style_test.go
@@ -0,0 +1,334 @@
+package adapters
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the pinata-style adapter scripts directly (lib.star preloaded)
+// over a shared store and virtual clock: the API-key-pair / Bearer-JWT
+// gate, content-addressed CIDv0 pinning (JSON + multipart file) with
+// isDuplicate re-pin detection, the pinList filters and default-10 paging,
+// pinByHash, unpin, and the {error:{reason,details}} envelope.
+var (
+ pinBearer = map[string]string{"Authorization": "Bearer pinata-jwt"}
+ pinKeyPair = map[string]string{"pinata_api_key": "vm-key", "pinata_secret_api_key": "vm-secret"}
+)
+
+// Expected CIDs: base58(0x12 0x20 || sha256(content)) — real CIDv0 math,
+// so identical bytes always pin to the identical "Qm…" hash.
+const (
+ pinJSONHash = "QmYGx7Wzqe5prvEsTSzYBQN8xViYUM9qsWJSF5EENLcNmM" // {"hello":"world"}
+)
+
+type pinataFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vmPin *starlark.VM
+ vmData *starlark.VM
+}
+
+func newPinataFixture(t *testing.T, start time.Time) *pinataFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "pinata-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &pinataFixture{t: t, vc: vc, vmPin: load("pinning.star"), vmData: load("data.star")}
+}
+
+// call invokes handler on vm with explicit headers (nil = header absent).
+func (f *pinataFixture) call(vm *starlark.VM, handler, method, path string, params, query map[string]string, body map[string]any, headers map[string]string) starlark.Response {
+ f.t.Helper()
+ resp, err := vm.Call(handler, starlark.Request{
+ Method: method, Path: path, Host: "api.pinata.cloud",
+ Headers: headers, Body: body, Params: params, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// callMultipart is call with a raw multipart body, the way the engine
+// delivers file uploads (JSON body nil, bytes in raw_body).
+func (f *pinataFixture) callMultipart(handler, path, contentType, rawBody string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ for k, v := range pinBearer {
+ headers[k] = v
+ }
+ headers["Content-Type"] = contentType
+ resp, err := f.vmPin.Call(handler, starlark.Request{
+ Method: "POST", Path: path, Host: "api.pinata.cloud", Headers: headers, RawBody: rawBody,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// pinRows pulls the rows array out of a {count, rows} envelope.
+func pinRows(t *testing.T, r starlark.Response) []any {
+ t.Helper()
+ rows, ok := r.Body["rows"].([]any)
+ if !ok {
+ t.Fatalf("rows = %v, want an array (status %d)", r.Body["rows"], r.Status)
+ }
+ return rows
+}
+
+// pinErrReason extracts error.reason from the Pinata error envelope.
+func pinErrReason(r starlark.Response) string {
+ e, _ := r.Body["error"].(map[string]any)
+ reason, _ := e["reason"].(string)
+ return reason
+}
+
+// pinataMultipart builds a file + pinataMetadata multipart body.
+func pinataMultipart(boundary, filename, fileData, metaJSON string) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "--%s\r\nContent-Disposition: form-data; name=\"file\"; filename=%q\r\nContent-Type: application/octet-stream\r\n\r\n%s\r\n",
+ boundary, filename, fileData)
+ fmt.Fprintf(&b, "--%s\r\nContent-Disposition: form-data; name=\"pinataMetadata\"\r\nContent-Type: application/json\r\n\r\n%s\r\n", boundary, metaJSON)
+ fmt.Fprintf(&b, "--%s--\r\n", boundary)
+ return b.String()
+}
+
+func TestPinataPinningLifecycle(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newPinataFixture(t, base)
+
+ // ===== missing or half-present credentials are 401 with the error envelope =====
+ // No credentials, key without secret, and an empty Bearer all 401.
+ for name, headers := range map[string]map[string]string{
+ "no credentials": nil,
+ "key without secret": {"pinata_api_key": "vm-key"},
+ "bearer no token": {"Authorization": "Bearer "},
+ } {
+ r := f.call(f.vmData, "on_test_auth", "GET", "/data/testAuthentication", nil, nil, nil, headers)
+ if r.Status != 401 || pinErrReason(r) != "UNAUTHORIZED" {
+ t.Fatalf("%s -> %d %v, want 401 UNAUTHORIZED", name, r.Status, r.Body)
+ }
+ }
+
+ // ===== the API key pair and a Bearer JWT both open testAuthentication =====
+ // Either scheme yields the congratulations message.
+ for name, headers := range map[string]map[string]string{"key pair": pinKeyPair, "bearer": pinBearer} {
+ r := f.call(f.vmData, "on_test_auth", "GET", "/data/testAuthentication", nil, nil, nil, headers)
+ if r.Status != 200 || r.Body["message"] != "Congratulations! You are communicating with the Pinata API!" {
+ t.Fatalf("%s testAuthentication -> %d %v", name, r.Status, r.Body)
+ }
+ }
+
+ // ===== pinJSONToIPFS pins content to a real CIDv0 =====
+ // Size is the serialized content's byte length; the CID is real CIDv0
+ // (base58 of the sha2-256 multihash), stamped at request time.
+ jsonPin := f.call(f.vmPin, "on_pin_json", "POST", "/pinning/pinJSONToIPFS", nil, nil, map[string]any{
+ "pinataContent": map[string]any{"hello": "world"},
+ "pinataMetadata": map[string]any{"name": "vm-pin"},
+ }, pinBearer)
+ if jsonPin.Status != 200 {
+ t.Fatalf("pinJSONToIPFS -> %d: %v", jsonPin.Status, jsonPin.Body)
+ }
+ if jsonPin.Body["IpfsHash"] != pinJSONHash {
+ t.Fatalf("IpfsHash = %v, want the content's CIDv0 %s", jsonPin.Body["IpfsHash"], pinJSONHash)
+ }
+ if jsonPin.Body["PinSize"] != int64(17) { // len(`{"hello":"world"}`)
+ t.Fatalf("PinSize = %v, want 17 (serialized bytes)", jsonPin.Body["PinSize"])
+ }
+ if jsonPin.Body["Timestamp"] != "2026-02-03T12:00:00.000Z" || jsonPin.Body["isDuplicate"] != false {
+ t.Fatalf("pin result = %v, want request-time Timestamp and isDuplicate false", jsonPin.Body)
+ }
+ // A body-less pin is a 400.
+ if r := f.call(f.vmPin, "on_pin_json", "POST", "/pinning/pinJSONToIPFS", nil, nil, nil, pinBearer); r.Status != 400 || pinErrReason(r) != "BAD_REQUEST" {
+ t.Fatalf("body-less pinJSONToIPFS -> %d %v, want 400 BAD_REQUEST", r.Status, r.Body)
+ }
+
+ // ===== re-pinning identical content is isDuplicate, not a new pin =====
+ // Same bytes -> same CID, isDuplicate true, and no second row.
+ dup := f.call(f.vmPin, "on_pin_json", "POST", "/pinning/pinJSONToIPFS", nil, nil, map[string]any{
+ "pinataContent": map[string]any{"hello": "world"},
+ }, pinKeyPair)
+ if dup.Status != 200 || dup.Body["IpfsHash"] != pinJSONHash || dup.Body["isDuplicate"] != true {
+ t.Fatalf("re-pin -> %d %v, want same CID and isDuplicate true", dup.Status, dup.Body)
+ }
+ other := f.call(f.vmPin, "on_pin_json", "POST", "/pinning/pinJSONToIPFS", nil, nil, map[string]any{
+ "pinataContent": map[string]any{"hello": "stunt"},
+ }, pinBearer)
+ if other.Status != 200 || other.Body["IpfsHash"] == pinJSONHash || other.Body["isDuplicate"] != false {
+ t.Fatalf("different content -> %d %v, want a fresh CID", other.Status, other.Body)
+ }
+
+ // ===== pinFileToIPFS sizes and names the pin from the multipart parts =====
+ // The file part's bytes set PinSize/CID; pinataMetadata names it. A
+ // multipart body with no file part is a 400.
+ f.vc.Advance(30 * time.Minute)
+ mp := pinataMultipart("vmBoundary", "hello.bin", strings.Repeat("ipfs", 500), `{"name":"vm-file-pin"}`)
+ filePin := f.callMultipart("on_pin_file", "/pinning/pinFileToIPFS",
+ "multipart/form-data; boundary=vmBoundary", mp)
+ if filePin.Status != 200 || filePin.Body["PinSize"] != int64(2000) || filePin.Body["isDuplicate"] != false {
+ t.Fatalf("pinFileToIPFS -> %d %v, want PinSize 2000 from the file part", filePin.Status, filePin.Body)
+ }
+ fileHash, _ := filePin.Body["IpfsHash"].(string)
+ if !strings.HasPrefix(fileHash, "Qm") || len(fileHash) != 46 {
+ t.Fatalf("file IpfsHash = %q, want a 46-char CIDv0", fileHash)
+ }
+ if filePin.Body["Timestamp"] != "2026-02-03T12:30:00.000Z" {
+ t.Fatalf("file Timestamp = %v, want the advanced clock time", filePin.Body["Timestamp"])
+ }
+ metaOnly := "--vmB\r\nContent-Disposition: form-data; name=\"pinataMetadata\"\r\n\r\n{}\r\n--vmB--\r\n"
+ if r := f.callMultipart("on_pin_file", "/pinning/pinFileToIPFS", "multipart/form-data; boundary=vmB", metaOnly); r.Status != 400 || pinErrReason(r) != "BAD_REQUEST" {
+ t.Fatalf("file-less pinFileToIPFS -> %d %v, want 400 BAD_REQUEST", r.Status, r.Body)
+ }
+
+ // ===== pinList filters by hash, size, status, and metadata name =====
+ // Three rows so far: two JSON pins (17/18 bytes, 12:00) and the file
+ // (2000 bytes, 12:30). Every filter narrows within them.
+ list := func(query map[string]string) starlark.Response {
+ return f.call(f.vmData, "on_pin_list", "GET", "/data/pinList", nil, query, nil, pinBearer)
+ }
+ if r := list(map[string]string{"hashContains": pinJSONHash}); r.Status != 200 || r.Body["count"] != int64(1) {
+ t.Fatalf("hashContains -> %d %v, want the one matching row", r.Status, r.Body)
+ }
+ if r := list(map[string]string{"pinSizeMin": "1000"}); r.Body["count"] != int64(1) {
+ t.Fatalf("pinSizeMin 1000 -> %v, want only the 2000-byte file", r.Body["count"])
+ }
+ if r := list(map[string]string{"pinSizeMax": "1000"}); r.Body["count"] != int64(2) {
+ t.Fatalf("pinSizeMax 1000 -> %v, want the two JSON pins", r.Body["count"])
+ }
+ if r := list(map[string]string{"status": "unpinned"}); r.Status != 200 || r.Body["count"] != int64(0) || len(pinRows(t, r)) != 0 {
+ t.Fatalf("status unpinned -> %d %v, want an empty result (no unpin history)", r.Status, r.Body)
+ }
+ if r := list(map[string]string{"status": "all"}); r.Body["count"] != int64(3) {
+ t.Fatalf("status all -> %v, want all three pins", r.Body["count"])
+ }
+ meta := list(map[string]string{"metadata": `{"name":"vm-file-pin"}`})
+ if meta.Status != 200 || meta.Body["count"] != int64(1) {
+ t.Fatalf("metadata name filter -> %d %v, want the file pin", meta.Status, meta.Body)
+ }
+ if mrows := pinRows(t, meta); len(mrows) == 1 {
+ row, _ := mrows[0].(map[string]any)
+ md, _ := row["metadata"].(map[string]any)
+ if md["name"] != "vm-file-pin" || row["ipfs_pin_hash"] != fileHash {
+ t.Fatalf("metadata-filtered row = %v, want the named file pin", mrows[0])
+ }
+ }
+
+ // ===== pinStart/pinEnd bound the date-pinned window =====
+ // ISO-8601 bounds: before 12:15 keeps the JSON pins, after 12:15 the file.
+ if r := list(map[string]string{"pinEnd": "2026-02-03T12:15:00.000Z"}); r.Body["count"] != int64(2) {
+ t.Fatalf("pinEnd 12:15 -> %v, want the two 12:00 JSON pins", r.Body["count"])
+ }
+ if r := list(map[string]string{"pinStart": "2026-02-03T12:15:00.000Z"}); r.Body["count"] != int64(1) {
+ t.Fatalf("pinStart 12:15 -> %v, want only the 12:30 file pin", r.Body["count"])
+ }
+ if r := list(map[string]string{"pinStart": "2026-06-01T00:00:00.000Z", "pinEnd": "2026-06-02T00:00:00.000Z"}); r.Body["count"] != int64(0) {
+ t.Fatalf("empty date window -> %v, want 0", r.Body["count"])
+ }
+
+ // ===== pinByHash requires hash and matches the CID exactly =====
+ byHash := func(query map[string]string) starlark.Response {
+ return f.call(f.vmData, "on_pin_by_hash", "GET", "/data/pinByHash", nil, query, nil, pinBearer)
+ }
+ if r := byHash(map[string]string{"hash": fileHash}); r.Status != 200 || r.Body["count"] != int64(1) || len(pinRows(t, r)) != 1 {
+ t.Fatalf("pinByHash file -> %d %v, want exactly the file pin", r.Status, r.Body)
+ }
+ if r := byHash(map[string]string{"hash": "QmNotAPinnedCid0000000000000000000000000000"}); r.Status != 200 || r.Body["count"] != int64(0) {
+ t.Fatalf("pinByHash unknown -> %d %v, want an empty 200", r.Status, r.Body)
+ }
+ if r := byHash(nil); r.Status != 400 || pinErrReason(r) != "BAD_REQUEST" {
+ t.Fatalf("pinByHash without hash -> %d %v, want 400 BAD_REQUEST", r.Status, r.Body)
+ }
+
+ // ===== pinList pages at the real default of 10 rows =====
+ // 12 more JSON pins -> 15 rows; default page 10, count stays 15.
+ f.vc.Advance(time.Hour)
+ for i := 1; i <= 12; i++ {
+ r := f.call(f.vmPin, "on_pin_json", "POST", "/pinning/pinJSONToIPFS", nil, nil, map[string]any{
+ "pinataContent": map[string]any{"i": i},
+ }, pinBearer)
+ if r.Status != 200 || r.Body["isDuplicate"] != false {
+ t.Fatalf("pin %d -> %d %v, want a fresh pin", i, r.Status, r.Body)
+ }
+ }
+ page1 := list(nil)
+ if rows := pinRows(t, page1); len(rows) != 10 || page1.Body["count"] != int64(15) {
+ t.Fatalf("default pinList -> %d rows count %v, want 10 of 15", len(rows), page1.Body["count"])
+ }
+ page2 := list(map[string]string{"pageOffset": "10"})
+ if rows := pinRows(t, page2); len(rows) != 5 || page2.Body["count"] != int64(15) {
+ t.Fatalf("pageOffset 10 -> %d rows count %v, want the remaining 5 of 15", len(rows), page2.Body["count"])
+ }
+ tail := list(map[string]string{"pageLimit": "3", "pageOffset": "12"})
+ if rows := pinRows(t, tail); len(rows) != 3 || tail.Body["count"] != int64(15) {
+ t.Fatalf("pageLimit 3 offset 12 -> %d rows, want 3", len(rows))
+ }
+ id1, _ := pinRows(t, page1)[0].(map[string]any)["id"].(string)
+ id2, _ := pinRows(t, page2)[0].(map[string]any)["id"].(string)
+ if id1 == id2 {
+ t.Fatalf("paging repeated row %s", id1)
+ }
+
+ // ===== unpin removes the CID; a second unpin is 403 FORBIDDEN =====
+ // Unpin 200s once, empties pinByHash, drops the list count, then 403s.
+ unpin := f.call(f.vmPin, "on_unpin", "DELETE", "/pinning/unpin/"+fileHash,
+ map[string]string{"cid": fileHash}, nil, nil, pinBearer)
+ if unpin.Status != 200 {
+ t.Fatalf("unpin -> %d: %v", unpin.Status, unpin.Body)
+ }
+ if r := byHash(map[string]string{"hash": fileHash}); r.Status != 200 || r.Body["count"] != int64(0) {
+ t.Fatalf("pinByHash after unpin -> %d %v, want 0 rows", r.Status, r.Body)
+ }
+ if r := list(map[string]string{"hashContains": fileHash}); r.Body["count"] != int64(0) {
+ t.Fatalf("pinList after unpin -> %v, want the file pin gone", r.Body["count"])
+ }
+ if r := list(nil); r.Body["count"] != int64(14) {
+ t.Fatalf("pinList count after unpin = %v, want 14", r.Body["count"])
+ }
+ if r := f.call(f.vmPin, "on_unpin", "DELETE", "/pinning/unpin/"+fileHash,
+ map[string]string{"cid": fileHash}, nil, nil, pinBearer); r.Status != 403 || pinErrReason(r) != "FORBIDDEN" {
+ t.Fatalf("second unpin -> %d %v, want 403 FORBIDDEN", r.Status, r.Body)
+ }
+ if r := f.call(f.vmPin, "on_unpin", "DELETE", "/pinning/unpin/QmNotAPinnedCid0000000000000000000000000000",
+ map[string]string{"cid": "QmNotAPinnedCid0000000000000000000000000000"}, nil, nil, pinKeyPair); r.Status != 403 {
+ t.Fatalf("unpin unknown CID -> %d, want 403", r.Status)
+ }
+}
diff --git a/adapters/reddit_style_test.go b/adapters/reddit_style_test.go
new file mode 100644
index 00000000..7da4c5c0
--- /dev/null
+++ b/adapters/reddit_style_test.go
@@ -0,0 +1,308 @@
+package adapters
+
+import (
+ "encoding/base64"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the reddit-style adapter scripts directly (lib.star preloaded) over
+// a shared store and virtual clock: the User-Agent gate on both routes, the
+// HTTP Basic client-credential gate on the token endpoint, the
+// authorization_code (permanent -> access + refresh) and refresh_token
+// (access only) grants, the Bearer gate on submit with the USER_REQUIRED
+// json envelope, the t3_ thing shape, and the one-hour token expiry.
+const redditUA = "stunt-vm-suite/1.0 (by /u/tester)"
+
+// redditBasic computes the HTTP Basic client-credential header value.
+func redditBasic() string {
+ return "Basic " + base64.StdEncoding.EncodeToString([]byte("vm-client-id:vm-client-secret"))
+}
+
+type redditFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ host string
+}
+
+func newRedditFixture(t *testing.T, start time.Time) *redditFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "reddit-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &redditFixture{t: t, vc: vc, host: "www.reddit.test", vms: map[string]*starlark.VM{
+ "oauth": load("oauth.star"), "submit": load("submit.star"),
+ }}
+}
+
+// call drives one POST handler; headers always carry the UA unless ua is
+// overridden ("" means none, mirroring a request without the header).
+func (f *redditFixture) call(group, handler, path string, form map[string]any, auth, ua string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if ua != "" {
+ headers["User-Agent"] = ua
+ }
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: "POST", Path: path, Host: f.host, Headers: headers, Body: form,
+ Params: map[string]string{}, Query: map[string]string{},
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// rdErrorTriple unwraps the Reddit json envelope's first error triple
+// ([CODE, message, field]).
+func rdErrorTriple(t *testing.T, r starlark.Response) []any {
+ t.Helper()
+ j, ok := r.Body["json"].(map[string]any)
+ if !ok {
+ t.Fatalf("body has no json envelope: %v", r.Body)
+ }
+ errs, ok := j["errors"].([]any)
+ if !ok || len(errs) == 0 {
+ t.Fatalf("json.errors = %v, want a non-empty list", j["errors"])
+ }
+ triple, ok := errs[0].([]any)
+ if !ok || len(triple) < 2 {
+ t.Fatalf("error triple = %v, want [CODE, message, field]", errs[0])
+ }
+ return triple
+}
+
+// TestRedditTokenEndpoint: the User-Agent and HTTP Basic gates plus the
+// authorization_code and refresh_token grant shapes.
+func TestRedditTokenEndpoint(t *testing.T) {
+ f := newRedditFixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC))
+ tokenPath := "/api/v1/access_token"
+
+ // ===== a missing or generic User-Agent is 429 on both routes =====
+ // Reddit 429s absent/generic UAs; the mock reproduces it.
+ noUA := f.call("oauth", "on_access_token", tokenPath,
+ map[string]any{"grant_type": "authorization_code"}, redditBasic(), "")
+ if noUA.Status != 429 || noUA.Body["message"] != "Too Many Requests" {
+ t.Fatalf("no UA -> %d %v, want 429 Too Many Requests", noUA.Status, noUA.Body)
+ }
+ if rdNum(noUA.Body["error"]) != 429 {
+ t.Fatalf("UA-reject error = %v (%T), want 429", noUA.Body["error"], noUA.Body["error"])
+ }
+ seeded := f.call("oauth", "on_access_token", tokenPath,
+ map[string]any{"grant_type": "authorization_code", "duration": "permanent"}, redditBasic(), redditUA)
+ access, _ := seeded.Body["access_token"].(string)
+ generic := f.call("submit", "on_submit", "/api/submit",
+ map[string]any{"sr": "test", "title": "Title"}, "Bearer "+access, "python-requests/2.31.0")
+ if generic.Status != 429 {
+ t.Fatalf("generic UA (no parens) -> %d, want 429", generic.Status)
+ }
+
+ // ===== the token endpoint requires HTTP Basic client credentials =====
+ // No Basic header -> 401 invalid_client; an unknown grant is a 400.
+ if r := f.call("oauth", "on_access_token", tokenPath,
+ map[string]any{"grant_type": "authorization_code"}, "", redditUA); r.Status != 401 || r.Body["error"] != "invalid_client" {
+ t.Fatalf("no Basic -> %d %v, want 401 invalid_client", r.Status, r.Body)
+ }
+ if r := f.call("oauth", "on_access_token", tokenPath,
+ map[string]any{"grant_type": "client_credentials"}, redditBasic(), redditUA); r.Status != 400 || r.Body["error"] != "unsupported_grant_type" {
+ t.Fatalf("unknown grant -> %d %v, want 400 unsupported_grant_type", r.Status, r.Body)
+ }
+
+ // ===== a permanent authorization_code mints access and refresh together =====
+ if seeded.Status != 200 {
+ t.Fatalf("authorization_code -> %d: %v", seeded.Status, seeded.Body)
+ }
+ if access == "" {
+ t.Fatalf("authorization_code returned no access_token: %v", seeded.Body)
+ }
+ refresh, _ := seeded.Body["refresh_token"].(string)
+ if refresh == "" || refresh[:6] != "rdref_" {
+ t.Fatalf("permanent grant refresh_token = %q, want an rdref_ token", refresh)
+ }
+ rdAssertTokenShape(t, seeded.Body)
+
+ // A temporary grant (no duration) issues no refresh token at all.
+ temp := f.call("oauth", "on_access_token", tokenPath,
+ map[string]any{"grant_type": "authorization_code"}, redditBasic(), redditUA)
+ if _, exists := temp.Body["refresh_token"]; exists {
+ t.Fatalf("temporary grant issued a refresh_token: %v", temp.Body)
+ }
+ rdAssertTokenShape(t, temp.Body)
+
+ // ===== a refresh grant returns a fresh access token and no new refresh =====
+ // Reddit only issues a refresh token on the initial permanent grant.
+ refreshed := f.call("oauth", "on_access_token", tokenPath,
+ map[string]any{"grant_type": "refresh_token", "refresh_token": refresh}, redditBasic(), redditUA)
+ if refreshed.Status != 200 {
+ t.Fatalf("refresh grant -> %d: %v", refreshed.Status, refreshed.Body)
+ }
+ if _, exists := refreshed.Body["refresh_token"]; exists {
+ t.Fatalf("refresh grant returned a new refresh_token: %v", refreshed.Body)
+ }
+ if got, _ := refreshed.Body["access_token"].(string); got == access {
+ t.Fatalf("refresh grant returned the same access token %q, want a fresh one", got)
+ }
+ rdAssertTokenShape(t, refreshed.Body)
+ if r := f.call("oauth", "on_access_token", tokenPath,
+ map[string]any{"grant_type": "refresh_token", "refresh_token": "rdref_unknown"}, redditBasic(), redditUA); r.Status != 400 || r.Body["error"] != "invalid_grant" {
+ t.Fatalf("unknown refresh token -> %d %v, want 400 invalid_grant", r.Status, r.Body)
+ }
+}
+
+// rdAssertTokenShape checks the shared OAuth token response fields.
+func rdAssertTokenShape(t *testing.T, body map[string]any) {
+ t.Helper()
+ if body["token_type"] != "bearer" {
+ t.Fatalf("token_type = %v, want bearer", body["token_type"])
+ }
+ if rdNum(body["expires_in"]) != 3600 {
+ t.Fatalf("expires_in = %v (%T), want 3600", body["expires_in"], body["expires_in"])
+ }
+ if body["scope"] != "submit identity" {
+ t.Fatalf("scope = %v, want 'submit identity'", body["scope"])
+ }
+}
+
+// TestRedditSubmit: the Bearer gate, the t3_ thing envelope, and the
+// HTTP-200 error-triple vocabulary.
+func TestRedditSubmit(t *testing.T) {
+ f := newRedditFixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC))
+ mint := func() string {
+ t.Helper()
+ r := f.call("oauth", "on_access_token", "/api/v1/access_token",
+ map[string]any{"grant_type": "authorization_code"}, redditBasic(), redditUA)
+ tok, _ := r.Body["access_token"].(string)
+ if r.Status != 200 || tok == "" {
+ t.Fatalf("mint access token -> %d: %v", r.Status, r.Body)
+ }
+ return tok
+ }
+ access := mint()
+
+ // ===== submit requires a bearer the adapter itself minted =====
+ // Missing, unknown, and expired tokens all get Reddit's USER_REQUIRED
+ // json envelope.
+ noAuth := f.call("submit", "on_submit", "/api/submit",
+ map[string]any{"sr": "test", "title": "Title"}, "", redditUA)
+ if noAuth.Status != 401 {
+ t.Fatalf("submit without bearer -> %d, want 401", noAuth.Status)
+ }
+ if code := rdErrorTriple(t, noAuth)[0]; code != "USER_REQUIRED" {
+ t.Fatalf("no-bearer error code = %v, want USER_REQUIRED", code)
+ }
+ unknown := f.call("submit", "on_submit", "/api/submit",
+ map[string]any{"sr": "test", "title": "Title"}, "Bearer rdtok_999", redditUA)
+ if unknown.Status != 401 {
+ t.Fatalf("submit with unknown bearer -> %d, want 401", unknown.Status)
+ }
+ if code := rdErrorTriple(t, unknown)[0]; code != "USER_REQUIRED" {
+ t.Fatalf("unknown-bearer error code = %v, want USER_REQUIRED", code)
+ }
+
+ // ===== a valid submit returns the t3_ thing envelope =====
+ ok := f.call("submit", "on_submit", "/api/submit", map[string]any{
+ "sr": "test", "title": "Hello from the stunt VM suite", "kind": "self",
+ }, "Bearer "+access, redditUA)
+ if ok.Status != 200 {
+ t.Fatalf("submit -> %d: %v", ok.Status, ok.Body)
+ }
+ j, _ := ok.Body["json"].(map[string]any)
+ if errs, _ := j["errors"].([]any); len(errs) != 0 {
+ t.Fatalf("valid submit errors = %v, want empty", errs)
+ }
+ data, _ := j["data"].(map[string]any)
+ postID, _ := data["id"].(string)
+ if postID == "" {
+ t.Fatalf("submit data = %v, want an id", data)
+ }
+ if data["name"] != "t3_"+postID {
+ t.Fatalf("submit name = %v, want t3_%s", data["name"], postID)
+ }
+ url, _ := data["url"].(string)
+ if want := "/r/test/comments/" + postID + "/hello_from_the_stunt_vm_suite/"; !strings.Contains(url, want) {
+ t.Fatalf("submit url = %q, want it to contain %q", url, want)
+ }
+
+ // ===== missing sr or title stay HTTP 200 with Reddit error triples =====
+ noSr := f.call("submit", "on_submit", "/api/submit",
+ map[string]any{"title": "Title"}, "Bearer "+access, redditUA)
+ if noSr.Status != 200 {
+ t.Fatalf("submit without sr -> %d, want 200 (Reddit reports field errors in-band)", noSr.Status)
+ }
+ if triple := rdErrorTriple(t, noSr); triple[0] != "SUBREDDIT_REQUIRED" || triple[2] != "sr" {
+ t.Fatalf("no-sr triple = %v, want [SUBREDDIT_REQUIRED, ..., sr]", triple)
+ }
+ noTitle := f.call("submit", "on_submit", "/api/submit",
+ map[string]any{"sr": "test"}, "Bearer "+access, redditUA)
+ if noTitle.Status != 200 {
+ t.Fatalf("submit without title -> %d, want 200", noTitle.Status)
+ }
+ if triple := rdErrorTriple(t, noTitle); triple[0] != "NO_TEXT" || triple[2] != "title" {
+ t.Fatalf("no-title triple = %v, want [NO_TEXT, ..., title]", triple)
+ }
+
+ // ===== an access token dies after its one-hour window =====
+ // The minted expires_at is honored on read (virtual clock, no sleeps).
+ f.vc.Advance(3601 * time.Second)
+ expired := f.call("submit", "on_submit", "/api/submit",
+ map[string]any{"sr": "test", "title": "Title"}, "Bearer "+access, redditUA)
+ if expired.Status != 401 {
+ t.Fatalf("submit with expired bearer -> %d, want 401", expired.Status)
+ }
+ if code := rdErrorTriple(t, expired)[0]; code != "USER_REQUIRED" {
+ t.Fatalf("expired-bearer error code = %v, want USER_REQUIRED", code)
+ }
+}
+
+// rdNum reads a response number whether the adapter produced a fresh
+// Starlark int or a value that round-tripped the JSON document store.
+func rdNum(v any) int64 {
+ switch n := v.(type) {
+ case int64:
+ return n
+ case float64:
+ return int64(n)
+ }
+ return -1
+}
diff --git a/adapters/revenuecat-style/README.md b/adapters/revenuecat-style/README.md
index d4972949..3b18aa7c 100644
--- a/adapters/revenuecat-style/README.md
+++ b/adapters/revenuecat-style/README.md
@@ -147,6 +147,8 @@ with the real RC field names:
```json
{
+ "request_date": "2026-08-14T10:00:00Z",
+ "request_date_ms": 1786768800000,
"subscriber": {
"original_app_user_id": "user-1",
"first_seen": "2026-08-14T10:00:00Z",
@@ -195,10 +197,21 @@ deterministically.
## Auth
-Bearer authentication: `Authorization: Bearer ` against the token store.
-The well-known static test key **`sk_test_revenuecat_style_mock_key`** is
-seeded once on first request (insert-once); any other key is rejected with
-`401 {"code": 401, "message": "Invalid API key."}`.
+Bearer authentication: `Authorization: Bearer ` against the token store,
+mirroring RevenueCat's key split:
+
+- **Secret key** **`sk_test_revenuecat_style_mock_key`** — accepted on every
+ endpoint (like real `sk_` project secret keys).
+- **Public SDK key** **`pk_test_revenuecat_style_mock_key`** — accepted only on
+ the SDK-facing endpoints (`GET /v1/subscribers/{id}`, `POST /v1/receipts`).
+ On restricted endpoints (subscriber `POST`/`DELETE`, revoke, webhook
+ management) it is rejected with
+ `401 {"code": 401, "message": "This endpoint requires a secret API key."}`.
+
+Both keys are seeded once on first request (insert-once); any other key is
+rejected with `401 {"code": 401, "message": "Invalid API key."}`, and a
+missing header with
+`401 {"code": 401, "message": "Missing API key in Authorization header."}`.
## Usage
diff --git a/adapters/revenuecat-style/adapter.yaml b/adapters/revenuecat-style/adapter.yaml
index 01e2faa1..4c615d29 100644
--- a/adapters/revenuecat-style/adapter.yaml
+++ b/adapters/revenuecat-style/adapter.yaml
@@ -59,8 +59,10 @@ resources:
- name: webhooks
kind: collection
-# Auth scheme metadata (mock: Bearer sk_... public SDK key; the well-known
-# static test key in scripts/lib.star is seeded into the token store).
+# Auth scheme metadata (mock: Bearer keys; the well-known static test secret
+# sk_... key and public pk_... SDK key in scripts/lib.star are seeded into
+# the token store — secrets work everywhere, publics only on SDK-facing
+# routes, like the real API).
identity:
token_scheme: bearer
diff --git a/adapters/revenuecat-style/scripts/lib.star b/adapters/revenuecat-style/scripts/lib.star
index 1a602a0f..65a54adb 100644
--- a/adapters/revenuecat-style/scripts/lib.star
+++ b/adapters/revenuecat-style/scripts/lib.star
@@ -8,22 +8,26 @@
# One day in seconds, assembled at runtime (no long digit runs in source).
_DAY_SECONDS = 24 * 60 * 60
-# Well-known static test API key, seeded once into the KV store on first
-# request (see _seed_tokens) so existing clients/tests that use it keep
-# working while any other key is rejected with 401. This mirrors
-# RevenueCat's public "sk_" SDK keys.
+# Well-known static test API keys, seeded once into the KV store on first
+# request (see _seed_tokens) so existing clients/tests that use them keep
+# working while any other key is rejected with 401. This mirrors RevenueCat's
+# key split: the "sk_" key is the project-wide SECRET key (any request); the
+# "pk_" key is the app-specific PUBLIC SDK key (subscriber reads + receipt
+# posts only — restricted actions need the secret key, like the real API).
_TEST_API_KEY = "sk_test_revenuecat_style_mock_key"
+_TEST_PUBLIC_KEY = "pk_test_revenuecat_style_mock_key"
-# _seed_tokens inserts the well-known test key into the KV store exactly once
-# per instance (guarded by the "auth_seeded" flag), stored under
+# _seed_tokens inserts the well-known test keys into the KV store exactly
+# once per instance (guarded by the "auth_seeded" flag): secrets under
# "tok:" with a far-future expiry computed at runtime (never a
-# hardcoded epoch).
+# hardcoded epoch), publics under "pub:".
def _seed_tokens():
if store_kv_get("revenuecat", "auth_seeded") == "yes":
return
store_kv_set("revenuecat", "auth_seeded", "yes")
exp = str(clock.now_unix() + 3600 * 24 * 365 * 10)
store_kv_set("revenuecat", "tok:" + _TEST_API_KEY, exp)
+ store_kv_set("revenuecat", "pub:" + _TEST_PUBLIC_KEY, "yes")
# _bearer extracts the token from an "Authorization: Bearer " header.
# Returns "" if the header is absent or not a Bearer header.
@@ -38,23 +42,43 @@ def _bearer(req):
return auth[7:]
return ""
+# _key_kind classifies a bearer key: "secret" (known + unexpired), "public",
+# or "" (unknown or expired).
+def _key_kind(token):
+ if token == "":
+ return ""
+ _seed_tokens()
+ exp = store_kv_get("revenuecat", "tok:" + token)
+ if exp != None and clock.now_unix() <= _to_int(exp):
+ return "secret"
+ if store_kv_get("revenuecat", "pub:" + token) == "yes":
+ return "public"
+ return ""
+
# _require_auth validates the Authorization: Bearer header against the
-# KV token store. Returns None if the key is known and unexpired, or a 401
-# error-response dict if missing, malformed, unknown, or expired.
-def _require_auth(req):
+# KV token store with the real key split: secret "sk_" keys may make any
+# request; public "pk_" SDK keys only the SDK-facing ones. secret_only marks
+# the restricted endpoints (subscriber writes, deletes, revoke, webhook
+# management) where a public key is a 401. Returns None if the key is
+# acceptable, else a 401 error-response dict.
+def _require_auth(req, secret_only = False):
token = _bearer(req)
if token == "":
return respond(401, {
"code": 401,
"message": "Missing API key in Authorization header.",
})
- _seed_tokens()
- exp = store_kv_get("revenuecat", "tok:" + token)
- if exp == None or clock.now_unix() > _to_int(exp):
+ kind = _key_kind(token)
+ if kind == "":
return respond(401, {
"code": 401,
"message": "Invalid API key.",
})
+ if kind == "public" and secret_only:
+ return respond(401, {
+ "code": 401,
+ "message": "This endpoint requires a secret API key.",
+ })
return None
# ============================================================================
@@ -180,10 +204,16 @@ def _strip_map_scalars(m):
out[k] = m[k]
return out
-# _subscriber_response wraps the public view in RevenueCat's response shape:
-# {subscriber: {...}}.
+# _subscriber_response wraps the public view in RevenueCat's v1 CustomerInfo
+# response shape: {request_date, request_date_ms, subscriber: {...}} — the
+# envelope the SDKs parse, timestamps minted from the engine clock.
def _subscriber_response(doc):
- return respond(200, {"subscriber": _subscriber_view(doc)})
+ now = clock.now_unix()
+ return respond(200, {
+ "request_date": clock.unix_to_rfc3339(now),
+ "request_date_ms": now * 1000,
+ "subscriber": _subscriber_view(doc),
+ })
# ============================================================================
# EXPIRATION (derive-on-read state machine)
diff --git a/adapters/revenuecat-style/scripts/receipts.star b/adapters/revenuecat-style/scripts/receipts.star
index 2c9c1d9a..d75bebe7 100644
--- a/adapters/revenuecat-style/scripts/receipts.star
+++ b/adapters/revenuecat-style/scripts/receipts.star
@@ -27,7 +27,9 @@
# _apply_purchase, _refresh_subscriber, _subscriber_response) are preloaded
# from scripts/lib.star.
-# on_post_receipt validates a receipt and applies the purchase.
+# on_post_receipt validates a receipt and applies the purchase. SDK-facing:
+# the public "pk_" key is accepted alongside the secret one (the real
+# endpoint is what the mobile SDK posts receipts with).
def on_post_receipt(req):
err = _require_auth(req)
if err != None:
diff --git a/adapters/revenuecat-style/scripts/subscribers.star b/adapters/revenuecat-style/scripts/subscribers.star
index e894eb44..27580c99 100644
--- a/adapters/revenuecat-style/scripts/subscribers.star
+++ b/adapters/revenuecat-style/scripts/subscribers.star
@@ -34,7 +34,7 @@ def on_get_subscriber(req):
# into subscriber.attributes and optionally seeds entitlements /
# subscriptions / non_subscriptions. Returns the subscriber state.
def on_post_subscriber(req):
- err = _require_auth(req)
+ err = _require_auth(req, True) # subscriber writes are secret-key only
if err != None:
return err
@@ -79,7 +79,7 @@ def on_post_subscriber(req):
# on_delete_subscriber deletes the subscriber (RevenueCat's
# DELETE /v1/subscribers/{app_user_id}).
def on_delete_subscriber(req):
- err = _require_auth(req)
+ err = _require_auth(req, True) # deleting subscribers is restricted to secret keys
if err != None:
return err
@@ -97,7 +97,7 @@ def on_delete_subscriber(req):
# on_revoke_subscription refunds a subscription (RevenueCat v2-shaped revoke):
# lapses it immediately, drops the entitlement it granted, fires CANCELLATION.
def on_revoke_subscription(req):
- err = _require_auth(req)
+ err = _require_auth(req, True) # revoke is a server-side restricted action
if err != None:
return err
diff --git a/adapters/revenuecat-style/scripts/webhooks.star b/adapters/revenuecat-style/scripts/webhooks.star
index cccb6744..40dfa7ea 100644
--- a/adapters/revenuecat-style/scripts/webhooks.star
+++ b/adapters/revenuecat-style/scripts/webhooks.star
@@ -14,7 +14,7 @@
# on_create_webhook registers a webhook subscription.
def on_create_webhook(req):
- err = _require_auth(req)
+ err = _require_auth(req, True) # management surface: secret keys only
if err != None:
return err
@@ -61,7 +61,7 @@ def on_create_webhook(req):
# on_list_webhooks returns registered webhook subscriptions.
def on_list_webhooks(req):
- err = _require_auth(req)
+ err = _require_auth(req, True) # management surface: secret keys only
if err != None:
return err
diff --git a/adapters/revenuecat_style_test.go b/adapters/revenuecat_style_test.go
new file mode 100644
index 00000000..fa9b8177
--- /dev/null
+++ b/adapters/revenuecat_style_test.go
@@ -0,0 +1,378 @@
+package adapters
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the revenuecat-style adapter scripts directly (lib.star preloaded)
+// over a shared store and virtual clock: the secret sk_ / public pk_ Bearer
+// key gates, the get-or-create subscriber behind the v1 CustomerInfo
+// envelope, receipt validation + real expiry math, derive-on-read
+// EXPIRATION state, revoke, deletion, and the {code, message} error
+// envelopes.
+const (
+ rcSecret = "Bearer sk_test_revenuecat_style_mock_key"
+ rcPublic = "Bearer pk_test_revenuecat_style_mock_key"
+)
+
+type rcFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ host string
+}
+
+func newRCFixture(t *testing.T, start time.Time) *rcFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "revenuecat-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, err := primitives.Open(filepath.Join(tmp, "s.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { store.Close() })
+ kvStore, err := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, err := blob.Open(filepath.Join(tmp, "blobs"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &rcFixture{t: t, vc: vc, host: "api.revenuecat.test", vms: map[string]*starlark.VM{
+ "receipts": load("receipts.star"), "subs": load("subscribers.star"),
+ "hooks": load("webhooks.star"),
+ }}
+}
+
+func (f *rcFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ return f.callHdr(group, handler, method, path, params, body, headers)
+}
+
+// callHdr drives a handler with full control over request headers (needed
+// for the receipts endpoint's X-Platform mechanism).
+func (f *rcFixture) callHdr(group, handler, method, path string, params map[string]string, body map[string]any, headers map[string]string) starlark.Response {
+ f.t.Helper()
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// rcNum coerces a Starlark-round-tripped JSON number to int64.
+func rcNum(t *testing.T, v any) int64 {
+ t.Helper()
+ switch n := v.(type) {
+ case int64:
+ return n
+ case int:
+ return int64(n)
+ case float64:
+ return int64(n)
+ }
+ t.Fatalf("value %v (%T) is not a number", v, v)
+ return 0
+}
+
+// rcSubscriber unwraps the {request_date, request_date_ms, subscriber}
+// CustomerInfo envelope, asserting a 200.
+func rcSubscriber(t *testing.T, r starlark.Response) map[string]any {
+ t.Helper()
+ if r.Status != 200 {
+ t.Fatalf("want 200, got %d: %v", r.Status, r.Body)
+ }
+ s, ok := r.Body["subscriber"].(map[string]any)
+ if !ok {
+ t.Fatalf("subscriber = %v (%T), want a map", r.Body["subscriber"], r.Body["subscriber"])
+ }
+ return s
+}
+
+func TestRevenueCatKeyGates(t *testing.T) {
+ base := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
+ f := newRCFixture(t, base)
+ get := func(id, auth string) starlark.Response {
+ return f.call("subs", "on_get_subscriber", "GET", "/v1/subscribers/"+id,
+ map[string]string{"app_user_id": id}, nil, nil, auth)
+ }
+
+ // ===== a missing or unknown key is a 401 {code, message} envelope =====
+ // No header and an unknown key are both 401s with the RC-style envelope.
+ if r := get("user-1", ""); r.Status != 401 || rcNum(t, r.Body["code"]) != 401 ||
+ r.Body["message"] != "Missing API key in Authorization header." {
+ t.Fatalf("no auth -> %d %v, want 401 missing-key envelope", r.Status, r.Body)
+ }
+ if r := get("user-1", "Bearer sk_nope"); r.Status != 401 || rcNum(t, r.Body["code"]) != 401 ||
+ r.Body["message"] != "Invalid API key." {
+ t.Fatalf("unknown key -> %d %v, want 401 invalid-key envelope", r.Status, r.Body)
+ }
+
+ // ===== the public pk_ SDK key passes subscriber reads and receipt posts but is 401 on restricted writes =====
+ // Real RevenueCat: public app keys are SDK-facing (reads + receipts);
+ // deletes/revokes/subscriber-writes/webhook management need the sk_ secret.
+ if r := get("user-p1", rcPublic); r.Status != 200 {
+ t.Fatalf("public key GET subscriber -> %d %v, want 200", r.Status, r.Body)
+ }
+ if r := f.callHdr("receipts", "on_post_receipt", "POST", "/v1/receipts", nil,
+ map[string]any{"app_user_id": "user-p1", "fetch_token": "fake_receipt_token", "product_id": "premium"},
+ map[string]string{"Authorization": rcPublic, "X-Platform": "ios"}); r.Status != 200 {
+ t.Fatalf("public key POST receipts -> %d %v, want 200", r.Status, r.Body)
+ }
+ if s := rcSubscriber(t, get("user-p1", rcSecret)); len(s["entitlements"].(map[string]any)) != 1 {
+ t.Fatalf("entitlements after public-key receipt = %v, want the granted one", s["entitlements"])
+ }
+ const wantGate = "This endpoint requires a secret API key."
+ if r := f.call("subs", "on_post_subscriber", "POST", "/v1/subscribers/user-p1",
+ map[string]string{"app_user_id": "user-p1"}, nil,
+ map[string]any{"attributes": map[string]any{"$displayName": "Alex"}}, rcPublic); r.Status != 401 || r.Body["message"] != wantGate {
+ t.Fatalf("public key POST subscriber -> %d %v, want 401 secret-required", r.Status, r.Body)
+ }
+ if r := f.call("subs", "on_delete_subscriber", "DELETE", "/v1/subscribers/user-p1",
+ map[string]string{"app_user_id": "user-p1"}, nil, nil, rcPublic); r.Status != 401 || r.Body["message"] != wantGate {
+ t.Fatalf("public key DELETE subscriber -> %d %v, want 401 secret-required", r.Status, r.Body)
+ }
+ if r := f.call("subs", "on_revoke_subscription", "POST", "/v1/subscribers/user-p1/subscriptions/premium/revoke",
+ map[string]string{"app_user_id": "user-p1", "product_id": "premium"}, nil,
+ map[string]any{"reason": "refund"}, rcPublic); r.Status != 401 || r.Body["message"] != wantGate {
+ t.Fatalf("public key revoke -> %d %v, want 401 secret-required", r.Status, r.Body)
+ }
+ if r := f.call("hooks", "on_create_webhook", "POST", "/v1/webhooks", nil, nil,
+ map[string]any{"url": "https://sink.example.test/hook"}, rcPublic); r.Status != 401 || r.Body["message"] != wantGate {
+ t.Fatalf("public key webhooks -> %d %v, want 401 secret-required", r.Status, r.Body)
+ }
+ // The same DELETE with the secret key succeeds: the 401s above were the
+ // gate, not the state.
+ if r := f.call("subs", "on_delete_subscriber", "DELETE", "/v1/subscribers/user-p1",
+ map[string]string{"app_user_id": "user-p1"}, nil, nil, rcSecret); r.Status != 200 {
+ t.Fatalf("secret key DELETE subscriber -> %d %v, want 200", r.Status, r.Body)
+ }
+
+ // ===== GET subscriber is get-or-create and answers in the v1 CustomerInfo envelope =====
+ // An unknown app_user_id is created empty (real RC get-or-create); the
+ // envelope carries request_date/request_date_ms plus the public maps.
+ r := get("user-env", rcSecret)
+ if r.Body["request_date"] != base.Format(time.RFC3339) {
+ t.Fatalf("request_date = %v, want %s", r.Body["request_date"], base.Format(time.RFC3339))
+ }
+ if got := rcNum(t, r.Body["request_date_ms"]); got != base.Unix()*1000 {
+ t.Fatalf("request_date_ms = %d, want %d", got, base.Unix()*1000)
+ }
+ s := rcSubscriber(t, r)
+ if s["original_app_user_id"] != "user-env" || s["first_seen"] != base.Format(time.RFC3339) {
+ t.Fatalf("subscriber identity = %v / %v", s["original_app_user_id"], s["first_seen"])
+ }
+ for _, m := range []string{"entitlements", "subscriptions", "non_subscriptions", "attributes"} {
+ if mm, ok := s[m].(map[string]any); !ok || len(mm) != 0 {
+ t.Fatalf("default %s = %v (%T), want an empty map", m, s[m], s[m])
+ }
+ }
+}
+
+func TestRevenueCatReceiptsLifecycleAndDeletion(t *testing.T) {
+ base := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
+ f := newRCFixture(t, base)
+ get := func(id string) starlark.Response {
+ return f.call("subs", "on_get_subscriber", "GET", "/v1/subscribers/"+id,
+ map[string]string{"app_user_id": id}, nil, nil, rcSecret)
+ }
+ receipt := func(body map[string]any, headers map[string]string) starlark.Response {
+ h := map[string]string{"Authorization": rcSecret}
+ for k, v := range headers {
+ h[k] = v
+ }
+ return f.callHdr("receipts", "on_post_receipt", "POST", "/v1/receipts", nil, body, h)
+ }
+
+ // ===== receipt validation mirrors the real 400 order: app_user_id, platform, fetch_token, bad token =====
+ // Each failure is a 400 {code, message} in the documented check order.
+ if r := receipt(map[string]any{"fetch_token": "t", "platform": "ios", "product_id": "premium"}, nil); r.Status != 400 ||
+ r.Body["message"] != "app_user_id is required" {
+ t.Fatalf("no app_user_id -> %d %v, want 400 app_user_id is required", r.Status, r.Body)
+ }
+ if r := receipt(map[string]any{"app_user_id": "u0", "fetch_token": "t", "product_id": "premium"}, nil); r.Status != 400 ||
+ r.Body["message"] != "X-Platform header (or platform field) is required: ios or android" {
+ t.Fatalf("no platform -> %d %v, want 400 platform is required", r.Status, r.Body)
+ }
+ if r := receipt(map[string]any{"app_user_id": "u0", "fetch_token": "t", "platform": "webos"}, nil); r.Status != 400 ||
+ r.Body["message"] != "Invalid platform: must be one of [ios, android]" {
+ t.Fatalf("bad platform -> %d %v, want 400 invalid platform", r.Status, r.Body)
+ }
+ if r := receipt(map[string]any{"app_user_id": "u0", "platform": "ios", "product_id": "premium"}, nil); r.Status != 400 ||
+ r.Body["message"] != "fetch_token is required" {
+ t.Fatalf("no fetch_token -> %d %v, want 400 fetch_token is required", r.Status, r.Body)
+ }
+ if r := receipt(map[string]any{"app_user_id": "u0", "platform": "ios", "product_id": "premium", "fetch_token": "invalid_receipt"}, nil); r.Status != 400 ||
+ r.Body["message"] != "There was an error fetching the receipt" {
+ t.Fatalf("invalid receipt -> %d %v, want 400 error fetching the receipt", r.Status, r.Body)
+ }
+ // The failed validations must not have created any subscriber.
+ if s := rcSubscriber(t, get("u0")); len(s["entitlements"].(map[string]any)) != 0 {
+ t.Fatalf("entitlements after failed receipts = %v, want empty", s["entitlements"])
+ }
+
+ // ===== an ios receipt grants the pro entitlement with real trial math, and renewals stack =====
+ // First purchase of premium (7-day intro trial): entitlement +
+ // subscription in the RC v1 schema, via the X-Platform header like the
+ // real API.
+ r := receipt(map[string]any{"app_user_id": "user-2", "fetch_token": "fake_receipt_token", "product_id": "premium"},
+ map[string]string{"X-Platform": "ios"})
+ s := rcSubscriber(t, r)
+ pro, ok := s["entitlements"].(map[string]any)["pro"].(map[string]any)
+ if !ok {
+ t.Fatalf("pro entitlement = %v, want present", s["entitlements"])
+ }
+ trialEnd := base.Add(7 * 24 * time.Hour)
+ if pro["expires_date"] != trialEnd.Format(time.RFC3339) || pro["product_identifier"] != "premium" ||
+ pro["purchase_date"] != base.Format(time.RFC3339) {
+ t.Fatalf("pro entitlement = %v, want premium trial to %s", pro, trialEnd.Format(time.RFC3339))
+ }
+ prem, ok := s["subscriptions"].(map[string]any)["premium"].(map[string]any)
+ if !ok {
+ t.Fatalf("subscriptions[premium] = %v, want present", s["subscriptions"])
+ }
+ if prem["period_type"] != "TRIAL" || prem["store"] != "app_store" || prem["is_active"] != true ||
+ prem["expires_date"] != trialEnd.Format(time.RFC3339) {
+ t.Fatalf("trial subscription = %v, want TRIAL/app_store/active to %s", prem, trialEnd.Format(time.RFC3339))
+ }
+ for _, m := range []map[string]any{pro, prem} {
+ for k := range m {
+ if len(k) > 0 && k[0] == '_' {
+ t.Fatalf("internal key %q leaked into the subscriber view: %v", k, m)
+ }
+ }
+ }
+ // The same receipt again is a renewal: NORMAL period stacked onto the
+ // unexpired trial (expires = trial end + 30d), original purchase kept.
+ renewEnd := trialEnd.Add(30 * 24 * time.Hour)
+ s = rcSubscriber(t, receipt(map[string]any{"app_user_id": "user-2", "fetch_token": "fake_receipt_token", "product_id": "premium"},
+ map[string]string{"X-Platform": "ios"}))
+ prem = s["subscriptions"].(map[string]any)["premium"].(map[string]any)
+ if prem["period_type"] != "NORMAL" || prem["expires_date"] != renewEnd.Format(time.RFC3339) ||
+ prem["original_purchase_date"] != base.Format(time.RFC3339) {
+ t.Fatalf("renewed subscription = %v, want NORMAL stacked to %s", prem, renewEnd.Format(time.RFC3339))
+ }
+
+ // ===== a google-play dict receipt feeds the product and lands in non_subscriptions =====
+ // android via the body platform field; the purchaseToken dict's productId
+ // picks the non-subscription product.
+ s = rcSubscriber(t, receipt(map[string]any{
+ "app_user_id": "user-3",
+ "platform": "android",
+ "fetch_token": map[string]any{"purchaseToken": "google_purchase_token_1", "productId": "gold_coins", "orderId": "GPA-order-1"},
+ }, nil))
+ nons, ok := s["non_subscriptions"].(map[string]any)["gold_coins"].([]any)
+ if !ok || len(nons) != 1 {
+ t.Fatalf("non_subscriptions[gold_coins] = %v, want one purchase record", s["non_subscriptions"])
+ }
+ rec, _ := nons[0].(map[string]any)
+ if rec["product_id"] != "gold_coins" || rec["id"] == "" || rec["purchase_date"] != base.Format(time.RFC3339) {
+ t.Fatalf("non-subscription record = %v, want gold_coins rc_* purchase at base", rec)
+ }
+ if ents := s["entitlements"].(map[string]any); len(ents) != 0 {
+ t.Fatalf("entitlements after consumable = %v, want none", ents)
+ }
+
+ // ===== revoke lapses a live subscription; delete and the 404 envelopes =====
+ // Revoke refunds user-5's live trial; unknown subscriber/product and
+ // double deletes are the 404 {code, message} shapes.
+ rcSubscriber(t, receipt(map[string]any{"app_user_id": "user-5", "fetch_token": "fake_receipt_token", "product_id": "premium"},
+ map[string]string{"X-Platform": "ios"}))
+ revoke := func(id, product string) starlark.Response {
+ return f.call("subs", "on_revoke_subscription", "POST", "/v1/subscribers/"+id+"/subscriptions/"+product+"/revoke",
+ map[string]string{"app_user_id": id, "product_id": product}, nil,
+ map[string]any{"reason": "refund"}, rcSecret)
+ }
+ s = rcSubscriber(t, revoke("user-5", "premium"))
+ if _, still := s["entitlements"].(map[string]any)["pro"]; still {
+ t.Fatalf("pro entitlement still present after revoke: %v", s["entitlements"])
+ }
+ prem = s["subscriptions"].(map[string]any)["premium"].(map[string]any)
+ if prem["is_active"] != false || rcNum(t, prem["auto_renewal_status"]) != 0 ||
+ prem["expires_date"] != base.Format(time.RFC3339) {
+ t.Fatalf("revoked subscription = %v, want inactive/auto-renew-off lapsed at base", prem)
+ }
+ if r := revoke("user-5", "nope"); r.Status != 404 || rcNum(t, r.Body["code"]) != 404 ||
+ r.Body["message"] != "Subscription not found" {
+ t.Fatalf("revoke unknown product -> %d %v, want 404 Subscription not found", r.Status, r.Body)
+ }
+ if r := revoke("ghost", "premium"); r.Status != 404 || r.Body["message"] != "Subscriber not found" {
+ t.Fatalf("revoke unknown subscriber -> %d %v, want 404 Subscriber not found", r.Status, r.Body)
+ }
+ del := func(id string) starlark.Response {
+ return f.call("subs", "on_delete_subscriber", "DELETE", "/v1/subscribers/"+id,
+ map[string]string{"app_user_id": id}, nil, nil, rcSecret)
+ }
+ if r := del("user-5"); r.Status != 200 {
+ t.Fatalf("DELETE subscriber -> %d %v, want 200", r.Status, r.Body)
+ }
+ if r := del("user-5"); r.Status != 404 || rcNum(t, r.Body["code"]) != 404 ||
+ r.Body["message"] != "Subscriber not found" {
+ t.Fatalf("second DELETE -> %d %v, want 404 Subscriber not found", r.Status, r.Body)
+ }
+ // A later GET recreates the subscriber empty, like the real API.
+ if s = rcSubscriber(t, get("user-5")); len(s["entitlements"].(map[string]any)) != 0 ||
+ s["original_app_user_id"] != "user-5" {
+ t.Fatalf("subscriber after delete+recreate = %v, want empty but same identity", s)
+ }
+
+ // ===== expiry is derived on read: a lapsed trial drops its entitlement =====
+ // Past the trial end the first reader observes the lapse: entitlement
+ // gone, subscription marked inactive, and stable on a second read.
+ rcSubscriber(t, receipt(map[string]any{"app_user_id": "user-4", "fetch_token": "fake_receipt_token", "product_id": "premium"},
+ map[string]string{"X-Platform": "ios"}))
+ f.vc.Advance(7*24*time.Hour + time.Hour)
+ s = rcSubscriber(t, get("user-4"))
+ if _, still := s["entitlements"].(map[string]any)["pro"]; still {
+ t.Fatalf("pro entitlement still present after lapse: %v", s["entitlements"])
+ }
+ prem = s["subscriptions"].(map[string]any)["premium"].(map[string]any)
+ if prem["is_active"] != false || prem["expires_date"] != trialEnd.Format(time.RFC3339) {
+ t.Fatalf("lapsed subscription = %v, want inactive with expires_date %s", prem, trialEnd.Format(time.RFC3339))
+ }
+ s = rcSubscriber(t, get("user-4"))
+ if prem = s["subscriptions"].(map[string]any)["premium"].(map[string]any); prem["is_active"] != false {
+ t.Fatalf("second read flipped the lapsed subscription: %v", prem)
+ }
+}
diff --git a/adapters/sendgrid-style/scripts/webhooks.star b/adapters/sendgrid-style/scripts/webhooks.star
index 2a4ff393..4ed938c4 100644
--- a/adapters/sendgrid-style/scripts/webhooks.star
+++ b/adapters/sendgrid-style/scripts/webhooks.star
@@ -31,6 +31,19 @@ def on_update_settings(req):
if url == None:
url = ""
+ # Real SendGrid requires the URL when the webhook is enabled; without
+ # this guard the flag would say on while deliveries silently go nowhere.
+ if enabled and url == "":
+ return respond(400, {
+ "errors": [
+ {
+ "message": "The event webhook URL is required when the webhook is enabled.",
+ "field": "url",
+ "help": None,
+ }
+ ],
+ })
+
flag = "no"
if enabled:
flag = "yes"
diff --git a/adapters/sendgrid_style_test.go b/adapters/sendgrid_style_test.go
new file mode 100644
index 00000000..16114bac
--- /dev/null
+++ b/adapters/sendgrid_style_test.go
@@ -0,0 +1,505 @@
+package adapters
+
+import (
+ "crypto/ecdsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "io"
+ "math/big"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the sendgrid-style adapter scripts directly (lib.star preloaded)
+// over a shared store, a virtual clock and a real local webhook sink: the
+// bearer gate, the v3 mail send contract (202 empty body, X-Message-Id,
+// personalizations, sandbox/asm tolerance), the derive-on-read processed ->
+// delivered / dropped lifecycle, the Email Activity query filters, the event
+// webhook settings round-trip, and the ECDSA P-256 signed deliveries.
+const sgAuth = "Bearer SG.testkey.testsecret"
+
+// sgKeyPEM is the Event Webhook verification key published in the adapter
+// README (the fixed synthetic P-256 key the simulator signs with).
+const sgKeyPEM = `-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0WzqFnJjT+5g+V+kv4PvLa+f4+vD
+V+AZ2Z+v257zCF9pOXvJU3unksixtekc1Sv4HD6MOXXpus0tODGWgMAMEQ==
+-----END PUBLIC KEY-----`
+
+// sgDelivery is one captured outbound Event Webhook delivery.
+type sgDelivery struct {
+ body string
+ headers http.Header
+}
+
+type sgFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ mu sync.Mutex
+ sink []sgDelivery
+ sinkURL string
+}
+
+func newSgFixture(t *testing.T, start time.Time) *sgFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "sendgrid-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ f := &sgFixture{t: t, vc: vc}
+ // Real (local) sink so the signed Event Webhook deliveries can be
+ // captured — the same emitter the engine hands handlers.
+ em := events.NewEmitter()
+ t.Cleanup(em.Close)
+ sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ f.mu.Lock()
+ f.sink = append(f.sink, sgDelivery{body: string(b), headers: r.Header.Clone()})
+ f.mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(sink.Close)
+ f.sinkURL = sink.URL
+
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: em,
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ f.vms = map[string]*starlark.VM{
+ "mail": load("mail.star"), "hooks": load("webhooks.star"),
+ }
+ return f
+}
+
+func (f *sgFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: "api.sendgrid.test", Headers: headers, Body: body, Params: params, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// send POSTs one mail send body and returns the 202 response.
+func (f *sgFixture) send(body map[string]any) starlark.Response {
+ f.t.Helper()
+ r := f.call("mail", "on_send_mail", "POST", "/v3/mail/send", nil, nil, body, sgAuth)
+ if r.Status != 202 {
+ f.t.Fatalf("send mail -> %d: %v", r.Status, r.Body)
+ }
+ return r
+}
+
+// messages lists sent mail, applying optional Email Activity query params.
+func (f *sgFixture) messages(query map[string]string) map[string]any {
+ f.t.Helper()
+ r := f.call("mail", "on_list_messages", "GET", "/v3/messages", nil, query, nil, sgAuth)
+ if r.Status != 200 {
+ f.t.Fatalf("list messages %v -> %d: %v", query, r.Status, r.Body)
+ }
+ return r.Body
+}
+
+func (f *sgFixture) deliveries() []sgDelivery {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ out := make([]sgDelivery, len(f.sink))
+ copy(out, f.sink)
+ return out
+}
+
+func TestSendgridAuthAndMailSend(t *testing.T) {
+ f := newSgFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC))
+
+ // ===== the bearer gate rejects missing and unknown keys with SendGrid's grant envelope =====
+ for _, tc := range []struct {
+ name string
+ auth string
+ }{
+ {"no authorization", ""},
+ {"unknown key", "Bearer SG.bogus.wrongkey"},
+ {"non-bearer", "Basic dXNlcjpwYXNz"},
+ } {
+ r := f.call("mail", "on_list_messages", "GET", "/v3/messages", nil, nil, nil, tc.auth)
+ if r.Status != 401 {
+ t.Fatalf("messages with %s -> %d, want 401", tc.name, r.Status)
+ }
+ errs, _ := r.Body["errors"].([]any)
+ if len(errs) != 1 {
+ t.Fatalf("messages with %s: errors = %v, want the single-entry envelope", tc.name, r.Body["errors"])
+ }
+ e0, _ := errs[0].(map[string]any)
+ if e0["message"] != "The provided authorization grant is invalid, expired, or revoked." {
+ t.Fatalf("messages with %s: message = %v", tc.name, e0["message"])
+ }
+ if e0["field"] != nil || e0["help"] != nil {
+ t.Fatalf("messages with %s: field/help = %v / %v, want null/null", tc.name, e0["field"], e0["help"])
+ }
+ }
+ if r := f.call("mail", "on_send_mail", "POST", "/v3/mail/send", nil, nil, map[string]any{
+ "personalizations": []any{map[string]any{"to": []any{map[string]any{"email": "x@example.test"}}}},
+ "from": map[string]any{"email": "y@example.test"},
+ }, ""); r.Status != 401 {
+ t.Fatalf("send without auth -> %d, want 401", r.Status)
+ }
+
+ // ===== mail send answers 202 with an empty body, an X-Message-Id, and flattened personalizations =====
+ // Exactly the real v3 contract: accepted, nothing to read back; the
+ // message itself shows up on the retrieval endpoint with one entry per
+ // recipient and the personalization subject winning over the top-level.
+ r := f.send(map[string]any{
+ "personalizations": []any{
+ map[string]any{"to": []any{map[string]any{"email": "a@example.test"}}, "subject": "Alpha"},
+ map[string]any{"to": []any{map[string]any{"email": "b@example.test"}}, "subject": "Beta"},
+ },
+ "subject": "ignored-top-level",
+ "from": map[string]any{"email": "sender@example.test", "name": "VM Suite"},
+ "content": []any{map[string]any{"type": "text/plain", "value": "hello"}},
+ })
+ if r.RawBody != "" || len(r.Body) != 0 {
+ t.Fatalf("send body = %q / %v, want empty (202 with no body, like real SendGrid)", r.RawBody, r.Body)
+ }
+ if r.Headers["X-Message-Id"] != "msg_1@stunt.local" {
+ t.Fatalf("send X-Message-Id = %q, want msg_1@stunt.local", r.Headers["X-Message-Id"])
+ }
+ if r.Headers["Access-Control-Allow-Origin"] != "https://sendgrid.com" {
+ t.Fatalf("send Access-Control-Allow-Origin = %q", r.Headers["Access-Control-Allow-Origin"])
+ }
+ f.send(map[string]any{
+ "personalizations": []any{
+ map[string]any{"to": []any{map[string]any{"email": "c@example.test"}}, "subject": "Gamma"},
+ },
+ "from": map[string]any{"email": "sender@example.test"},
+ })
+ listed := f.messages(nil)
+ msgs, _ := listed["messages"].([]any)
+ if len(msgs) != 2 {
+ t.Fatalf("messages = %d, want the 2 sent", len(msgs))
+ }
+ var alpha map[string]any
+ for _, m := range msgs {
+ mm, _ := m.(map[string]any)
+ if mm["subject"] == "Alpha" {
+ alpha = mm
+ }
+ }
+ if alpha == nil {
+ t.Fatalf("no Alpha message in %v", msgs)
+ }
+ if alpha["subject"] != "Alpha" || alpha["status"] != "processed" {
+ t.Fatalf("alpha subject/status = %v / %v, want Alpha / processed", alpha["subject"], alpha["status"])
+ }
+ if id, _ := alpha["id"].(string); id != "msg_1@stunt.local" {
+ t.Fatalf("alpha id = %q, want the send's X-Message-Id", id)
+ }
+ to, _ := alpha["to"].([]any)
+ if len(to) != 2 || to[0].(map[string]any)["email"] != "a@example.test" || to[1].(map[string]any)["email"] != "b@example.test" {
+ t.Fatalf("alpha to = %v, want one entry per recipient across personalizations", alpha["to"])
+ }
+ if from, _ := alpha["from"].(map[string]any); from["email"] != "sender@example.test" || from["name"] != "VM Suite" {
+ t.Fatalf("alpha from = %v, want the sender object round-tripped", alpha["from"])
+ }
+ if alpha["created_at"] != "2024-01-15T12:00:00Z" {
+ t.Fatalf("alpha created_at = %v (the adapter's stable synthetic stamp)", alpha["created_at"])
+ }
+
+ // asm / mail_settings.sandbox_mode are not modeled: the send is accepted
+ // and stored like any other (real SendGrid would validate-only under
+ // sandbox mode) — asserted as-is, see the deviation report.
+ sandbox := f.send(map[string]any{
+ "personalizations": []any{
+ map[string]any{"to": []any{map[string]any{"email": "d@example.test"}}, "subject": "Sandboxed"},
+ },
+ "from": map[string]any{"email": "sender@example.test"},
+ "asm": map[string]any{"group_id": 42, "groups_to_display": []any{7}},
+ "mail_settings": map[string]any{
+ "sandbox_mode": map[string]any{"enable": true},
+ "bypass_list_management": map[string]any{"enable": true},
+ },
+ "batch_id": "ZGD7JxuLTEJmG9yzSYpVAg",
+ })
+ if sandbox.Status != 202 {
+ t.Fatalf("sandboxed send -> %d, want 202 (asm/mail_settings tolerated)", sandbox.Status)
+ }
+ if got := len(f.messages(nil)["messages"].([]any)); got != 3 {
+ t.Fatalf("messages after sandboxed send = %d, want 3 (stored like any other)", got)
+ }
+
+ // ===== the retrieval endpoint pages with limit and the opaque offset cursor =====
+ p1 := f.messages(map[string]string{"limit": "2"})
+ if got := len(p1["messages"].([]any)); got != 2 {
+ t.Fatalf("limit=2 page = %d messages, want 2", got)
+ }
+ next, _ := p1["next_offset"].(string)
+ if next != "2" {
+ t.Fatalf("limit=2 next_offset = %q, want 2", next)
+ }
+ p2 := f.messages(map[string]string{"limit": "2", "offset": next})
+ if got := len(p2["messages"].([]any)); got != 1 {
+ t.Fatalf("offset=2 page = %d messages, want the remaining 1", got)
+ }
+ if _, has := p2["next_offset"]; has {
+ t.Fatalf("last page carries next_offset = %v", p2["next_offset"])
+ }
+ if r := f.call("mail", "on_list_messages", "GET", "/v3/messages", nil,
+ map[string]string{"limit": "2", "offset": "not-a-cursor"}, nil, sgAuth); r.Status != 400 {
+ t.Fatalf("invalid cursor -> %d %v, want 400", r.Status, r.Body)
+ } else if errs, _ := r.Body["errors"].([]any); len(errs) != 1 || errs[0].(map[string]any)["message"] != "Invalid cursor parameter." {
+ t.Fatalf("invalid cursor errors = %v, want Invalid cursor parameter.", r.Body["errors"])
+ }
+}
+
+func TestSendgridLifecycleAndEventWebhook(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newSgFixture(t, base)
+
+ // ===== the delivery lifecycle derives processed -> delivered (or dropped) on read, exactly once =====
+ // A send is accepted as processed; the terminal status lands on the first
+ // read past the 3s window, and failure injection selects dropped.
+ f.send(map[string]any{
+ "personalizations": []any{
+ map[string]any{"to": []any{map[string]any{"email": "reader@example.test"}}, "subject": "welcome"},
+ },
+ "from": map[string]any{"email": "sender@example.test"},
+ })
+ f.send(map[string]any{
+ "personalizations": []any{
+ map[string]any{"to": []any{map[string]any{"email": "victim@example.test"}}, "subject": "doomed"},
+ },
+ "from": map[string]any{"email": "sender@example.test"},
+ "simulate_fail": true,
+ })
+ statuses := func() map[string]string {
+ out := map[string]string{}
+ for _, m := range f.messages(nil)["messages"].([]any) {
+ mm, _ := m.(map[string]any)
+ out[mm["subject"].(string)] = mm["status"].(string)
+ }
+ return out
+ }
+ if s := statuses(); s["welcome"] != "processed" || s["doomed"] != "processed" {
+ t.Fatalf("statuses at t=0 = %v, want both processed", s)
+ }
+ if got := len(f.deliveries()); got != 0 {
+ t.Fatalf("deliveries before any webhook is enabled = %d, want 0", got)
+ }
+ f.vc.Advance(4 * time.Second) // past the 3s terminal window
+ if s := statuses(); s["welcome"] != "delivered" || s["doomed"] != "dropped" {
+ t.Fatalf("statuses at 4s = %v, want delivered / dropped", s)
+ }
+ if s := statuses(); s["welcome"] != "delivered" || s["doomed"] != "dropped" {
+ t.Fatalf("statuses re-read = %v, want the terminal states to persist", s)
+ }
+
+ // ===== the Email Activity query language narrows the list =====
+ one := func(query string) []string {
+ t.Helper()
+ var subjects []string
+ for _, m := range f.messages(map[string]string{"query": query})["messages"].([]any) {
+ mm, _ := m.(map[string]any)
+ subjects = append(subjects, mm["subject"].(string))
+ }
+ return subjects
+ }
+ if got := one(`subject CONTAINS "wel"`); len(got) != 1 || got[0] != "welcome" {
+ t.Fatalf(`subject CONTAINS "wel" = %v, want [welcome]`, got)
+ }
+ if got := one(`status="delivered"`); len(got) != 1 || got[0] != "welcome" {
+ t.Fatalf(`status="delivered" = %v, want [welcome]`, got)
+ }
+ if got := one(`to_email="victim@example.test"`); len(got) != 1 || got[0] != "doomed" {
+ t.Fatalf(`to_email="victim@example.test" = %v, want [doomed]`, got)
+ }
+ if got := one(`from_email="sender@example.test" AND status!="delivered"`); len(got) != 1 || got[0] != "doomed" {
+ t.Fatalf(`from_email AND status!= = %v, want [doomed]`, got)
+ }
+ if got := one(`msg_id="msg_1@stunt.local"`); len(got) != 1 || got[0] != "welcome" {
+ t.Fatalf(`msg_id= = %v, want [welcome]`, got)
+ }
+ if got := one(`not_a_field="welcome"`); len(got) != 2 {
+ t.Fatalf("unrecognized field term = %v, want the whole list (term ignored)", got)
+ }
+
+ // ===== event webhook settings round-trip and require a URL when enabled =====
+ settings := f.call("hooks", "on_get_settings", "GET", "/v3/user/webhooks/event/settings", nil, nil, nil, sgAuth)
+ if settings.Status != 200 || settings.Body["enabled"] != false || settings.Body["url"] != "" {
+ t.Fatalf("default settings -> %d %v, want disabled with an empty url", settings.Status, settings.Body)
+ }
+ // Enabling without a URL is the documented-required-field 400 (real
+ // SendGrid requires url with enabled); the test event is 400 until a
+ // URL is configured too.
+ if r := f.call("hooks", "on_update_settings", "POST", "/v3/user/webhooks/event/settings", nil, nil,
+ map[string]any{"enabled": true}, sgAuth); r.Status != 400 {
+ t.Fatalf("enable without url -> %d %v, want 400", r.Status, r.Body)
+ }
+ if r := f.call("hooks", "on_send_test_event", "POST", "/v3/user/webhooks/event/test", nil, nil, nil, sgAuth); r.Status != 400 {
+ t.Fatalf("test event before enabling -> %d %v, want 400", r.Status, r.Body)
+ }
+ updated := f.call("hooks", "on_update_settings", "POST", "/v3/user/webhooks/event/settings", nil, nil,
+ map[string]any{"enabled": true, "url": f.sinkURL}, sgAuth)
+ if updated.Status != 200 || updated.Body["enabled"] != true || updated.Body["url"] != f.sinkURL {
+ t.Fatalf("enable with url -> %d %v", updated.Status, updated.Body)
+ }
+ echoed := f.call("hooks", "on_get_settings", "GET", "/v3/user/webhooks/event/settings", nil, nil, nil, sgAuth)
+ if echoed.Body["enabled"] != true || echoed.Body["url"] != f.sinkURL || echoed.Body["friendly_name"] != "stunt event webhook" {
+ t.Fatalf("echoed settings = %v", echoed.Body)
+ }
+ test := f.call("hooks", "on_send_test_event", "POST", "/v3/user/webhooks/event/test", nil, nil, nil, sgAuth)
+ if test.Status != 202 || test.RawBody != "" || test.Headers["X-Message-Id"] != "msg_test@stunt.local" {
+ t.Fatalf("test event -> %d %q %v, want 202 empty with the test message id", test.Status, test.RawBody, test.Headers)
+ }
+ dv := f.deliveries()
+ if len(dv) != 1 {
+ t.Fatalf("deliveries after test event = %d, want 1", len(dv))
+ }
+ if kind, payload := sgEnvelope(t, dv[0]); kind != "processed" || payload["email"] != "test@example.com" || payload["sg_message_id"] != "msg_test@stunt.local" {
+ t.Fatalf("test delivery = %s %v, want the processed sample event", kind, payload)
+ }
+
+ // ===== deliveries are ECDSA P-256 signed over timestamp + raw body and fire once per recipient stage =====
+ // The signature is the raw r||s form over str(timestamp) + body (the
+ // adapter's one documented deviation from Twilio's DER encoding).
+ sentAt := f.vc.Now().Unix()
+ f.send(map[string]any{
+ "personalizations": []any{
+ map[string]any{"to": []any{map[string]any{"email": "reader@example.test"}}, "subject": "receipt"},
+ },
+ "from": map[string]any{"email": "billing@example.test"},
+ })
+ dv = f.deliveries()
+ if len(dv) != 2 {
+ t.Fatalf("deliveries after send = %d, want the test event + one processed", len(dv))
+ }
+ kind, payload := sgEnvelope(t, dv[1])
+ if kind != "processed" || payload["email"] != "reader@example.test" || payload["sg_message_id"] != "msg_3@stunt.local" {
+ t.Fatalf("processed delivery = %s %v", kind, payload)
+ }
+ sgNum(t, payload["timestamp"], float64(sentAt), "processed delivery timestamp (virtual clock)")
+ if payload["sg_event_id"] != "evt_2" {
+ t.Fatalf("processed sg_event_id = %v, want evt_2 (evt_1 was the test event)", payload["sg_event_id"])
+ }
+ f.vc.Advance(4 * time.Second)
+ f.messages(nil) // the read that derives the terminal state
+ dv = f.deliveries()
+ if len(dv) != 3 {
+ t.Fatalf("deliveries after terminal read = %d, want the delivered event too", len(dv))
+ }
+ if kind, payload := sgEnvelope(t, dv[2]); kind != "delivered" || payload["sg_message_id"] != "msg_3@stunt.local" {
+ t.Fatalf("terminal delivery = %s %v, want delivered for the receipt", kind, payload)
+ }
+ f.messages(nil) // re-reading does not re-emit
+ if got := len(f.deliveries()); got != 3 {
+ t.Fatalf("deliveries after re-read = %d, want still 3 (exactly once)", got)
+ }
+ for i, d := range f.deliveries() {
+ sgVerifyDelivery(t, d, i)
+ }
+}
+
+// sgVerifyDelivery checks the ECDSA P-256 signature over timestamp + raw
+// body against the published public key. The signature is the raw r||s form
+// (64 bytes), not ASN.1 DER — the adapter's documented deviation — so the
+// halves are split manually before ecdsa.Verify.
+func sgVerifyDelivery(t *testing.T, d sgDelivery, i int) {
+ t.Helper()
+ sig, err := base64.StdEncoding.DecodeString(d.headers.Get("X-Twilio-Email-Event-Webhook-Signature"))
+ if err != nil || len(sig) != 64 {
+ t.Fatalf("delivery %d: signature decodes to %d bytes (err %v), want the 64-byte raw r||s form", i, len(sig), err)
+ }
+ ts := d.headers.Get("X-Twilio-Email-Event-Webhook-Timestamp")
+ if ts == "" {
+ t.Fatalf("delivery %d carries no X-Twilio-Email-Event-Webhook-Timestamp", i)
+ }
+ block, _ := pem.Decode([]byte(sgKeyPEM))
+ if block == nil {
+ t.Fatalf("delivery %d: bad verification key PEM", i)
+ }
+ pubAny, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ t.Fatalf("delivery %d: parse public key: %v", i, err)
+ }
+ pub, ok := pubAny.(*ecdsa.PublicKey)
+ if !ok {
+ t.Fatalf("delivery %d: key is %T, want ECDSA", i, pubAny)
+ }
+ digest := sha256.Sum256([]byte(ts + d.body))
+ r := new(big.Int).SetBytes(sig[:32])
+ s := new(big.Int).SetBytes(sig[32:])
+ if !ecdsa.Verify(pub, digest[:], r, s) {
+ t.Fatalf("delivery %d: ECDSA signature does not verify over %q", i, ts+d.body)
+ }
+}
+
+// sgEnvelope unpacks the {type, payload} delivery envelope.
+func sgEnvelope(t *testing.T, d sgDelivery) (string, map[string]any) {
+ t.Helper()
+ var env struct {
+ Type string `json:"type"`
+ Payload map[string]any `json:"payload"`
+ }
+ if err := json.Unmarshal([]byte(d.body), &env); err != nil {
+ t.Fatalf("delivery body is not JSON: %v (body %s)", err, d.body)
+ }
+ return env.Type, env.Payload
+}
+
+// sgNum compares a JSON number regardless of int64/float64 width (payloads
+// round-trip through the emitter, where ints may come back floats).
+func sgNum(t *testing.T, v any, want float64, what string) {
+ t.Helper()
+ switch n := v.(type) {
+ case int64:
+ if float64(n) != want {
+ t.Fatalf("%s = %d, want %v", what, n, want)
+ }
+ case float64:
+ if n != want {
+ t.Fatalf("%s = %v, want %v", what, n, want)
+ }
+ default:
+ t.Fatalf("%s is %T(%v), want number %v", what, v, v, want)
+ }
+}
diff --git a/adapters/signin_with_apple_style_test.go b/adapters/signin_with_apple_style_test.go
new file mode 100644
index 00000000..97749a09
--- /dev/null
+++ b/adapters/signin_with_apple_style_test.go
@@ -0,0 +1,411 @@
+package adapters
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "math/big"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the signin-with-apple-style adapter scripts directly (lib.star
+// preloaded) over a shared store and virtual clock: the /auth/authorize
+// redirect with its single-use code, the /auth/token exchange and refresh
+// (client_secret JWTs are minted and verified with REAL ES256 crypto), and
+// the /auth/keys JWKS whose served key verifies the minted id_token.
+const (
+ siwaVMHost = "appleid.apple.test"
+ siwaVMClientID = "com.example.signin.service"
+ siwaVMRedirectURI = "https://client.example.test/callback"
+ siwaVMState = "vm-state-xyz"
+)
+
+// siwaVMPrivPEM mirrors the adapter's documented synthetic EC P-256 keypair
+// (README): the public half is served at GET /auth/keys, the private half
+// signs client_secret JWTs here exactly the way a real developer key would.
+const siwaVMPrivPEM = `-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwp+ZPlH6FJFcHfYS
+Nd1ENT6RzZQUkTDz67JzlvlvoRShRANCAAREw7SM/k20F3w/oDzR9M6V6jHDK4Hi
+RkybQejVvpvgn2EoiMcG6uzUH+aAOgtE+0wCB2gWqc5DoeX6fHyFgDqT
+-----END PRIVATE KEY-----`
+
+type siwaVMFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vm *starlark.VM
+ host string
+}
+
+func newSiwaVMFixture(t *testing.T, start time.Time) *siwaVMFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "signin-with-apple-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ src, err := os.ReadFile(filepath.Join(root, "scripts", "oauth.star"))
+ if err != nil {
+ t.Fatalf("read oauth.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib oauth.star: %v", err)
+ }
+ return &siwaVMFixture{t: t, vc: vc, vm: vm, host: siwaVMHost}
+}
+
+// call drives a handler: query params for the GET routes, form-field map for
+// the POSTed /auth/token (what the engine's form parser hands the handler).
+func (f *siwaVMFixture) call(handler, method, path string, query, body map[string]string) starlark.Response {
+ f.t.Helper()
+ if query == nil {
+ query = map[string]string{}
+ }
+ if body == nil {
+ body = map[string]string{}
+ }
+ anyBody := make(map[string]any, len(body))
+ for k, v := range body {
+ anyBody[k] = v
+ }
+ resp, err := f.vm.Call(handler, starlark.Request{
+ Method: method, Path: path, Host: f.host, Headers: map[string]string{}, Body: anyBody, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// siwaVMKey parses the documented private key PEM.
+func siwaVMKey(t *testing.T) *ecdsa.PrivateKey {
+ t.Helper()
+ block, _ := pem.Decode([]byte(siwaVMPrivPEM))
+ if block == nil {
+ t.Fatal("bad test key PEM")
+ }
+ key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ t.Fatalf("parse test key: %v", err)
+ }
+ priv, ok := key.(*ecdsa.PrivateKey)
+ if !ok {
+ t.Fatal("test key is not ECDSA")
+ }
+ return priv
+}
+
+// siwaVMSecret mints a REAL ES256 client_secret JWT (raw r||s signature)
+// with the documented key: alg ES256, aud appleid.apple.com, exp at
+// now+ttl — the exact shape the adapter's verifier demands.
+func siwaVMSecret(t *testing.T, priv *ecdsa.PrivateKey, now, ttl int64, aud string) string {
+ t.Helper()
+ header := `{"alg":"ES256","kid":"mock-siwa-key-1","typ":"JWT"}`
+ payload := `{"iss":"MOCKTEAMID","sub":"` + siwaVMClientID + `","aud":"` + aud +
+ `","iat":` + jsonInt(now) + `,"exp":` + jsonInt(now+ttl) + `}`
+ return siwaVMSign(t, priv, header, payload)
+}
+
+// siwaVMSign signs a compact-JSON header/payload pair as an ES256 JWT
+// (base64url segments, 64-byte raw r||s signature).
+func siwaVMSign(t *testing.T, priv *ecdsa.PrivateKey, header, payload string) string {
+ t.Helper()
+ h := base64.RawURLEncoding.EncodeToString([]byte(header))
+ p := base64.RawURLEncoding.EncodeToString([]byte(payload))
+ digest := sha256.Sum256([]byte(h + "." + p))
+ r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:])
+ if err != nil {
+ t.Fatalf("sign: %v", err)
+ }
+ sig := make([]byte, 64)
+ r.FillBytes(sig[:32])
+ s.FillBytes(sig[32:])
+ return h + "." + p + "." + base64.RawURLEncoding.EncodeToString(sig)
+}
+
+// jsonInt formats an int64 for hand-built compact JSON payloads.
+func jsonInt(n int64) string {
+ b, _ := json.Marshal(n)
+ return string(b)
+}
+
+// siwaVMCode runs the authorize redirect once and extracts the code param.
+func siwaVMCode(t *testing.T, f *siwaVMFixture, state string) string {
+ t.Helper()
+ resp := f.call("on_authorize", "GET", "/auth/authorize", map[string]string{
+ "client_id": siwaVMClientID, "redirect_uri": siwaVMRedirectURI,
+ "state": state, "response_type": "code", "scope": "name email",
+ }, nil)
+ if resp.Status != 302 {
+ t.Fatalf("authorize -> %d: %v", resp.Status, resp.Body)
+ }
+ loc := resp.Headers["Location"]
+ if loc == "" {
+ t.Fatal("authorize: missing Location header")
+ }
+ var code string
+ for _, kv := range strings.Split(strings.SplitN(loc, "?", 2)[1], "&") {
+ if strings.HasPrefix(kv, "code=") {
+ code = strings.TrimPrefix(kv, "code=")
+ }
+ }
+ if code == "" {
+ t.Fatalf("authorize: no code in Location %q", loc)
+ }
+ return code
+}
+
+// siwaVMSegment decodes a base64url JWT segment to a JSON map.
+func siwaVMSegment(t *testing.T, token string, idx int) map[string]any {
+ t.Helper()
+ parts := strings.Split(token, ".")
+ if len(parts) != 3 {
+ t.Fatalf("token %q has %d segments, want 3", token, len(parts))
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(parts[idx])
+ if err != nil {
+ t.Fatalf("decode segment %d: %v", idx, err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("unmarshal segment %d (%s): %v", idx, raw, err)
+ }
+ return m
+}
+
+func TestSigninWithAppleVMOAuthFlow(t *testing.T) {
+ base := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
+ f := newSiwaVMFixture(t, base)
+ priv := siwaVMKey(t)
+ secret := siwaVMSecret(t, priv, base.Unix(), 3600, "https://appleid.apple.com")
+
+ // ===== authorize redirects with a single-use code plus state and validates its params =====
+ resp := f.call("on_authorize", "GET", "/auth/authorize", map[string]string{
+ "client_id": siwaVMClientID, "redirect_uri": siwaVMRedirectURI,
+ "state": siwaVMState, "response_type": "code", "scope": "name email",
+ }, nil)
+ if resp.Status != 302 {
+ t.Fatalf("authorize -> %d: %v", resp.Status, resp.Body)
+ }
+ loc := resp.Headers["Location"]
+ if !strings.HasPrefix(loc, siwaVMRedirectURI+"?code=") {
+ t.Fatalf("authorize Location = %q, want %s?code=...", loc, siwaVMRedirectURI)
+ }
+ if !strings.Contains(loc, "state="+siwaVMState) {
+ t.Fatalf("authorize Location %q missing state %q", loc, siwaVMState)
+ }
+ // A redirect_uri that already carries a query gets & not ?.
+ extra := f.call("on_authorize", "GET", "/auth/authorize", map[string]string{
+ "client_id": siwaVMClientID, "redirect_uri": "https://client.example.test/cb?x=1",
+ "response_type": "code",
+ }, nil)
+ if l := extra.Headers["Location"]; !strings.Contains(l, "cb?x=1&code=") {
+ t.Fatalf("authorize Location %q, want & separator after existing query", l)
+ }
+ // Missing required params and a wrong response_type are 400s.
+ if r := f.call("on_authorize", "GET", "/auth/authorize", map[string]string{"response_type": "code"}, nil); r.Status != 400 {
+ t.Fatalf("authorize without client_id/redirect_uri -> %d, want 400", r.Status)
+ }
+ if r := f.call("on_authorize", "GET", "/auth/authorize", map[string]string{
+ "client_id": siwaVMClientID, "redirect_uri": siwaVMRedirectURI, "response_type": "token",
+ }, nil); r.Status != 400 || r.Body["error"] != "unsupported_response_type" {
+ t.Fatalf("authorize response_type=token -> %d %v, want 400 unsupported_response_type", r.Status, r.Body)
+ }
+
+ // ===== the token exchange mints a real es256 id_token with apple claim shapes =====
+ code := siwaVMCode(t, f, siwaVMState)
+ tok := f.call("on_token", "POST", "/auth/token", nil, map[string]string{
+ "grant_type": "authorization_code", "code": code,
+ "client_id": siwaVMClientID, "client_secret": secret, "redirect_uri": siwaVMRedirectURI,
+ })
+ if tok.Status != 200 {
+ t.Fatalf("token exchange -> %d: %v", tok.Status, tok.Body)
+ }
+ if tok.Body["token_type"] != "Bearer" {
+ t.Fatalf("token_type = %v, want Bearer", tok.Body["token_type"])
+ }
+ if n := ckVMNum(tok.Body["expires_in"]); n != 3600 {
+ t.Fatalf("expires_in = %v, want 3600", tok.Body["expires_in"])
+ }
+ access, _ := tok.Body["access_token"].(string)
+ refresh, _ := tok.Body["refresh_token"].(string)
+ if access == "" || refresh == "" {
+ t.Fatalf("access/refresh token = %q / %q, want non-empty", access, refresh)
+ }
+ idToken, _ := tok.Body["id_token"].(string)
+ if idToken == "" {
+ t.Fatalf("id_token = %v, want non-empty", tok.Body["id_token"])
+ }
+ header := siwaVMSegment(t, idToken, 0)
+ if header["alg"] != "ES256" || header["kid"] != "mock-siwa-key-1" || header["typ"] != "JWT" {
+ t.Fatalf("id_token JOSE header = %v, want ES256/mock-siwa-key-1/JWT", header)
+ }
+ claims := siwaVMSegment(t, idToken, 1)
+ if claims["iss"] != "https://appleid.apple.com" {
+ t.Fatalf("id_token iss = %v", claims["iss"])
+ }
+ if claims["aud"] != siwaVMClientID {
+ t.Fatalf("id_token aud = %v, want %s", claims["aud"], siwaVMClientID)
+ }
+ if sub, _ := claims["sub"].(string); !strings.HasPrefix(sub, "00") {
+ t.Fatalf("id_token sub = %v, want 00-prefixed user id", claims["sub"])
+ }
+ // Apple ships email_verified / is_private_email as STRINGS and
+ // nonce_supported as a bool — the adapter mirrors the real shapes.
+ if email, _ := claims["email"].(string); !strings.HasSuffix(email, "@privaterelay.appleid.com") {
+ t.Fatalf("id_token email = %v, want privaterelay address", claims["email"])
+ }
+ if claims["email_verified"] != "true" || claims["is_private_email"] != "false" {
+ t.Fatalf("id_token email flags = %v/%v, want \"true\"/\"false\" strings", claims["email_verified"], claims["is_private_email"])
+ }
+ if claims["nonce_supported"] != true {
+ t.Fatalf("id_token nonce_supported = %v, want boolean true", claims["nonce_supported"])
+ }
+ if iat, exp := ckVMNum(claims["iat"]), ckVMNum(claims["exp"]); exp-iat != 3600 || iat != float64(base.Unix()) {
+ t.Fatalf("id_token iat/exp = %v/%v, want virtual now and +1h", iat, exp)
+ }
+
+ // ===== the served jwks verifies the minted id_token signature =====
+ keys := f.call("on_get_keys", "GET", "/auth/keys", nil, nil)
+ if keys.Status != 200 {
+ t.Fatalf("auth/keys -> %d: %v", keys.Status, keys.Body)
+ }
+ keyList, _ := keys.Body["keys"].([]any)
+ if len(keyList) != 1 {
+ t.Fatalf("JWKS keys = %d, want 1", len(keyList))
+ }
+ jwk, _ := keyList[0].(map[string]any)
+ if jwk["kty"] != "EC" || jwk["crv"] != "P-256" || jwk["alg"] != "ES256" || jwk["use"] != "sig" || jwk["kid"] != "mock-siwa-key-1" {
+ t.Fatalf("JWKS key = %v, want EC/P-256/ES256/sig/mock-siwa-key-1", jwk)
+ }
+ x, err := base64.RawURLEncoding.DecodeString(jwk["x"].(string))
+ if err != nil || len(x) != 32 {
+ t.Fatalf("JWKS x = %v (%v), want 32 bytes", jwk["x"], err)
+ }
+ y, err := base64.RawURLEncoding.DecodeString(jwk["y"].(string))
+ if err != nil || len(y) != 32 {
+ t.Fatalf("JWKS y = %v (%v), want 32 bytes", jwk["y"], err)
+ }
+ pub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)}
+ segs := strings.Split(idToken, ".")
+ sig, err := base64.RawURLEncoding.DecodeString(segs[2])
+ if err != nil || len(sig) != 64 {
+ t.Fatalf("id_token signature = %d bytes (%v), want 64 raw r||s", len(sig), err)
+ }
+ digest := sha256.Sum256([]byte(segs[0] + "." + segs[1]))
+ if !ecdsa.Verify(pub, digest[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) {
+ t.Fatal("id_token ES256 signature did not verify against the served JWKS key")
+ }
+
+ // ===== auth codes are single-use and client_secrets are verified cryptographically =====
+ replay := f.call("on_token", "POST", "/auth/token", nil, map[string]string{
+ "grant_type": "authorization_code", "code": code,
+ "client_id": siwaVMClientID, "client_secret": secret, "redirect_uri": siwaVMRedirectURI,
+ })
+ if replay.Status != 400 || replay.Body["error"] != "invalid_grant" {
+ t.Fatalf("replay auth code -> %d %v, want 400 invalid_grant", replay.Status, replay.Body)
+ }
+ // A non-JWT secret, an expired secret, a forged signature and a wrong aud
+ // are all invalid_client (the secret check precedes the code lookup, so
+ // the consumed code still exercises it).
+ for _, tc := range []struct {
+ name string
+ secret string
+ }{
+ {"not a jwt", "not-a-jwt"},
+ {"expired", siwaVMSecret(t, priv, base.Unix(), -60, "https://appleid.apple.com")},
+ {"forged key", siwaVMSecret(t, mustRandomKey(t), base.Unix(), 3600, "https://appleid.apple.com")},
+ {"wrong aud", siwaVMSecret(t, priv, base.Unix(), 3600, "https://someone-else.test")},
+ } {
+ r := f.call("on_token", "POST", "/auth/token", nil, map[string]string{
+ "grant_type": "authorization_code", "code": code,
+ "client_id": siwaVMClientID, "client_secret": tc.secret,
+ })
+ if r.Status != 400 || r.Body["error"] != "invalid_client" {
+ t.Fatalf("%s client_secret -> %d %v, want 400 invalid_client", tc.name, r.Status, r.Body)
+ }
+ }
+ // A code from a different client_id does not exchange.
+ otherCode := siwaVMCode(t, f, "other")
+ if r := f.call("on_token", "POST", "/auth/token", nil, map[string]string{
+ "grant_type": "authorization_code", "code": otherCode,
+ "client_id": "com.someone.else", "client_secret": secret,
+ }); r.Status != 400 || r.Body["error"] != "invalid_grant" {
+ t.Fatalf("client_id mismatch -> %d %v, want 400 invalid_grant", r.Status, r.Body)
+ }
+
+ // ===== the refresh grant rotates access tokens and rejects stale or foreign inputs =====
+ refreshed := f.call("on_token", "POST", "/auth/token", nil, map[string]string{
+ "grant_type": "refresh_token", "refresh_token": refresh,
+ "client_id": siwaVMClientID, "client_secret": secret,
+ })
+ if refreshed.Status != 200 {
+ t.Fatalf("refresh -> %d: %v", refreshed.Status, refreshed.Body)
+ }
+ newAccess, _ := refreshed.Body["access_token"].(string)
+ if newAccess == "" || newAccess == access {
+ t.Fatalf("refresh access_token = %q, want rotated from %q", newAccess, access)
+ }
+ if refreshed.Body["token_type"] != "Bearer" || ckVMNum(refreshed.Body["expires_in"]) != 3600 {
+ t.Fatalf("refresh envelope = %v, want Bearer/3600", refreshed.Body)
+ }
+ // An access token is not a refresh token; a foreign client_id is rejected.
+ if r := f.call("on_token", "POST", "/auth/token", nil, map[string]string{
+ "grant_type": "refresh_token", "refresh_token": access,
+ "client_id": siwaVMClientID, "client_secret": secret,
+ }); r.Status != 400 || r.Body["error"] != "invalid_grant" {
+ t.Fatalf("refresh with access token -> %d %v, want 400 invalid_grant", r.Status, r.Body)
+ }
+ if r := f.call("on_token", "POST", "/auth/token", nil, map[string]string{
+ "grant_type": "refresh_token", "refresh_token": refresh,
+ "client_id": "com.someone.else", "client_secret": secret,
+ }); r.Status != 400 || r.Body["error"] != "invalid_grant" {
+ t.Fatalf("refresh client_id mismatch -> %d %v, want 400 invalid_grant", r.Status, r.Body)
+ }
+ // Unknown grants are refused before anything else.
+ if r := f.call("on_token", "POST", "/auth/token", nil, map[string]string{
+ "grant_type": "password", "client_id": siwaVMClientID, "client_secret": secret,
+ }); r.Status != 400 || r.Body["error"] != "unsupported_grant_type" {
+ t.Fatalf("grant_type=password -> %d %v, want 400 unsupported_grant_type", r.Status, r.Body)
+ }
+}
+
+// mustRandomKey generates an unregistered P-256 key (for forged-signature
+// cases: well-formed ES256 JWTs that must NOT verify).
+func mustRandomKey(t *testing.T) *ecdsa.PrivateKey {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ return key
+}
diff --git a/adapters/tenderly-style/scripts/lib.star b/adapters/tenderly-style/scripts/lib.star
index f5f7ae04..3446c97d 100644
--- a/adapters/tenderly-style/scripts/lib.star
+++ b/adapters/tenderly-style/scripts/lib.star
@@ -83,6 +83,10 @@ def _build_simulation_result(body, account, project):
gas_used = 21000 + (input_len // 2 % 200000)
sim_id = _gen_sim_id()
+ # hash derives from the sim sequence: every simulation must get its own
+ # transaction hash (real Tenderly hashes the tx contents; passing the
+ # "sim_NNNNNN" id itself through _to_int collapses every sim to 0x..01)
+ sim_num = _to_int(sim_id[4:])
# Revert detection: an explicit body flag, or the Error(string) selector
# (0x08c379a0) in the calldata prefix — the canonical "revert with reason".
@@ -116,7 +120,7 @@ def _build_simulation_result(body, account, project):
return {
"transaction": {
- "hash": "0x" + _hex_pad(_to_int_or_float(sim_id), 64),
+ "hash": "0x" + _hex_pad(sim_num, 64),
"block_number": block_number,
"block_hash": "0x" + _hex_pad(_to_int_or_float(block_number) + 100, 64),
"status": status,
@@ -159,23 +163,27 @@ def _str_to_hex(s):
out = out + hexchars[code // 16] + hexchars[code % 16]
return out
-# _topic_addr formats an address as a 32-byte left-padded topic (64 hex chars).
+# _topic_addr formats an address as a 32-byte left-padded topic (64 hex
+# chars, lowercase — EVM log topics are raw hex, never checksummed case).
def _topic_addr(addr):
a = addr
if a[:2] == "0x":
a = a[2:]
+ a = a.lower()
while len(a) < 64:
a = "0" + a
return "0x" + a
# _abi_error_string ABI-encodes an Error(string) revert output for a reason:
-# selector 0x08c379a0 + offset(0x20) + length + data padded to 32 bytes.
+# selector 0x08c379a0 + offset(0x20) + length + data padded to whole 32-byte
+# words (the ABI always pads to a word boundary, so reasons longer than one
+# word need the full next word, not just a minimum of 64 hex chars).
def _abi_error_string(reason):
selector = "08c379a0"
offset = "0000000000000000000000000000000000000000000000000000000000000020"
length = _hex_pad(len(reason), 64)
data = _str_to_hex(reason)
- while len(data) < 64:
+ while len(data) % 64 != 0:
data = data + "0"
return "0x" + selector + offset + length + data
@@ -218,15 +226,18 @@ def _to_int_or_float(v):
return _to_int(v)
# _hex_pad converts a number to a zero-padded hex string of given length.
+# Zero renders as a real zero ("0"/all-zero padding): EVM hex quantities and
+# ABI length words must be exact, so the old coerce-0-to-1 made a 0-value
+# trace read "0x1" and an empty string's length word read 1.
def _hex_pad(n, length):
hexchars = "0123456789abcdef"
s = ""
v = n
- if v == 0:
- v = 1
while v > 0:
s = hexchars[v % 16] + s
v = v // 16
+ if s == "":
+ s = "0"
while len(s) < length:
s = "0" + s
return s
diff --git a/adapters/tenderly_style_test.go b/adapters/tenderly_style_test.go
new file mode 100644
index 00000000..49417680
--- /dev/null
+++ b/adapters/tenderly_style_test.go
@@ -0,0 +1,366 @@
+package adapters
+
+import (
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the tenderly-style adapter scripts directly (lib.star preloaded)
+// over a shared store: the access-key gate against the seeded test token,
+// the networks surface and its perPage/page envelope switch, the
+// deterministic simulation shapes (plain call, value transfer with the
+// ERC-20 Transfer log and balance overrides, ABI-encoded reverts via both
+// the explicit flag and the Error(string) selector), and the bundle /
+// list / retrieve round-trip.
+const tdAuth = "Bearer test-token-tenderly"
+
+const (
+ tdFrom = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
+ tdTo = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
+)
+
+// tdERC20Transfer is a 4-byte selector + address + uint256 calldata blob.
+const tdERC20Transfer = ("0xa9059cbb00000000000000000000000012345678" +
+ "90abcdef1234567890abcdef123456780000000000000000000000000000000000000" +
+ "00000000000000000000f4240")
+
+type tdFixture struct {
+ t *testing.T
+ vms map[string]*starlark.VM
+}
+
+func newTdFixture(t *testing.T, start time.Time) *tdFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "tenderly-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &tdFixture{t: t, vms: map[string]*starlark.VM{
+ "sim": load("simulate.star"), "net": load("networks.star"),
+ }}
+}
+
+func (f *tdFixture) call(group, handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ resp, err := f.vms[group].Call(handler, starlark.Request{
+ Method: method, Path: path, Host: "api.tenderly.test", Headers: headers, Body: body, Params: params, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// simulate POSTs one simulation body for the account/project pair.
+func (f *tdFixture) simulate(body map[string]any) map[string]any {
+ f.t.Helper()
+ r := f.call("sim", "on_simulate", "POST",
+ "/api/v1/account/vm-suite/project/proj/simulate",
+ map[string]string{"account": "vm-suite", "project": "proj"}, nil, body, tdAuth)
+ if r.Status != 200 {
+ f.t.Fatalf("simulate -> %d: %v", r.Status, r.Body)
+ }
+ return r.Body
+}
+
+// tx builds a simulation body for one transaction.
+func tdTx(input, value string, extra map[string]any) map[string]any {
+ body := map[string]any{
+ "network_id": "1",
+ "block_number": 19000000,
+ "transaction": map[string]any{
+ "from": tdFrom,
+ "to": tdTo,
+ "gas": 100000,
+ "gas_price": "1000000000",
+ "value": value,
+ "input": input,
+ },
+ }
+ for k, v := range extra {
+ body[k] = v
+ }
+ return body
+}
+
+func TestTenderlyAuthAndNetworks(t *testing.T) {
+ f := newTdFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC))
+
+ // ===== the access-key gate rejects missing and unknown bearers with the slug envelope =====
+ // Only the seeded test token validates; everything else is the
+ // {slug: unauthorized} envelope on every route.
+ if r := f.call("net", "on_list_networks", "GET", "/api/v1/networks", nil, nil, nil, ""); r.Status != 401 || r.Body["slug"] != "unauthorized" {
+ t.Fatalf("networks without auth -> %d %v, want 401 {slug: unauthorized}", r.Status, r.Body)
+ }
+ if r := f.call("sim", "on_simulate", "POST", "/api/v1/account/vm-suite/project/proj/simulate",
+ map[string]string{"account": "vm-suite", "project": "proj"}, nil, tdTx("0x", "0", nil), "Bearer bogus-token"); r.Status != 401 || r.Body["slug"] != "unauthorized" {
+ t.Fatalf("simulate with unknown bearer -> %d %v, want 401 {slug: unauthorized}", r.Status, r.Body)
+ }
+
+ // ===== networks answer the bare array and switch to the paged envelope under perPage =====
+ listed := f.call("net", "on_list_networks", "GET", "/api/v1/networks", nil, nil, nil, tdAuth)
+ if listed.Status != 200 || len(listed.BodyList) != 5 {
+ t.Fatalf("networks -> %d (%d items), want the bare 5-network array", listed.Status, len(listed.BodyList))
+ }
+ first, _ := listed.BodyList[0].(map[string]any)
+ if first["id"] != "1" || first["name"] != "Ethereum Mainnet" || first["hex_id"] != "0x1" {
+ t.Fatalf("networks[0] = %v, want the mainnet entry", first)
+ }
+ page1 := f.call("net", "on_list_networks", "GET", "/api/v1/networks", nil,
+ map[string]string{"perPage": "2", "page": "1"}, nil, tdAuth)
+ if page1.Status != 200 {
+ t.Fatalf("networks page 1 -> %d: %v", page1.Status, page1.Body)
+ }
+ if nets, _ := page1.Body["networks"].([]any); len(nets) != 2 {
+ t.Fatalf("networks page 1 = %d items, want 2 (perPage honored)", len(nets))
+ }
+ if page1.Body["next_page"] != int64(2) && page1.Body["next_page"] != float64(2) {
+ t.Fatalf("networks page 1 next_page = %v, want 2", page1.Body["next_page"])
+ }
+ page3 := f.call("net", "on_list_networks", "GET", "/api/v1/networks", nil,
+ map[string]string{"perPage": "2", "page": "3"}, nil, tdAuth)
+ if nets, _ := page3.Body["networks"].([]any); len(nets) != 1 {
+ t.Fatalf("networks page 3 = %d items, want the last 1", len(nets))
+ }
+ if _, has := page3.Body["next_page"]; has {
+ t.Fatalf("networks page 3 carries next_page = %v, want none (last page)", page3.Body["next_page"])
+ }
+}
+
+func TestTenderlySimulateShapes(t *testing.T) {
+ f := newTdFixture(t, time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC))
+
+ // ===== a plain simulation round-trips the deterministic Tenderly shape =====
+ // gas_used derives from the calldata length; the hash from the sim
+ // sequence; the zero-value call emits no logs and no overrides.
+ sim := f.simulate(tdTx(tdERC20Transfer, "0", nil))
+ if sim["simulationId"] != "sim_000001" || sim["network"] != "1" {
+ t.Fatalf("simulationId/network = %v / %v, want sim_000001 / 1", sim["simulationId"], sim["network"])
+ }
+ tx, _ := sim["transaction"].(map[string]any)
+ if tx["status"] != true {
+ t.Fatalf("plain call status = %v, want true", tx["status"])
+ }
+ tdNum(t, tx["gas_used"], 21000+float64(len(tdERC20Transfer)/2), "plain call gas_used (21000 + calldata half-length)")
+ if got, _ := tx["hash"].(string); got != fmt.Sprintf("0x%064x", 1) {
+ t.Fatalf("plain call hash = %s, want the sequence-derived hash", got)
+ }
+ tdNum(t, tx["block_number"], 19000000, "plain call block_number echo")
+ if got, _ := tx["block_hash"].(string); got != fmt.Sprintf("0x%064x", 19000100) {
+ t.Fatalf("plain call block_hash = %s, want block_number + 100 padded", got)
+ }
+ if tx["input"] != tdERC20Transfer || tx["from"] != tdFrom || tx["to"] != tdTo || tx["value"] != "0" {
+ t.Fatalf("plain call echo fields = %v / %v / %v / %v", tx["input"], tx["from"], tx["to"], tx["value"])
+ }
+ if tx["output"] != "0x" || tx["revert_reason"] != nil {
+ t.Fatalf("plain call output/revert_reason = %v / %v, want 0x / null", tx["output"], tx["revert_reason"])
+ }
+ trace, _ := sim["sim_call_trace"].(map[string]any)
+ if trace["type"] != "CALL" || trace["status"] != true {
+ t.Fatalf("trace head = %v / %v, want CALL / true", trace["type"], trace["status"])
+ }
+ if trace["gas"] != "0x186a0" || trace["gasUsed"] != fmt.Sprintf("0x%x", 21000+len(tdERC20Transfer)/2) {
+ t.Fatalf("trace gas fields = %v / %v, want hex quantities for gas 100000 and gas_used", trace["gas"], trace["gasUsed"])
+ }
+ if trace["value"] != "0x0" {
+ t.Fatalf("trace value = %v, want the exact zero quantity 0x0", trace["value"])
+ }
+ if calls, _ := trace["calls"].([]any); len(calls) != 0 {
+ t.Fatalf("plain call trace calls = %d, want 0", len(calls))
+ }
+ if logs, _ := sim["logs"].([]any); len(logs) != 0 {
+ t.Fatalf("plain call logs = %d, want 0 (no value moved)", len(logs))
+ }
+ if bo, _ := sim["balanceOverrides"].(map[string]any); len(bo) != 0 {
+ t.Fatalf("plain call balanceOverrides = %d entries, want 0", len(bo))
+ }
+
+ // ===== a value transfer emits the ERC-20 Transfer log and balance overrides =====
+ const oneETH = 1000000000000000000
+ xfer := f.simulate(tdTx("0x", "1000000000000000000", nil))
+ xtx, _ := xfer["transaction"].(map[string]any)
+ if xtx["status"] != true {
+ t.Fatalf("value transfer status = %v, want true", xtx["status"])
+ }
+ logs, _ := xfer["logs"].([]any)
+ if len(logs) != 1 {
+ t.Fatalf("value transfer logs = %d, want the single Transfer event", len(logs))
+ }
+ ev, _ := logs[0].(map[string]any)
+ if ev["address"] != tdTo {
+ t.Fatalf("Transfer address = %v, want the recipient contract", ev["address"])
+ }
+ topics, _ := ev["topics"].([]any)
+ if len(topics) != 3 {
+ t.Fatalf("Transfer topics = %d, want 3", len(topics))
+ }
+ if topics[0] != "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" {
+ t.Fatalf("Transfer topic0 = %v, want the ERC-20 Transfer signature", topics[0])
+ }
+ if topics[1] != "0x"+strings.Repeat("0", 24)+strings.ToLower(tdFrom[2:]) {
+ t.Fatalf("Transfer topic1 = %v, want the left-padded sender", topics[1])
+ }
+ if topics[2] != "0x"+strings.Repeat("0", 24)+strings.ToLower(tdTo[2:]) {
+ t.Fatalf("Transfer topic2 = %v, want the left-padded recipient", topics[2])
+ }
+ if ev["data"] != fmt.Sprintf("0x%064x", oneETH) {
+ t.Fatalf("Transfer data = %v, want the 32-byte value word", ev["data"])
+ }
+ bo, _ := xfer["balanceOverrides"].(map[string]any)
+ if bo[tdFrom] != "-1000000000000000000" || bo[tdTo] != "+1000000000000000000" {
+ t.Fatalf("balanceOverrides = %v, want -/+ the transferred wei on the two ends", bo)
+ }
+
+ // ===== reverting simulations carry the ABI-encoded Error(string) output =====
+ // Both triggers produce status false with the revert reason surfaced on
+ // the transaction and the trace, and the output is the canonical
+ // selector + offset + length + word-padded data encoding.
+ reasons := []string{"insufficient balance", "transfer amount exceeds the allowance by far"}
+ for i, reason := range reasons {
+ rev := f.simulate(tdTx("0x", "0", map[string]any{"revert": true, "revert_reason": reason}))
+ rtx, _ := rev["transaction"].(map[string]any)
+ if rtx["status"] != false {
+ t.Fatalf("revert[%d] status = %v, want false", i, rtx["status"])
+ }
+ if rtx["revert_reason"] != reason {
+ t.Fatalf("revert[%d] revert_reason = %v, want %q", i, rtx["revert_reason"], reason)
+ }
+ if got, _ := rtx["output"].(string); got != tdAbiError(reason) {
+ t.Fatalf("revert[%d] output = %s, want the exact ABI Error(string) encoding %s", i, got, tdAbiError(reason))
+ }
+ rtrace, _ := rev["sim_call_trace"].(map[string]any)
+ if rtrace["status"] != false || rtrace["error"] != reason {
+ t.Fatalf("revert[%d] trace = %v / %v, want false / the reason", i, rtrace["status"], rtrace["error"])
+ }
+ if logs, _ := rev["logs"].([]any); len(logs) != 0 {
+ t.Fatalf("revert[%d] logs = %d, want 0 (no side effects on revert)", i, len(logs))
+ }
+ }
+ // Selector-detected revert: calldata starting with Error(string)'s
+ // 0x08c379a0 reverts even without the explicit body flag.
+ sel := f.simulate(tdTx("0x08c379a0", "0", nil))
+ if stx, _ := sel["transaction"].(map[string]any); stx["status"] != false || stx["revert_reason"] != "execution reverted" {
+ t.Fatalf("selector revert = %v / %v, want false / execution reverted", stx["status"], stx["revert_reason"])
+ }
+
+ // ===== bundles fan out per simulation and stored results list and retrieve by id =====
+ bundle := f.call("sim", "on_simulate_bundle", "POST",
+ "/api/v1/account/vm-suite/project/proj/simulate-bundle",
+ map[string]string{"account": "vm-suite", "project": "proj"}, nil, map[string]any{
+ "simulations": []any{tdTx("0x", "0", nil), tdTx("0x", "42", nil)},
+ }, tdAuth)
+ if bundle.Status != 200 {
+ t.Fatalf("simulate-bundle -> %d: %v", bundle.Status, bundle.Body)
+ }
+ results, _ := bundle.Body["simulation_results"].([]any)
+ if len(results) != 2 {
+ t.Fatalf("bundle results = %d, want one per simulation", len(results))
+ }
+ firstID, _ := results[0].(map[string]any)["simulationId"].(string)
+ if bundle.Body["bundle_id"] != "bundle_"+firstID {
+ t.Fatalf("bundle_id = %v, want bundle_ + the first result's id", bundle.Body["bundle_id"])
+ }
+ if r := f.call("sim", "on_simulate_bundle", "POST",
+ "/api/v1/account/vm-suite/project/proj/simulate-bundle",
+ map[string]string{"account": "vm-suite", "project": "proj"}, nil,
+ map[string]any{"simulations": []any{}}, tdAuth); r.Status != 400 || r.Body["slug"] != "bad_request" {
+ t.Fatalf("empty bundle -> %d %v, want 400 {slug: bad_request}", r.Status, r.Body)
+ }
+
+ // List is scoped to the account; retrieve round-trips one stored result.
+ listed := f.call("sim", "on_list_simulations", "GET",
+ "/api/v1/account/vm-suite/project/proj/simulations",
+ map[string]string{"account": "vm-suite", "project": "proj"}, nil, nil, tdAuth)
+ sims, _ := listed.Body["simulations"].([]any)
+ if len(sims) != 7 { // 2 reverts + 1 selector + 1 plain + 1 transfer + 2 bundle
+ t.Fatalf("listed simulations = %d, want the 7 stored", len(sims))
+ }
+ other := f.call("sim", "on_list_simulations", "GET",
+ "/api/v1/account/other-acct/project/proj/simulations",
+ map[string]string{"account": "other-acct", "project": "proj"}, nil, nil, tdAuth)
+ if sims, _ := other.Body["simulations"].([]any); len(sims) != 0 {
+ t.Fatalf("other account sees %d simulations, want 0", len(sims))
+ }
+ got := f.call("sim", "on_retrieve_simulation", "GET",
+ "/api/v1/account/vm-suite/project/proj/simulations/"+firstID,
+ map[string]string{"account": "vm-suite", "project": "proj", "id": firstID}, nil, nil, tdAuth)
+ if got.Status != 200 || got.Body["simulationId"] != firstID {
+ t.Fatalf("retrieve %s -> %d %v, want the stored result", firstID, got.Status, got.Body)
+ }
+ if r := f.call("sim", "on_retrieve_simulation", "GET",
+ "/api/v1/account/vm-suite/project/proj/simulations/sim_nope",
+ map[string]string{"account": "vm-suite", "project": "proj", "id": "sim_nope"}, nil, nil, tdAuth); r.Status != 404 || r.Body["slug"] != "not_found" {
+ t.Fatalf("retrieve unknown id -> %d %v, want 404 {slug: not_found}", r.Status, r.Body)
+ }
+}
+
+// tdAbiError ABI-encodes an Error(string) revert output the way Solidity
+// does (mirrors lib.star's _abi_error_string): selector, 0x20 offset word,
+// length word, data padded to whole 32-byte words.
+func tdAbiError(reason string) string {
+ data := hex.EncodeToString([]byte(reason))
+ for len(data)%64 != 0 {
+ data += "0"
+ }
+ return "0x08c379a0" + fmt.Sprintf("%064x", 32) + fmt.Sprintf("%064x", len(reason)) + data
+}
+
+// tdNum compares a JSON number regardless of int64/float64 width (stored
+// docs round-trip through the collection store, where ints come back floats).
+func tdNum(t *testing.T, v any, want float64, what string) {
+ t.Helper()
+ switch n := v.(type) {
+ case int64:
+ if float64(n) != want {
+ t.Fatalf("%s = %d, want %v", what, n, want)
+ }
+ case float64:
+ if n != want {
+ t.Fatalf("%s = %v, want %v", what, n, want)
+ }
+ default:
+ t.Fatalf("%s is %T(%v), want number %v", what, v, v, want)
+ }
+}
diff --git a/adapters/thegraph_style_test.go b/adapters/thegraph_style_test.go
new file mode 100644
index 00000000..5e87ee46
--- /dev/null
+++ b/adapters/thegraph_style_test.go
@@ -0,0 +1,390 @@
+package adapters
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/vektah/gqlparser/v2/ast"
+ sk "go.starlark.net/starlark"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/graphqlsim"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the thegraph-style adapter scripts directly (lib.star preloaded)
+// over a shared store and virtual clock: real GraphQL documents executed by
+// the engine's GraphQL executor against the seeded subgraph (collection
+// arguments, aliases, where-filter operators, relational joins, _meta), the
+// spec-shaped errors[] surface for validation failures and the first cap,
+// and the REST SDL endpoint's public/known-key/unknown-key auth triad.
+const (
+ tgDeploy = "5zvR82QoaXYxfyKOCH8Qfl6p" // Uniswap V3-style deployment (the graphql path)
+ tgEnsDeploy = "5XqPmWe6gZyrTtFjASCbxgykJ7KbAA8puFezV8vsJoEB"
+ tgUSDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
+ tgWETH = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
+ tgUsdcWeth = "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640"
+ tgWbtcWeth = "0x11b815efb8f581194ae79006d24e0d814b7697f6"
+)
+
+type theGraphFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vms map[string]*starlark.VM
+ schema *ast.Schema
+ host string
+}
+
+func newTheGraphFixture(t *testing.T, start time.Time) *theGraphFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "thegraph-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ sdl, err := os.ReadFile(filepath.Join(root, "schemas", "schema.graphql"))
+ if err != nil {
+ t.Fatalf("read schema.graphql: %v", err)
+ }
+ schema, err := graphqlsim.LoadSchema(sdl)
+ if err != nil {
+ t.Fatalf("load graphql schema: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ load := func(script string) *starlark.VM {
+ t.Helper()
+ src, err := os.ReadFile(filepath.Join(root, "scripts", script))
+ if err != nil {
+ t.Fatalf("read %s: %v", script, err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib %s: %v", script, err)
+ }
+ return vm
+ }
+ return &theGraphFixture{t: t, vc: vc, schema: schema, host: "api.thegraph.test", vms: map[string]*starlark.VM{
+ "gql": load("resolvers.star"), "sdl": load("graphql.star"),
+ }}
+}
+
+// --- GraphQL harness (mirrors the engine's convention-named dispatch) ---
+
+// tgResolverSet maps on_ / resolve__ onto the resolvers
+// VM, exactly as the engine's graphql transport does.
+type tgResolverSet struct{ f *theGraphFixture }
+
+func (rs *tgResolverSet) Lookup(parentType, field string) (graphqlsim.Resolver, bool) {
+ fn := "on_" + field
+ if parentType != "Query" && parentType != "Mutation" {
+ fn = "resolve_" + parentType + "_" + field
+ }
+ vm := rs.f.vms["gql"]
+ if !vm.Has(fn) {
+ return nil, false
+ }
+ return func(_ context.Context, parent map[string]any, args map[string]any) (any, error) {
+ callArg, err := starlark.GoToStarlark(map[string]any{"parent": parent, "args": args})
+ if err != nil {
+ return nil, err
+ }
+ raw, err := vm.CallRaw(fn, callArg)
+ if err != nil {
+ return nil, err
+ }
+ return tgResultToGo(raw)
+ }, true
+}
+
+// tgResultToGo unwraps a respond(...) dict like the engine does; a None body
+// is a null GraphQL value.
+func tgResultToGo(v sk.Value) (any, error) {
+ if d, ok := v.(*sk.Dict); ok {
+ if body, found, _ := d.Get(sk.String("body")); found {
+ if _, none := body.(sk.NoneType); none {
+ return nil, nil
+ }
+ return starlark.ValueToGo(body)
+ }
+ }
+ if _, none := v.(sk.NoneType); none {
+ return nil, nil
+ }
+ return starlark.ValueToGo(v)
+}
+
+// gql executes a document; callers decide whether an error is expected.
+func (f *theGraphFixture) gql(query string, vars map[string]any) (*graphqlsim.Result, error) {
+ return graphqlsim.Execute(context.Background(), f.schema, query, vars, "", &tgResolverSet{f}, graphqlsim.Options{})
+}
+
+// gqlOK executes and fatals on validation or execution errors.
+func (f *theGraphFixture) gqlOK(query string, vars map[string]any) map[string]any {
+ f.t.Helper()
+ res, err := f.gql(query, vars)
+ if err != nil {
+ f.t.Fatalf("graphql: %v (query %s)", err, query)
+ }
+ if len(res.Errors) > 0 {
+ f.t.Fatalf("graphql errors: %v (query %s)", res.Errors, query)
+ }
+ data, ok := res.Data.(map[string]any)
+ if !ok {
+ f.t.Fatalf("data = %v (%T), want object (query %s)", res.Data, res.Data, query)
+ }
+ return data
+}
+
+// gqlErrs fatals when the document does NOT produce errors[] and returns
+// the joined messages.
+func (f *theGraphFixture) gqlErrs(query string, vars map[string]any) (*graphqlsim.Result, string) {
+ f.t.Helper()
+ res, err := f.gql(query, vars)
+ if err != nil {
+ f.t.Fatalf("graphql: %v (query %s)", err, query)
+ }
+ if len(res.Errors) == 0 {
+ f.t.Fatalf("want errors[], got clean data %v (query %s)", res.Data, query)
+ }
+ msgs := make([]string, len(res.Errors))
+ for i, e := range res.Errors {
+ msgs[i] = e.Message
+ }
+ return res, strings.Join(msgs, "; ")
+}
+
+// tgObj fetches key as a non-nil object (test-local).
+func tgObj(t *testing.T, m map[string]any, key string) map[string]any {
+ t.Helper()
+ v, ok := m[key].(map[string]any)
+ if !ok {
+ t.Fatalf("%s = %v (%T), want object", key, m[key], m[key])
+ }
+ return v
+}
+
+// tgNum reads a GraphQL Int whether the resolver produced a fresh Starlark
+// int or a value that round-tripped the JSON document store.
+func tgNum(v any) int64 {
+ switch n := v.(type) {
+ case int64:
+ return n
+ case float64:
+ return int64(n)
+ case int:
+ return int64(n)
+ }
+ return -1
+}
+
+// tgPools runs a pools query and returns the rows.
+func (f *theGraphFixture) tgPools(where string) []map[string]any {
+ f.t.Helper()
+ q := `{ pools(first: 10` + where + `) { id } }`
+ data := f.gqlOK(q, nil)
+ rows, ok := data["pools"].([]any)
+ if !ok {
+ f.t.Fatalf("pools = %v, want a list (query %s)", data["pools"], q)
+ }
+ out := make([]map[string]any, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, r.(map[string]any))
+ }
+ return out
+}
+
+// TestTheGraphSubgraphQueries: the executor-backed subgraph contract.
+func TestTheGraphSubgraphQueries(t *testing.T) {
+ f := newTheGraphFixture(t, time.Date(2026, 3, 4, 9, 0, 0, 0, time.UTC))
+
+ // ===== pools collection arguments sort by volume and join token0/token1 =====
+ data := f.gqlOK(`{
+ top: pools(first: 2, orderBy: volumeUSD, orderDirection: desc) {
+ id token0 { id symbol decimals } token1 { symbol } feeTier volumeUSD
+ }
+ }`, nil)
+ top, ok := data["top"].([]any)
+ if !ok || len(top) != 2 {
+ t.Fatalf("pools(first: 2) = %v, want 2 rows", data["top"])
+ }
+ first := top[0].(map[string]any)
+ if first["id"] != tgUsdcWeth {
+ t.Fatalf("pools[0].id = %v, want the USDC/WETH pool (highest volumeUSD)", first["id"])
+ }
+ token0 := tgObj(t, first, "token0")
+ if token0["symbol"] != "USDC" || token0["id"] != tgUSDC {
+ t.Fatalf("token0 = %v, want the joined USDC entity", token0)
+ }
+ if got := tgNum(token0["decimals"]); got != 6 {
+ t.Fatalf("token0.decimals = %v (%T), want Int 6 (not the stored \"6\" string)", token0["decimals"], token0["decimals"])
+ }
+ if tgObj(t, first, "token1")["symbol"] != "WETH" {
+ t.Fatalf("token1.symbol = %v, want the joined WETH", first["token1"])
+ }
+ // BigInt/BigDecimal serialize as decimal strings (graph-node wire form).
+ if first["feeTier"] != "500" || first["volumeUSD"] != "8912345678.901234" {
+ t.Fatalf("pool scalars = %v/%v, want the decimal-string wire form", first["feeTier"], first["volumeUSD"])
+ }
+
+ // ===== where filters map the graph-node suffix operators =====
+ // token0_not_in excludes the USDC pools; token0_in and _not keep their
+ // complements; numeric suffixes compare numerically on decimal strings.
+ notIn := f.tgPools(`, where: { token0_not_in: ["` + tgUSDC + `"] }`)
+ if len(notIn) != 1 || notIn[0]["id"] != tgWbtcWeth {
+ t.Fatalf("token0_not_in=[USDC] pools = %v, want exactly the WBTC pool", notIn)
+ }
+ in := f.tgPools(`, where: { token0_in: ["` + tgUSDC + `"] }`)
+ if len(in) != 2 {
+ t.Fatalf("token0_in=[USDC] pools = %v, want the two USDC pools", in)
+ }
+ notScalar := f.tgPools(`, where: { token0_not: "0x2260fac5e5542a773aa44fbcfedf7c193bc2b5f0" }`)
+ if len(notScalar) != 2 {
+ t.Fatalf("token0_not=WBTC pools = %v, want the two USDC pools", notScalar)
+ }
+ if got := len(f.tgPools(`, where: { txCount_gt: "700000" } `)); got != 2 {
+ t.Fatalf("txCount_gt=700000 pools = %d, want 2 (1234567, 890123)", got)
+ }
+ // Variables resolve into where clauses like literals do.
+ data = f.gqlOK(`query($sym: String) { tokens(first: 5, where: {symbol: $sym}) { id symbol } }`,
+ map[string]any{"sym": "WETH"})
+ weth, ok := data["tokens"].([]any)
+ if !ok || len(weth) != 1 || weth[0].(map[string]any)["symbol"] != "WETH" {
+ t.Fatalf("where {symbol: $sym} = %v, want the single WETH token", data["tokens"])
+ }
+ // skip paginates past the first page.
+ data = f.gqlOK(`{ page2: tokens(first: 2, skip: 2) { symbol } }`, nil)
+ if page2, ok := data["page2"].([]any); !ok || len(page2) != 2 {
+ t.Fatalf("tokens(first: 2, skip: 2) = %v, want the remaining 2 tokens", data["page2"])
+ }
+
+ // ===== validation failures and the first cap surface as GraphQL errors =====
+ // Unknown fields/filters are rejected before execution; the graph-node
+ // first cap fails the field, nulling data through the non-null list.
+ if _, err := f.gql(`{ pools(first: 1) { id sqrtPrice } }`, nil); err == nil || !strings.Contains(err.Error(), "sqrtPrice") {
+ t.Fatalf("unknown Pool field error = %v, want it to name sqrtPrice", err)
+ }
+ if _, err := f.gql(`{ swaps(first: 5) { id } }`, nil); err == nil || !strings.Contains(err.Error(), "swaps") {
+ t.Fatalf("unknown root field error = %v, want it to name swaps", err)
+ }
+ if _, err := f.gql(`{ pools(first: 1, where: { unknownThing: "x" }) { id } }`, nil); err == nil {
+ t.Fatal("unknown where filter accepted; want a validation error")
+ }
+ if _, err := f.gql(`{ pools(first: 1, orderBy: fakeField) { id } }`, nil); err == nil {
+ t.Fatal("unknown orderBy enum accepted; want a validation error")
+ }
+ res, msgs := f.gqlErrs(`{ pools(first: 2000) { id } }`, nil)
+ if !strings.Contains(msgs, "first parameter cannot exceed 1000") {
+ t.Fatalf("first=2000 errors = %q, want the graph-node cap message", msgs)
+ }
+ if res.Data != nil {
+ t.Fatalf("first=2000 data = %v, want null ([Pool!]! failed and propagated)", res.Data)
+ }
+
+ // ===== domains join owner/resolvedAddress; lookups miss as null =====
+ data = f.gqlOK(`{
+ domains(first: 10, orderBy: createdAt, orderDirection: asc) {
+ id name labelName owner { id } resolvedAddress { id } createdAt
+ }
+ }`, nil)
+ domains, ok := data["domains"].([]any)
+ if !ok || len(domains) != 3 {
+ t.Fatalf("domains = %v, want the 3 seeded ENS-style domains", data["domains"])
+ }
+ d0 := domains[0].(map[string]any)
+ if d0["name"] != "vitalik.eth" || d0["labelName"] != "vitalik" {
+ t.Fatalf("domains[0] = %v, want vitalik.eth first (createdAt asc)", d0)
+ }
+ owner := tgObj(t, d0, "owner")
+ if ownerID, _ := owner["id"].(string); len(ownerID) != 42 || !strings.HasPrefix(ownerID, "0x") {
+ t.Fatalf("domain.owner.id = %v, want the joined 0x… account", owner["id"])
+ }
+ single := f.gqlOK(`query($id: ID!) { domain(id: $id) { name owner { id } } }`,
+ map[string]any{"id": d0["id"]})
+ if tgObj(t, single, "domain")["name"] != "vitalik.eth" {
+ t.Fatalf("domain(id) = %v, want the same entity back", single["domain"])
+ }
+ miss := f.gqlOK(`query($id: ID!) { domain(id: $id) { name } }`, map[string]any{"id": "0xmissing"})
+ if v, ok := miss["domain"]; !ok || v != nil {
+ t.Fatalf("domain(0xmissing) = %v, want null without errors[]", miss["domain"])
+ }
+
+ // ===== _meta reports the deployment head; Token.pools joins in reverse =====
+ data = f.gqlOK(`{
+ _meta { deployment network block { number } hasIndexingErrors genesis { number } }
+ weth: token(id: "`+tgWETH+`") { symbol pools { id } }
+ }`, nil)
+ meta := tgObj(t, data, "_meta")
+ if meta["deployment"] != tgDeploy || meta["network"] != "mainnet" || meta["hasIndexingErrors"] != false {
+ t.Fatalf("_meta = %v, want the seeded deployment on mainnet without indexing errors", meta)
+ }
+ if got := tgNum(tgObj(t, meta, "block")["number"]); got <= 0 {
+ t.Fatalf("_meta.block.number = %v, want a positive head number", tgObj(t, meta, "block")["number"])
+ }
+ if got := tgNum(tgObj(t, meta, "genesis")["number"]); got != 1 {
+ t.Fatalf("_meta.genesis.number = %v, want 1", got)
+ }
+ wethTok := tgObj(t, data, "weth")
+ pools, ok := wethTok["pools"].([]any)
+ if !ok || len(pools) != 2 {
+ t.Fatalf("WETH pools reverse join = %v, want the 2 pools holding WETH as token1", wethTok["pools"])
+ }
+
+ // ===== the REST SDL surface is public and rejects unknown bearer keys =====
+ // GET /subgraphs/id/{id}/graphql: anonymous is fine (hosted-service
+ // semantics); a presented key must be the known one (gateway semantics).
+ sdlGet := func(auth string) starlark.Response {
+ t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ r, err := f.vms["sdl"].Call("on_schema", starlark.Request{
+ Method: "GET", Path: "/subgraphs/id/" + tgEnsDeploy + "/graphql", Host: f.host,
+ Headers: headers, Params: map[string]string{"subgraphId": tgEnsDeploy}, Query: map[string]string{},
+ })
+ if err != nil {
+ t.Fatalf("on_schema: %v", err)
+ }
+ return r
+ }
+ anon := sdlGet("")
+ sdlText, _ := anon.Body["data"].(string)
+ if anon.Status != 200 || !strings.Contains(sdlText, "type Domain") {
+ t.Fatalf("anonymous SDL -> %d %q, want the ENS SDL string", anon.Status, sdlText)
+ }
+ if ct := anon.Headers["Content-Type"]; ct != "application/graphql" {
+ t.Fatalf("SDL Content-Type = %q, want application/graphql", ct)
+ }
+ bad := sdlGet("Bearer not-a-known-key")
+ if bad.Status != 401 {
+ t.Fatalf("unknown bearer SDL -> %d, want 401", bad.Status)
+ }
+ if errs, ok := bad.Body["errors"].([]any); !ok || len(errs) != 1 {
+ t.Fatalf("unknown bearer body = %v, want the GraphQL errors[] envelope", bad.Body)
+ } else if msg := errs[0].(map[string]any)["message"]; msg != "valid API key expected" {
+ t.Fatalf("unknown bearer error message = %v, want the gateway phrasing", msg)
+ }
+ if known := sdlGet("Bearer mock-graph-api-key"); known.Status != 200 {
+ t.Fatalf("known bearer SDL -> %d, want 200 (the well-known test key)", known.Status)
+ }
+}
diff --git a/adapters/walletconnect-style/adapter.yaml b/adapters/walletconnect-style/adapter.yaml
index 9adf529e..c6c1be87 100644
--- a/adapters/walletconnect-style/adapter.yaml
+++ b/adapters/walletconnect-style/adapter.yaml
@@ -28,9 +28,12 @@ endpoints:
handler: scripts/relay.star#on_list_sessions
# --- Session actions (parameterized) ---
+ # concurrency_key: approve and disconnect read-modify-write the session
+ # doc keyed by topic (find -> update/delete), so same-topic calls serialize.
- route: /v1/sessions/{topic}/approve
method: POST
handler: scripts/relay.star#on_approve_session
+ concurrency_key: topic
- route: /v1/sessions/{topic}/request
method: POST
handler: scripts/relay.star#on_session_request
@@ -40,6 +43,7 @@ endpoints:
- route: /v1/sessions/{topic}
method: DELETE
handler: scripts/relay.star#on_disconnect_session
+ concurrency_key: topic
# Backing stores — collections for stateful data.
resources:
diff --git a/adapters/walletconnect-style/scripts/lib.star b/adapters/walletconnect-style/scripts/lib.star
index 68f129a5..4c3ae4a3 100644
--- a/adapters/walletconnect-style/scripts/lib.star
+++ b/adapters/walletconnect-style/scripts/lib.star
@@ -96,6 +96,9 @@ def _parse_wc_uri(uri):
if at_idx < 0:
return None
topic = rest[:at_idx]
+ if topic == "":
+ # A wc: URI always names a non-empty topic before the @version.
+ return None
after_at = rest[at_idx + 1:]
q_idx = _find_char(after_at, "?")
if q_idx < 0:
diff --git a/adapters/walletconnect-style/scripts/relay.star b/adapters/walletconnect-style/scripts/relay.star
index a655245c..970957ec 100644
--- a/adapters/walletconnect-style/scripts/relay.star
+++ b/adapters/walletconnect-style/scripts/relay.star
@@ -26,6 +26,7 @@ def on_create_pairing(req):
body = {}
uri = body.get("uri", None)
+ relay_proto = "irn"
if uri != None and uri != "":
# Parse the wc: URI to extract the topic and symKey.
@@ -34,21 +35,22 @@ def on_create_pairing(req):
return respond(400, {"error": "invalid_uri", "message": "could not parse wc: URI"})
topic = parsed["topic"]
sym_key = parsed["symKey"]
+ # The URI names its relay protocol; echo it rather than assume irn.
+ relay_proto = parsed["relayProtocol"]
else:
- # No URI — generate a topic from a sequence number.
+ # No URI — mint a topic AND symKey: a real pairing always carries a
+ # 256-bit symmetric key (an empty one cannot seed a wc: URI).
seq = store_kv_incr("wc", "pairing_seq")
topic = _topic("pairing-" + str(seq))
- sym_key = ""
+ sym_key = _topic("symkey-" + str(seq))
pc = store_collection("pairings")
doc = {
"topic": topic,
- "relay": {"protocol": "irn"},
+ "relay": {"protocol": relay_proto},
"expiry": PAIRING_EXPIRY,
"state": {"symKey": sym_key},
}
- for k in doc:
- pass
pc.insert(doc)
return respond(200, doc)
diff --git a/adapters/walletconnect_style_test.go b/adapters/walletconnect_style_test.go
new file mode 100644
index 00000000..9f050d46
--- /dev/null
+++ b/adapters/walletconnect_style_test.go
@@ -0,0 +1,525 @@
+package adapters
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "stuntapi.com/stunt/internal/adapter/runtime"
+ "stuntapi.com/stunt/internal/primitives"
+ "stuntapi.com/stunt/internal/primitives/blob"
+ "stuntapi.com/stunt/internal/primitives/clock"
+ "stuntapi.com/stunt/internal/primitives/events"
+ "stuntapi.com/stunt/internal/primitives/kv"
+ "stuntapi.com/stunt/internal/starlark"
+)
+
+// Drives the walletconnect-style adapter script directly (lib.star preloaded)
+// over a shared store and virtual clock: the pairing -> propose -> approve ->
+// JSON-RPC request -> extend -> disconnect relay lifecycle, wc: URI parsing,
+// the auto-paired symKey, the derived eip155 namespaces, the bare-array
+// session list with limit paging, the {error, message} envelopes, and the
+// unenforced projectId gate.
+
+const (
+ wcHost = "relay.walletconnect.test"
+ wcWallet = "0x1234567890abcdef1234567890abcdef12345678"
+ wcPairingExpiry = 2592000 // 30-day pairing TTL, seconds
+ wcSessionExpiry = 604800 // 7-day session TTL, seconds
+)
+
+// 64-hex fixtures for the wc: URI round-trip (topics/symKeys carry no 0x).
+var (
+ wcTopicURI = strings.Repeat("a1b2c3d4", 8)
+ wcSymKeyURI = strings.Repeat("9f8e7d6c", 8)
+)
+
+func wcBase() time.Time { return time.Unix(1_750_000_000, 0).UTC() }
+
+type walletconnectFixture struct {
+ t *testing.T
+ vc *clock.Clock
+ vm *starlark.VM
+ host string
+}
+
+func newWalletconnectFixture(t *testing.T, start time.Time) *walletconnectFixture {
+ t.Helper()
+ root := filepath.Join(repoAdaptersDir(t), "walletconnect-style")
+ libSrc, err := os.ReadFile(filepath.Join(root, "scripts", "lib.star"))
+ if err != nil {
+ t.Fatalf("read lib.star: %v", err)
+ }
+ tmp := t.TempDir()
+ store, _ := primitives.Open(filepath.Join(tmp, "s.db"))
+ t.Cleanup(func() { store.Close() })
+ kvStore, _ := kv.Open(filepath.Join(tmp, "s.kv.db"))
+ t.Cleanup(func() { kvStore.Close() })
+ blobStore, _ := blob.Open(filepath.Join(tmp, "blobs"))
+ t.Cleanup(func() { blobStore.Close() })
+
+ vc := clock.NewVirtualClock(start)
+ builtins := runtime.BuildAllBuiltins(runtime.BuiltinOptions{
+ Store: store, KV: kvStore, Blob: blobStore, Clock: vc, ServiceName: "test", Emitter: events.NewEmitter(),
+ })
+ src, err := os.ReadFile(filepath.Join(root, "scripts", "relay.star"))
+ if err != nil {
+ t.Fatalf("read relay.star: %v", err)
+ }
+ vm, err := starlark.LoadWithLib(string(src), string(libSrc), builtins)
+ if err != nil {
+ t.Fatalf("LoadWithLib relay.star: %v", err)
+ }
+ return &walletconnectFixture{t: t, vc: vc, vm: vm, host: wcHost}
+}
+
+func (f *walletconnectFixture) call(handler, method, path string, params, query map[string]string, body map[string]any, auth string) starlark.Response {
+ f.t.Helper()
+ headers := map[string]string{}
+ if auth != "" {
+ headers["Authorization"] = auth
+ }
+ if params == nil {
+ params = map[string]string{}
+ }
+ if query == nil {
+ query = map[string]string{}
+ }
+ resp, err := f.vm.Call(handler, starlark.Request{
+ Method: method, Path: path, Host: f.host, Headers: headers, Body: body, Params: params, Query: query,
+ })
+ if err != nil {
+ f.t.Fatalf("%s %s: %v", handler, path, err)
+ }
+ return resp
+}
+
+// sessions lists the bare-array session index keyed by topic.
+func (f *walletconnectFixture) sessions(query map[string]string) map[string]map[string]any {
+ f.t.Helper()
+ r := f.call("on_list_sessions", "GET", "/v1/sessions", nil, query, nil, "")
+ if r.Status != 200 {
+ f.t.Fatalf("list sessions -> %d: %v", r.Status, r.Body)
+ }
+ byTopic := map[string]map[string]any{}
+ for _, e := range r.BodyList {
+ m, ok := e.(map[string]any)
+ if !ok {
+ f.t.Fatalf("session entry = %T, want an object", e)
+ }
+ topic, _ := m["topic"].(string)
+ if topic == "" {
+ f.t.Fatalf("session entry has no topic: %v", m)
+ }
+ byTopic[topic] = m
+ }
+ return byTopic
+}
+
+// wcNum reads a response number as int64 whether the adapter produced a
+// Starlark int (computed fresh in this call) or a float (read back through
+// the JSON document store) — both marshal to the same JSON number on the wire.
+func wcNum(v any) int64 {
+ switch n := v.(type) {
+ case int64:
+ return n
+ case float64:
+ return int64(n)
+ }
+ return 0
+}
+
+// wcHex reports whether s is exactly n lowercase hex characters.
+func wcHex(s string, n int) bool {
+ if len(s) != n {
+ return false
+ }
+ for _, c := range s {
+ if !strings.ContainsRune("0123456789abcdef", c) {
+ return false
+ }
+ }
+ return true
+}
+
+// wcHash reports whether s is a 0x-prefixed 64-hex synthetic hash.
+func wcHash(s string) bool {
+ return strings.HasPrefix(s, "0x") && wcHex(strings.TrimPrefix(s, "0x"), 64)
+}
+
+// TestWalletconnectPairingAndSessionLifecycle: the pairing surface (URI
+// round-trip, malformed-URI rejects, auto-generated pairings), session
+// proposal + approval with derived eip155 namespaces, and the bare-array
+// session list with limit paging.
+func TestWalletconnectPairingAndSessionLifecycle(t *testing.T) {
+ f := newWalletconnectFixture(t, wcBase())
+
+ // ===== every route answers without a projectId (the gate is not wired) =====
+ // The manifest declares identity.token_scheme: bearer and lib.star ships
+ // _require_project_id, but no handler calls the helper — the relay answers
+ // with no credential at all. Asserted as-is; see the deviation report.
+ if r := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, ""); r.Status != 200 {
+ t.Fatalf("pairing with no projectId -> %d, want 200 (gate unenforced): %v", r.Status, r.Body)
+ }
+ if r := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, "Bearer not-a-project-id"); r.Status != 200 {
+ t.Fatalf("pairing with a bogus bearer -> %d, want 200 (gate unenforced): %v", r.Status, r.Body)
+ }
+ if r := f.call("on_list_sessions", "GET", "/v1/sessions", nil, nil, nil, ""); r.Status != 200 {
+ t.Fatalf("list with no projectId -> %d, want 200 (gate unenforced)", r.Status)
+ }
+
+ // ===== a wc: URI pairing round-trips its topic, relay protocol, and symKey =====
+ paired := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{
+ "uri": "wc:" + wcTopicURI + "@2?relay-protocol=irn&symKey=" + wcSymKeyURI,
+ }, "")
+ if paired.Status != 200 {
+ t.Fatalf("pairing from URI -> %d: %v", paired.Status, paired.Body)
+ }
+ if paired.Body["topic"] != wcTopicURI {
+ t.Fatalf("pairing topic = %v, want the URI topic %s", paired.Body["topic"], wcTopicURI)
+ }
+ relay, _ := paired.Body["relay"].(map[string]any)
+ if relay["protocol"] != "irn" {
+ t.Fatalf("relay protocol = %v, want irn", relay["protocol"])
+ }
+ if got := wcNum(paired.Body["expiry"]); got != wcPairingExpiry {
+ t.Fatalf("pairing expiry = %v, want the 30-day TTL %d", paired.Body["expiry"], wcPairingExpiry)
+ }
+ state, _ := paired.Body["state"].(map[string]any)
+ if state["symKey"] != wcSymKeyURI {
+ t.Fatalf("pairing symKey = %v, want the URI symKey", state["symKey"])
+ }
+ // A URI naming a different relay protocol is echoed, not forced to irn.
+ other := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{
+ "uri": "wc:" + strings.Repeat("0f1e2d3c", 8) + "@2?relay-protocol=custom&symKey=" + strings.Repeat("55667788", 8),
+ }, "")
+ otherRelay, _ := other.Body["relay"].(map[string]any)
+ if other.Status != 200 || otherRelay["protocol"] != "custom" {
+ t.Fatalf("custom relay-protocol URI -> %d %v, want the echoed protocol", other.Status, other.Body)
+ }
+ // Malformed URIs (wrong scheme, missing @version, empty topic) are 400s.
+ for _, bad := range []string{
+ "https://wallet.example.test/uri", // not a wc: URI
+ "wc:topic-without-a-version", // no @2
+ "wc:@2?symKey=abc", // empty topic before @2
+ } {
+ r := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{"uri": bad}, "")
+ if r.Status != 400 {
+ t.Fatalf("uri %q -> %d, want 400", bad, r.Status)
+ }
+ if r.Body["error"] != "invalid_uri" {
+ t.Fatalf("uri %q error = %v, want invalid_uri", bad, r.Body["error"])
+ }
+ if msg, _ := r.Body["message"].(string); msg == "" {
+ t.Fatalf("uri %q carries no message: %v", bad, r.Body)
+ }
+ }
+
+ // ===== an auto pairing mints a fresh topic and a 64-hex symKey =====
+ auto1 := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, "")
+ if auto1.Status != 200 {
+ t.Fatalf("auto pairing -> %d: %v", auto1.Status, auto1.Body)
+ }
+ autoTopic1, _ := auto1.Body["topic"].(string)
+ autoState1, _ := auto1.Body["state"].(map[string]any)
+ autoSym1, _ := autoState1["symKey"].(string)
+ if !wcHex(autoTopic1, 64) || autoTopic1 == wcTopicURI {
+ t.Fatalf("auto topic = %q, want a fresh 64-hex topic", autoTopic1)
+ }
+ if !wcHex(autoSym1, 64) {
+ t.Fatalf("auto symKey = %q, want a minted 64-hex key (never empty)", autoSym1)
+ }
+ auto2 := f.call("on_create_pairing", "POST", "/v1/pairings", nil, nil, map[string]any{}, "")
+ autoTopic2, _ := auto2.Body["topic"].(string)
+ autoState2, _ := auto2.Body["state"].(map[string]any)
+ if autoTopic2 == autoTopic1 || autoState2["symKey"] == autoSym1 {
+ t.Fatalf("second auto pairing reuses topic/symKey: %q vs %q", autoTopic2, autoTopic1)
+ }
+
+ // ===== proposing requires pairingTopic — and accepts one never paired =====
+ noTopic := f.call("on_propose_session", "POST", "/v1/sessions", nil, nil, map[string]any{}, "")
+ if noTopic.Status != 400 {
+ t.Fatalf("propose without pairingTopic -> %d, want 400", noTopic.Status)
+ }
+ if noTopic.Body["error"] != "missing_pairingTopic" {
+ t.Fatalf("propose error = %v, want missing_pairingTopic", noTopic.Body["error"])
+ }
+ // The simulated wallet never consults the pairings store: an unknown
+ // pairingTopic still proposes. Asserted as-is; see the deviation report.
+ ghost := f.call("on_propose_session", "POST", "/v1/sessions", nil, nil,
+ map[string]any{"pairingTopic": strings.Repeat("ee", 32)}, "")
+ if ghost.Status != 200 {
+ t.Fatalf("propose on an unknown pairingTopic -> %d, want 200 (no pairing check): %v", ghost.Status, ghost.Body)
+ }
+ ghostTopic, _ := ghost.Body["topic"].(string)
+ if !wcHex(ghostTopic, 64) {
+ t.Fatalf("proposed topic = %q, want 64-hex", ghostTopic)
+ }
+ if ghost.Body["acknowledged"] != false || ghost.Body["pairingTopic"] != strings.Repeat("ee", 32) {
+ t.Fatalf("proposal shape = %v, want acknowledged:false + echoed pairingTopic", ghost.Body)
+ }
+ if got := wcNum(ghost.Body["expiry"]); got != wcSessionExpiry {
+ t.Fatalf("proposal expiry = %v, want the 7-day TTL %d", ghost.Body["expiry"], wcSessionExpiry)
+ }
+ if ns, _ := ghost.Body["namespaces"].(map[string]any); len(ns) != 0 {
+ t.Fatalf("proposal namespaces = %v, want empty until approved", ghost.Body["namespaces"])
+ }
+ proposed := f.call("on_propose_session", "POST", "/v1/sessions", nil, nil, map[string]any{
+ "pairingTopic": wcTopicURI,
+ "requiredNamespaces": map[string]any{
+ "eip155": map[string]any{
+ "chains": []any{"eip155:137", "eip155:10"},
+ "methods": []any{"eth_signTypedData_v4"},
+ "events": []any{"chainChanged"},
+ },
+ },
+ }, "")
+ if proposed.Status != 200 {
+ t.Fatalf("propose -> %d: %v", proposed.Status, proposed.Body)
+ }
+ topicA, _ := proposed.Body["topic"].(string)
+ if topicA == "" || topicA == wcTopicURI {
+ t.Fatalf("session topic = %q, want its own topic distinct from the pairing", topicA)
+ }
+
+ // ===== approve acknowledges the session and derives eip155 namespaces =====
+ approved := f.call("on_approve_session", "POST", "/v1/sessions/"+topicA+"/approve",
+ map[string]string{"topic": topicA}, nil, map[string]any{}, "")
+ if approved.Status != 200 {
+ t.Fatalf("approve -> %d: %v", approved.Status, approved.Body)
+ }
+ if approved.Body["topic"] != topicA || approved.Body["acknowledged"] != true {
+ t.Fatalf("approval = %v, want acknowledged:true on its topic", approved.Body)
+ }
+ ns, _ := approved.Body["namespaces"].(map[string]any)
+ eip, _ := ns["eip155"].(map[string]any)
+ accounts, _ := eip["accounts"].([]any)
+ if len(accounts) != 2 || accounts[0] != "eip155:137:"+wcWallet || accounts[1] != "eip155:10:"+wcWallet {
+ t.Fatalf("derived accounts = %v, want one per required chain suffixed with the wallet", accounts)
+ }
+ if m, _ := eip["methods"].([]any); len(m) != 1 || m[0] != "eth_signTypedData_v4" {
+ t.Fatalf("derived methods = %v, want the required set", eip["methods"])
+ }
+ if ev, _ := eip["events"].([]any); len(ev) != 1 || ev[0] != "chainChanged" {
+ t.Fatalf("derived events = %v, want the required set", eip["events"])
+ }
+ // Without requiredNamespaces the wallet falls back to its defaults.
+ dflt := f.call("on_propose_session", "POST", "/v1/sessions", nil, nil,
+ map[string]any{"pairingTopic": autoTopic1}, "")
+ if dflt.Status != 200 {
+ t.Fatalf("propose defaults -> %d: %v", dflt.Status, dflt.Body)
+ }
+ dfltTopic, _ := dflt.Body["topic"].(string)
+ def := f.call("on_approve_session", "POST", "/v1/sessions/"+dfltTopic+"/approve",
+ map[string]string{"topic": dfltTopic}, nil, map[string]any{}, "")
+ if def.Status != 200 {
+ t.Fatalf("approve defaults -> %d: %v", def.Status, def.Body)
+ }
+ defNS, _ := def.Body["namespaces"].(map[string]any)
+ defEip, _ := defNS["eip155"].(map[string]any)
+ if a, _ := defEip["accounts"].([]any); len(a) != 1 || a[0] != "eip155:1:"+wcWallet {
+ t.Fatalf("default accounts = %v, want eip155:1:", defEip["accounts"])
+ }
+ if m, _ := defEip["methods"].([]any); len(m) != 2 {
+ t.Fatalf("default methods = %v, want the 2 wallet defaults", defEip["methods"])
+ }
+ // Unknown topics answer the documented 404 envelope.
+ unknown := f.call("on_approve_session", "POST", "/v1/sessions/dead00beef/approve",
+ map[string]string{"topic": "dead00beef"}, nil, map[string]any{}, "")
+ if unknown.Status != 404 || unknown.Body["error"] != "session_not_found" {
+ t.Fatalf("approve unknown topic -> %d %v, want 404 session_not_found", unknown.Status, unknown.Body)
+ }
+ if msg, _ := unknown.Body["message"].(string); !strings.Contains(msg, "dead00beef") {
+ t.Fatalf("approve unknown message = %q, want it to name the topic", msg)
+ }
+
+ // ===== the session list is a bare array capped by limit =====
+ all := f.sessions(nil)
+ if len(all) != 3 {
+ t.Fatalf("session list has %d entries, want all three proposals", len(all))
+ }
+ a := all[topicA]
+ if a["acknowledged"] != true {
+ t.Fatalf("listed approved session = %v, want acknowledged:true", a)
+ }
+ if ns, _ := a["namespaces"].(map[string]any); len(ns) == 0 {
+ t.Fatalf("listed approved session has empty namespaces: %v", a)
+ }
+ if got := wcNum(a["expiry"]); got != wcSessionExpiry {
+ t.Fatalf("listed expiry = %v, want %d", a["expiry"], wcSessionExpiry)
+ }
+ if g := all[ghostTopic]; g["acknowledged"] != false {
+ t.Fatalf("listed unapproved session = %v, want acknowledged:false", g)
+ }
+ page1 := f.call("on_list_sessions", "GET", "/v1/sessions", nil, map[string]string{"limit": "1"}, nil, "")
+ if page1.Status != 200 || len(page1.BodyList) != 1 {
+ t.Fatalf("limit=1 -> %d (%d entries), want a 1-entry page", page1.Status, len(page1.BodyList))
+ }
+ // The page is a bare array: no envelope object, and no cursor token is
+ // surfaced anywhere (the offset token must be guessed). Asserted as-is;
+ // see the deviation report.
+ if page1.Body != nil {
+ t.Fatalf("paged list carries an envelope object: %v", page1.Body)
+ }
+ t1, _ := page1.BodyList[0].(map[string]any)["topic"].(string)
+ page2 := f.call("on_list_sessions", "GET", "/v1/sessions", nil,
+ map[string]string{"limit": "1", "cursor": "1"}, nil, "")
+ if page2.Status != 200 || len(page2.BodyList) != 1 {
+ t.Fatalf("cursor=1 -> %d (%d entries), want the other 1-entry page", page2.Status, len(page2.BodyList))
+ }
+ t2, _ := page2.BodyList[0].(map[string]any)["topic"].(string)
+ known := map[string]bool{topicA: true, ghostTopic: true, dfltTopic: true}
+ if !known[t1] || !known[t2] || t1 == t2 {
+ t.Fatalf("pages = %q / %q, want two distinct listed sessions", t1, t2)
+ }
+ badCursor := f.call("on_list_sessions", "GET", "/v1/sessions", nil,
+ map[string]string{"cursor": "not-a-cursor"}, nil, "")
+ if badCursor.Status != 400 || badCursor.Body["error"] != "invalid_cursor" {
+ t.Fatalf("invalid cursor -> %d %v, want 400 invalid_cursor", badCursor.Status, badCursor.Body)
+ }
+}
+
+// TestWalletconnectSessionRequestLifecycle: the auto-approving wallet's
+// JSON-RPC surface — the 2.0 envelope with globally monotonic ids, synthetic
+// per-method hashes, the missing approval gate, the fixed-TTL extend, and the
+// disconnect that retires the topic.
+func TestWalletconnectSessionRequestLifecycle(t *testing.T) {
+ f := newWalletconnectFixture(t, wcBase())
+ propose := func(pairingTopic string) string {
+ r := f.call("on_propose_session", "POST", "/v1/sessions", nil, nil,
+ map[string]any{"pairingTopic": pairingTopic}, "")
+ if r.Status != 200 {
+ t.Fatalf("propose -> %d: %v", r.Status, r.Body)
+ }
+ topic, _ := r.Body["topic"].(string)
+ if topic == "" {
+ t.Fatalf("proposal carries no topic: %v", r.Body)
+ }
+ return topic
+ }
+ request := func(topic, method string, params []any) starlark.Response {
+ return f.call("on_session_request", "POST", "/v1/sessions/"+topic+"/request",
+ map[string]string{"topic": topic}, nil,
+ map[string]any{"request": map[string]any{"method": method, "params": params}}, "")
+ }
+ params := []any{"0x48656c6c6f", wcWallet}
+ topicA := propose(strings.Repeat("aa", 32)) // paired and approved below
+ topicB := propose(strings.Repeat("bb", 32)) // never approved
+ if r := f.call("on_approve_session", "POST", "/v1/sessions/"+topicA+"/approve",
+ map[string]string{"topic": topicA}, nil, map[string]any{}, ""); r.Status != 200 {
+ t.Fatalf("approve -> %d: %v", r.Status, r.Body)
+ }
+
+ // ===== the approval gate is missing: an unacknowledged session still answers =====
+ // topicB was never approved; the auto-wallet serves it anyway. Asserted
+ // as-is; see the deviation report.
+ if r := request(topicB, "eth_requestAccounts", []any{}); r.Status != 200 {
+ t.Fatalf("request on an unapproved session -> %d, want 200 (no approval gate): %v", r.Status, r.Body)
+ }
+
+ // ===== wallet requests answer in a JSON-RPC 2.0 envelope with monotonic ids =====
+ // Batch JSON-RPC arrays are not modeled: one request object per call.
+ id0 := wcNum(request(topicB, "eth_requestAccounts", []any{}).Body["id"])
+ acc := request(topicA, "eth_requestAccounts", []any{})
+ if acc.Status != 200 {
+ t.Fatalf("eth_requestAccounts -> %d: %v", acc.Status, acc.Body)
+ }
+ if acc.Body["jsonrpc"] != "2.0" || acc.Body["topic"] != topicA {
+ t.Fatalf("envelope = %v, want jsonrpc 2.0 on its topic", acc.Body)
+ }
+ if res, _ := acc.Body["result"].([]any); len(res) != 1 || res[0] != wcWallet {
+ t.Fatalf("eth_requestAccounts result = %v, want []", acc.Body["result"])
+ }
+ if wcNum(acc.Body["id"]) != id0+1 {
+ t.Fatalf("eth_requestAccounts id = %v after %d, want the next global id", acc.Body["id"], id0)
+ }
+ // The id sequence is global (kv-backed) and increments per call — across
+ // sessions, not per topic.
+ accts := request(topicA, "eth_accounts", []any{})
+ if wcNum(accts.Body["id"]) != id0+2 || accts.Body["jsonrpc"] != "2.0" {
+ t.Fatalf("eth_accounts id = %v after %d, want the next global id", accts.Body["id"], id0+2)
+ }
+ if res, _ := accts.Body["result"].([]any); len(res) != 1 || res[0] != wcWallet {
+ t.Fatalf("eth_accounts result = %v, want []", accts.Body["result"])
+ }
+
+ // ===== signing and transaction methods return synthetic 0x-hex hashes =====
+ for _, method := range []string{"personal_sign", "eth_sendTransaction", "eth_sign"} {
+ r := request(topicA, method, params)
+ if r.Status != 200 {
+ t.Fatalf("%s -> %d: %v", method, r.Status, r.Body)
+ }
+ sig, ok := r.Body["result"].(string)
+ if !ok || !wcHash(sig) {
+ t.Fatalf("%s result = %v, want a 0x-prefixed 64-hex hash", method, r.Body["result"])
+ }
+ }
+ // Unknown methods fall through to the same synthetic hash shape.
+ if r := request(topicA, "eth_signTypedData_v4", params); r.Status != 200 || !wcHash(r.Body["result"].(string)) {
+ t.Fatalf("unknown method -> %d %v, want the default synthetic hash", r.Status, r.Body)
+ }
+ // Hashes are seeded by the request id, so the same method differs per call.
+ s1, _ := request(topicA, "personal_sign", params).Body["result"].(string)
+ s2, _ := request(topicA, "personal_sign", params).Body["result"].(string)
+ if s1 == s2 || !wcHash(s1) || !wcHash(s2) {
+ t.Fatalf("repeated personal_sign = %q / %q, want distinct id-seeded hashes", s1, s2)
+ }
+ // Unknown topics answer the same 404 envelope as the other actions.
+ if r := request("dead00beef", "eth_requestAccounts", []any{}); r.Status != 404 || r.Body["error"] != "session_not_found" {
+ t.Fatalf("request unknown topic -> %d %v, want 404 session_not_found", r.Status, r.Body)
+ }
+
+ // ===== extend echoes the fixed session TTL without persisting anything =====
+ ext := f.call("on_extend_session", "POST", "/v1/sessions/"+topicA+"/extend",
+ map[string]string{"topic": topicA}, nil, map[string]any{}, "")
+ if ext.Status != 200 || ext.Body["topic"] != topicA {
+ t.Fatalf("extend -> %d %v, want the echoed topic", ext.Status, ext.Body)
+ }
+ if got := wcNum(ext.Body["expiry"]); got != wcSessionExpiry {
+ t.Fatalf("extend expiry = %v, want the 7-day TTL %d", ext.Body["expiry"], wcSessionExpiry)
+ }
+ // The stored doc is untouched — and the value is a TTL constant, not the
+ // absolute unix timestamp real WC expiry uses. Asserted as-is; see the
+ // deviation report.
+ if got := wcNum(f.sessions(nil)[topicA]["expiry"]); got != wcSessionExpiry {
+ t.Fatalf("stored expiry after extend = %v, want the unchanged %d", got, wcSessionExpiry)
+ }
+ if r := f.call("on_extend_session", "POST", "/v1/sessions/dead00beef/extend",
+ map[string]string{"topic": "dead00beef"}, nil, map[string]any{}, ""); r.Status != 404 {
+ t.Fatalf("extend unknown topic -> %d, want 404", r.Status)
+ }
+
+ // ===== disconnect retires the topic and every later call 404s =====
+ del := f.call("on_disconnect_session", "DELETE", "/v1/sessions/"+topicA,
+ map[string]string{"topic": topicA}, nil, map[string]any{}, "")
+ if del.Status != 200 || del.Body["acknowledged"] != false {
+ t.Fatalf("disconnect -> %d %v, want acknowledged:false", del.Status, del.Body)
+ }
+ if msg, _ := del.Body["message"].(string); msg != "session disconnected" {
+ t.Fatalf("disconnect message = %q, want session disconnected", msg)
+ }
+ remaining := f.sessions(nil)
+ if _, gone := remaining[topicA]; gone {
+ t.Fatalf("session %s still listed after disconnect", topicA)
+ }
+ if _, kept := remaining[topicB]; !kept {
+ t.Fatalf("disconnect also removed the unrelated session %s", topicB)
+ }
+ for _, c := range []struct{ handler, method, suffix string }{
+ {"on_approve_session", "POST", "/approve"},
+ {"on_session_request", "POST", "/request"},
+ {"on_extend_session", "POST", "/extend"},
+ {"on_disconnect_session", "DELETE", ""},
+ } {
+ r := f.call(c.handler, c.method, "/v1/sessions/"+topicA+c.suffix,
+ map[string]string{"topic": topicA}, nil, map[string]any{}, "")
+ if r.Status != 404 || r.Body["error"] != "session_not_found" {
+ t.Fatalf("%s after disconnect -> %d %v, want 404 session_not_found", c.handler, r.Status, r.Body)
+ }
+ if msg, _ := r.Body["message"].(string); !strings.Contains(msg, topicA) {
+ t.Fatalf("%s after disconnect message = %q, want it to name the topic", c.handler, msg)
+ }
+ }
+}
diff --git a/conformance/matrix.json b/conformance/matrix.json
index 68a7b9c0..864f90a8 100644
--- a/conformance/matrix.json
+++ b/conformance/matrix.json
@@ -5,8 +5,8 @@
"tiers": {
"sdk_and_vm": 2,
"sdk_only": 34,
- "vm_only": 38,
- "boot": 24
+ "vm_only": 59,
+ "boot": 3
}
},
"adapters": [
@@ -724,9 +724,16 @@
"api_name": "Apple Push Notification service (APNs)",
"api_version": "v2",
"routes": 2,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "the provider token gate distinguishes missing invalid and expired tokens",
+ "a push to the known device returns 200 with a canonical uuid apns-id",
+ "unknown device tokens are 400 baddevicetoken",
+ "empty aps payloads are 400 payloadempty",
+ "sent notifications are retrievable per device"
+ ],
"missing": [
"No apns-topic, apns-push-type, apns-priority, or apns-collapse-id handling",
"No provider-certificate auth — only JWT provider tokens",
@@ -735,7 +742,8 @@
],
"deviations": [
"Provider JWT verified against one fixed P-256 key whose private half is published",
- "GET /3/device/{token}/notifications is a simulator-only endpoint (no real fetch API)"
+ "GET /3/device/{token}/notifications is a simulator-only endpoint (no real fetch API)",
+ "sent_at on the notifications endpoint is a constant; 410 Unregistered is modeled but unreachable"
],
"covered": [
{
@@ -8978,9 +8986,30 @@
"api_name": "Avalara AvaTax REST API",
"api_version": "2",
"routes": 8,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "every v2 endpoint demands a credential: a bare call is a 401 AuthenticationRequired envelope",
+ "any Bearer or any HTTP Basic credential opens the gate",
+ "a non-Basic/Non-Bearer scheme does not count as a credential",
+ "the effective rate keys off the address state (CA 0.095) with a State/County/City/Special breakdown",
+ "the summary aggregates the taxable base per jurisdiction",
+ "per-line tax rounds to cents: two lines aggregate, line 2 keeps its own tax",
+ "SDK decimal strings (\"100.00\") price identically to JSON numbers",
+ "the shipFrom/shipTo form keys off shipTo (NY 0.0875)",
+ "unknown or missing addresses fall back to the synthetic 0.0825 default",
+ "create prices the document, mints id/code/companyId and applies AvaTax defaults",
+ "an omitted date defaults to the clock's today, and advances with it",
+ "read round-trips by id; unknown ids are 404 NotFound",
+ "the list supports OData $filter and $orderBy with @recordsetCount",
+ "$top/$skip pages through an @odata.nextLink that round-trips",
+ "void flips status to Cancelled and the record reads back cancelled",
+ "re-void is idempotent",
+ "the companies catalog lists DEFAULT and STORE1 with default locations",
+ "nexus $filter literals are typed: id eq 1001 matches ints, hasNexus eq true matches bools",
+ "the taxcode catalog is filterable by taxCode"
+ ],
"missing": [
"No company CRUD — GET /v2/companies list only",
"No address validation (/v2/addresses/resolve)",
@@ -8989,7 +9018,9 @@
"No jurisdiction lookups — definitions serve nexuses and taxcodes only"
],
"deviations": [
- "Tax is a deterministic split — State 50%, County 25%, City 20%, Special 5% of the rate"
+ "Tax is a deterministic split — State 50%, County 25%, City 20%, Special 5% of the rate",
+ "Void returns a minimal {id, status} envelope; real AvaTax returns the full TransactionModel",
+ "Re-void is idempotent 200; real AvaTax rejects voiding a Cancelled document"
],
"covered": [
{
@@ -26867,9 +26898,17 @@
"api_name": "CloudKit Web Services API",
"api_version": "1",
"routes": 5,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "the s2s signature gate rejects unsigned tampered stale and foreign-key requests",
+ "users current returns the s2s owner identity",
+ "zones list seeds defaults filters by prefix and pages",
+ "records lookup returns the seeded shape with inline NOT_FOUND",
+ "records query filters sorts and pages on a numeric resultsLimit",
+ "records modify creates updates and deletes round-trip"
+ ],
"missing": [
"No private or shared database surfaces (/database/1/.../private|shared)",
"No assets: no /assets/upload endpoint or asset field handling",
@@ -28760,9 +28799,28 @@
"api_name": "Dropbox API",
"api_version": "2",
"routes": 8,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "upload takes the JSON {path, content} convenience body (documented deviation)",
+ "the real RPC upload (Dropbox-API-Arg header + raw octet-stream body) lands identically",
+ "re-uploading an existing path answers the real mode:\"add\" conflict",
+ "mode overwrite replaces in place; autorename forks a suffixed path",
+ "list_folder returns the whole path-prefix subtree, not one level",
+ "the root listing spans everything; unknown and file paths carry distinct 409 tags",
+ "paging slices the filtered subtree by body cursor, ignoring query strings",
+ "download streams raw bytes by path and by id, with metadata alongside",
+ "folders and unknown paths decline under the 409 path envelope",
+ "get_temporary_link pairs the file's metadata with the synthetic link",
+ "a presented bearer must be registered: unknown and expired tokens get distinct 401 tags",
+ "an absent Authorization header stays open (documented deviation)",
+ "create_folder mints folder metadata and conflicts case-insensitively",
+ "get_current_account returns the synthetic /2/users snapshot",
+ "deleting a folder removes its entire subtree from every read path",
+ "trash tombstones audit the exact cascade batch",
+ "delete is permanent: a re-created path is a brand-new entry"
+ ],
"missing": [
"No move or copy endpoints (/2/files/move_v2, /2/files/copy_v2)",
"No upload sessions for large files (/2/files/upload_session/*)",
@@ -29173,9 +29231,18 @@
"api_name": "Dune Analytics API",
"api_version": "v1",
"routes": 6,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "the api-key gate rejects missing, empty and non-bearer authorization",
+ "an execution walks PENDING -\u003e EXECUTING -\u003e COMPLETED as the clock advances",
+ "a missing required parameter is the 400 envelope and both SDK parameter shapes resolve",
+ "simulate_fail terminates QUERY_STATE_FAILED and results carry the failure envelope",
+ "the inline-result route completes synchronously",
+ "results pages honor limit/offset with a followable next_uri",
+ "the CSV variant streams text/csv for the same page"
+ ],
"missing": [
"No query management: GET/POST/PATCH/DELETE /api/v1/query endpoints",
"No execution cancel (POST /api/v1/execution/{id}/cancel)",
@@ -29186,7 +29253,8 @@
"deviations": [
"executions follow a fixed clock: EXECUTING at +1s, COMPLETED at +3s after execute",
"queries come from a static 3-entry catalog; no real SQL is executed",
- "simulate_fail body flag forces QUERY_STATE_FAILED; real API has no failure trigger"
+ "simulate_fail body flag forces QUERY_STATE_FAILED; real API has no failure trigger",
+ "Auth is a presence-only Bearer; real Dune uses the x-dune-api-key header"
],
"covered": [
{
@@ -100006,9 +100074,18 @@
"api_name": "ERC-4337 Bundler RPC",
"api_version": "0.7",
"routes": 2,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "supportedEntryPoints, chainId, and the JSON-RPC envelope",
+ "estimateUserOperationGas validates the full v0.7 field set",
+ "sendUserOperation answers a deterministic hash and defaults the entry point",
+ "the op walks mempool -\u003e bundled -\u003e included on the virtual clock",
+ "simulate_fail reverts on inclusion with the AA95 reason",
+ "the paymaster signs the op into paymasterAndData",
+ "missing or invalid userOps are 400s"
+ ],
"missing": [
"No eth_chainId, eth_blockNumber, or eth_getBlockByNumber passthrough",
"No ERC-7677 paymaster RPC (pm_* methods); only local /paymaster/sign",
@@ -100020,7 +100097,9 @@
"gas estimates are deterministic fixed values; real bundlers differ per implementation",
"inclusion runs on a fixed clock: mempool 0-1s, bundled 1-3s, included at \u003e=3s",
"eth_sendUserOperation accepts {simulate_fail:true} as a third params element",
- "mock paymaster POST /paymaster/sign mints synthetic sponsorship signatures"
+ "mock paymaster POST /paymaster/sign mints synthetic sponsorship signatures",
+ "userOp keeps the v0.6 paymasterAndData field though the adapter is EntryPoint-v0.7-only",
+ "Error envelopes use generic -32602 rather than AA-prefixed codes (-32500...)"
],
"covered": [
{
@@ -100291,16 +100370,25 @@
"api_name": "Etherscan API",
"api_version": "1.0",
"routes": 1,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "the apikey query parameter gates every module call",
+ "unknown modules and actions answer the NOTOK envelope over HTTP 200",
+ "balance reads the seeded ledger; unknown addresses default to \"0\"",
+ "txlist scopes by address then applies block filters, sort and paging",
+ "contract verification: ABI, source, and the unverified fallback",
+ "stats and token holders keep every number a decimal string"
+ ],
"missing": [
- "No txlistinternal, tokentx, or getLogs actions",
+ "No getLogs action (txlistinternal/tokentx are served, hard-coded empty)",
"No logs, proxy, or gastracker modules (gasoracle, eth_call passthrough)",
"No getminedblocks, getblocknobytime, or nodecount actions"
],
"deviations": [
- "Auth accepts any non-empty apikey; only a missing key yields the error envelope"
+ "Auth accepts any non-empty apikey; only a missing key yields the error envelope",
+ "txlistinternal/tokentx/tokenbalance return hard-coded empty results"
],
"covered": [
{
@@ -115679,9 +115767,28 @@
"api_name": "Jumio API",
"api_version": "v1",
"routes": 5,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "a missing, bare or wrong-scheme token is a 401 in the Jumio error envelope",
+ "a scan create answers PENDING with a synthetic decimal scan reference",
+ "sequential creates advance the reference sequence",
+ "a create without merchantScanReference is a 400",
+ "PENDING holds through the processing window then flips to DONE",
+ "a FAILED scan carries a real reject reason and its description",
+ "unknown scans are 404 on every parameterized route",
+ "extracted data is None while the scan is PENDING",
+ "DONE exposes the synthetic document extraction",
+ "FAILED scans answer data with a 409 repeating the reason",
+ "delete removes the scan and later reads are 404s",
+ "deleting after the terminal window still advances the lifecycle",
+ "a correctly MACed webhook body is accepted",
+ "a tampered body, wrong MAC or missing header is a 401",
+ "the terminal transition emits exactly one signed scan.completed",
+ "failed scans emit scan.failed carrying the rejection reason",
+ "a delete-driven terminal transition also emits, then nothing more"
+ ],
"missing": [
"No document or selfie image upload/retrieval endpoints",
"No hosted redirect verification flow; API scan creation only",
@@ -115691,7 +115798,9 @@
"Scan lifecycle on a fixed clock: PENDING ~3s then DONE or FAILED at +3s",
"simulate_fail and simulate_reject_reason create fields are stunt-only hooks",
"Webhook HMAC secret is the public constant stunt_jumio_mock_signing_key",
- "POST /netverify/v2/webhooks is a local stand-in receiver, not a Jumio endpoint"
+ "POST /netverify/v2/webhooks is a local stand-in receiver, not a Jumio endpoint",
+ "Bearer-presence gate; real Jumio uses HTTP Basic against a server-token store",
+ "Scan references are decimal groups, not UUIDs; extracted PII is fixed synthetic"
],
"covered": [
{
@@ -115779,9 +115888,54 @@
"api_name": "LinkedIn API",
"api_version": "v2",
"routes": 8,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "authorize without redirect_uri, state or client_id is invalid_request",
+ "authorize redirects back with a fresh code and the state echoed",
+ "a redirect_uri that already carries a query is joined with \u0026",
+ "the exchange demands grant_type=authorization_code",
+ "an unknown code is 400 invalid_grant",
+ "a good exchange mints a 60-day token pair for a fresh member",
+ "the code is single-use: a replay is invalid_grant",
+ "client mismatches are 400 invalid_client",
+ "a mismatched attempt must not burn the code",
+ "a second flow mints a distinct member",
+ "the refresh grant demands client creds",
+ "an unknown refresh token is invalid_grant",
+ "refresh rotates the pair and keeps the member",
+ "the presented refresh token is single-use",
+ "rotation chains: the new refresh token refreshes again",
+ "a missing bearer is 401 in the service error envelope",
+ "wrong schemes and unknown bearers answer the same 401",
+ "every API route enforces the same gate",
+ "a bearer dies at its clock-derived 60-day expiry",
+ "userinfo returns the OAuth member profile",
+ "publishing as anyone but the token's member is a 403",
+ "a good publish mints a ugcPost urn echoed in x-linkedin-id",
+ "the post resolves to a share urn carrying its own author",
+ "resolving an unknown urn is a 404",
+ "unconfigured, publishing is unthrottled",
+ "arming fail_after injects 429 REQUEST_LIMIT_EXCEEDED",
+ "the limit is per member",
+ "a throttled attempt creates no post",
+ "q must be author",
+ "reply resolves urn:li:person:me to the authenticated member",
+ "commenting as anyone but the caller is a 403",
+ "replying to an unknown object is a 404",
+ "ingest lists only the token member's comments",
+ "member B's comment resolved me and lists only under B",
+ "createdOn is clock-stamped and monotonic",
+ "count pages with a next link that round-trips the query",
+ "without count the whole list returns unpaged",
+ "a malformed start cursor is a 400",
+ "an unknown entity is a 404",
+ "each queryType totals base+3/5/7/11 split across two daily buckets",
+ "entity accepts both the parenthesized and bare urn forms",
+ "start past the data returns an empty page",
+ "an unknown queryType falls back to the base total (deviation, as-is)"
+ ],
"missing": [
"No ugcPosts delete or edit (DELETE /v2/ugcPosts/{id})",
"No organizations or company page API (GET /v2/organizations)",
@@ -115789,7 +115943,9 @@
"No video upload flow or multi-image carousel posts"
],
"deviations": [
- "Rate-limit injection on POST /v2/ugcPosts publish is a simulator test hook"
+ "Rate-limit injection on POST /v2/ugcPosts publish is a simulator test hook",
+ "An unknown analytics queryType silently falls back to the base total; real LinkedIn 400s",
+ "Refresh tokens never expire; only access tokens carry the 60-day expiry"
],
"covered": [
{
@@ -188352,16 +188508,35 @@
"api_name": "1inch Aggregation Protocol API",
"api_version": "v6.0",
"routes": 5,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "a quote returns token pairs, a decimal toAmount and a 100-point split",
+ "quotes are deterministic and address matching is case-insensitive",
+ "the toAmount scales linearly with the input amount",
+ "a same-token quote scales the amount by the pseudo-rate (as-is)",
+ "missing params and unknown tokens are 400 error envelopes",
+ "a swap returns router-addressed calldata with gas and gasPrice",
+ "the swap toAmount matches the quote for the same input",
+ "slippage is optional and ignored (as-is)",
+ "missing params and unknown tokens are 400s",
+ "the spender is the router contract address",
+ "approve calldata targets the token with the max allowance",
+ "a missing or unknown token is a 400",
+ "the token list is an address-keyed map of six tokens",
+ "every token in the list is quotable as a source"
+ ],
"missing": [
"No Fusion+ order flow (order quotes, submission, status, events)",
"No Limit Order Protocol endpoints (create, list, history)",
"No raw transaction broadcast, status check, or chains list endpoints"
],
"deviations": [
- "Quotes deterministic from src/dst/amount; same input yields same toAmount and split"
+ "Quotes deterministic from src/dst/amount; same input yields same toAmount and split",
+ "Quote field is toAmount; real v6.0 returns dstAmount",
+ "Same-token quotes return amount x pseudo-rate; real API rejects",
+ "slippage is optional and ignored on swap; real v6.0 requires it"
],
"covered": [
{
@@ -188427,9 +188602,18 @@
"api_name": "Onfido API",
"api_version": "v3.6",
"routes": 7,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "a missing or non-Token Authorization header is 401 authorization_error",
+ "applicant create flags exactly the blank names and reads back by id",
+ "document and live photo uploads bind to a real applicant and default side",
+ "check create demands report_names and a known applicant",
+ "the check completes from the clock and emits check.completed exactly once",
+ "simulate_fail completes with consider and consider breakdowns",
+ "the webhook receiver MACs the exact raw bytes"
+ ],
"missing": [
"No SDK token generation (POST /v3.6/sdk_token)",
"No applicant list, update, delete, or resume endpoints",
@@ -188441,7 +188625,9 @@
"Check lifecycle fixed: in_progress ~3s then complete; awaiting_applicant skipped",
"simulate_fail yields result consider; real sandbox uses special sandbox documents",
"Webhook HMAC secret is the public constant stunt_onfido_mock_signing_key",
- "POST /v3.6/webhooks is a local stand-in receiver, not an Onfido endpoint"
+ "POST /v3.6/webhooks is a local stand-in receiver, not an Onfido endpoint",
+ "Synthetic sequential ids (app-000001...) where real Onfido uses UUIDs",
+ "The awaiting_applicant phase is skipped; documents assumed on file"
],
"covered": [
{
@@ -188857,9 +189043,20 @@
"api_name": "OpenSea API",
"api_version": "2.0.0",
"routes": 7,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "the X-API-KEY gate 401s every surface with the V1ErrorWrapper envelope",
+ "the asset list seeds five mock-punks NFTs and filters by collection_slug",
+ "single-asset reads match the address case-insensitively and 404 unknown shapes",
+ "collections read back contracts and string-typed stats; unknown slugs 404",
+ "limit/next cursor pagination walks the pages and 400s a malformed cursor",
+ "events filter by collection_slug and event_type",
+ "listings carry the Seaport ask shape: the NFT in offer, payment in consideration",
+ "offers invert the shape: payment in offer, the NFT in consideration",
+ "created offers are stateful, deterministic, and defaulted"
+ ],
"missing": [
"No collections list endpoint (GET /api/v2/collections), only get-by-slug",
"No listing creation or order cancellation (offer creation only)",
@@ -188867,7 +189064,10 @@
"No NFT transfer history endpoint (chain/{chain}/transfers)"
],
"deviations": [
- "X-API-KEY accepted as any non-empty value; no real key validation"
+ "X-API-KEY accepted as any non-empty value; no real key validation",
+ "Asset routes serve the deprecated v2 surface; real v2 replaced them with collection-scoped nfts endpoints",
+ "Identical offers re-create and store duplicate orders (no order_hash dedupe)",
+ "Create-offer body and response are simulator-specific, not the documented criteria-offer shapes"
],
"covered": [
{
@@ -189870,9 +190070,29 @@
"api_name": "Persona Inquiry API",
"api_version": "2023-01-05",
"routes": 5,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "a missing, bare or wrong-scheme token is a 401 in the JSON:API error envelope",
+ "a create mints a zero-padded inq_ id in the JSON:API envelope",
+ "sequential creates advance the id sequence",
+ "any Bearer is accepted: the gate checks presence, not a store (as-is)",
+ "a create missing template_id or reference_id is a 400 invalid_request",
+ "the status derives from the clock created to pending to completed",
+ "resume restarts the clock at pending without duplicating verifications",
+ "simulate_fail declines at the terminal transition and seeds nothing",
+ "unknown inquiries are JSON:API 404s on every parameterized route",
+ "verifications are empty until the terminal transition fires",
+ "completion seeds the government-id and selfie verifications",
+ "a fresh correctly-signed webhook is accepted",
+ "a tampered body or wrong MAC is a 401 invalid_signature",
+ "a stale or far-future t is a 401 invalid_timestamp",
+ "a missing header or unparseable signature is a 401",
+ "polling through pending still emits exactly one inquiry.completed",
+ "re-reads and post-resume re-completions do not re-emit",
+ "a declined inquiry emits inquiry.declined signed the same way"
+ ],
"missing": [
"No inquiries list endpoint (GET /api/inquiry/v1/inquiries)",
"No inquiry templates or reports endpoints",
@@ -189884,7 +190104,9 @@
"Fixed clock lifecycle: created (0-1s), pending (1-3s), completed or declined (+3s)",
"simulate_fail create flag yields declined instead of completed (stunt-only)",
"Webhook HMAC secret is the public constant stunt_persona_mock_signing_key",
- "POST /api/inquiry/v1/webhooks is a local stand-in receiver with 5-minute replay window"
+ "POST /api/inquiry/v1/webhooks is a local stand-in receiver with 5-minute replay window",
+ "JSON:API attributes are snake_case; real Persona serializes kebab-case",
+ "Create takes a flat body; real API expects the JSON:API data/attributes wrapper"
],
"covered": [
{
@@ -191062,16 +191284,32 @@
"api_name": "Pinata API",
"api_version": "1.0",
"routes": 6,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "missing or half-present credentials are 401 with the error envelope",
+ "the API key pair and a Bearer JWT both open testAuthentication",
+ "pinJSONToIPFS pins content to a real CIDv0",
+ "re-pinning identical content is isDuplicate, not a new pin",
+ "pinFileToIPFS sizes and names the pin from the multipart parts",
+ "pinList filters by hash, size, status, and metadata name",
+ "pinStart/pinEnd bound the date-pinned window",
+ "pinByHash requires hash and matches the CID exactly",
+ "pinList pages at the real default of 10 rows",
+ "unpin removes the CID; a second unpin is 403 FORBIDDEN"
+ ],
"missing": [
"No pin-by-CID endpoint (POST /pinning/addHashToPinQueue)",
"No pin-jobs listing (GET /pinning/pinJobs)",
"No metadata update or pin-policy change (POST /pinning/hashMetadata)",
"No pinned-data-usage endpoint (GET /data/userPinnedDataTotal)"
],
- "deviations": [],
+ "deviations": [
+ "Credentials are presence-checked only; any non-empty key pair or Bearer JWT passes",
+ "Every stored pin is status pinned — unpinned/pending/failed not modeled (no job queue)",
+ "JSON pin CIDs derive from stunt canonical serialization, not byte-identical to real Pinata"
+ ],
"covered": [
{
"method": "POST",
@@ -191103,8 +191341,8 @@
"method": "POST",
"route": "/pinning/pinFileToIPFS",
"tags": [
- "body",
- "stateful"
+ "stateful",
+ "clock"
]
},
{
@@ -191112,7 +191350,8 @@
"route": "/pinning/pinJSONToIPFS",
"tags": [
"body",
- "stateful"
+ "stateful",
+ "clock"
]
},
{
@@ -194094,9 +194333,19 @@
"api_name": "Reddit API",
"api_version": "1.0",
"routes": 2,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "a missing or generic User-Agent is 429 on both routes",
+ "the token endpoint requires HTTP Basic client credentials",
+ "a permanent authorization_code mints access and refresh together",
+ "a refresh grant returns a fresh access token and no new refresh",
+ "submit requires a bearer the adapter itself minted",
+ "a valid submit returns the t3_ thing envelope",
+ "missing sr or title stay HTTP 200 with Reddit error triples",
+ "an access token dies after its one-hour window"
+ ],
"missing": [
"No listing endpoints (subreddit hot/new/top, thread comment pages)",
"No votes or comments (/api/vote, /api/comment)",
@@ -194105,7 +194354,9 @@
"No subreddit, search, or inbox/messages endpoints"
],
"deviations": [
- "User-Agent gate simplified: any UA containing / and ( passes, else 429"
+ "User-Agent gate simplified: any UA containing / and ( passes, else 429",
+ "The authorization_code grant never validates code or redirect_uri — any or missing code mints tokens",
+ "Post ids are plain sequence strings, not Reddit base36"
],
"covered": [
{
@@ -194351,9 +194602,19 @@
"api_name": "RevenueCat API",
"api_version": "v1",
"routes": 7,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "a missing or unknown key is a 401 {code, message} envelope",
+ "the public pk_ SDK key passes subscriber reads and receipt posts but is 401 on restricted writes",
+ "GET subscriber is get-or-create and answers in the v1 CustomerInfo envelope",
+ "receipt validation mirrors the real 400 order: app_user_id, platform, fetch_token, bad token",
+ "an ios receipt grants the pro entitlement with real trial math, and renewals stack",
+ "a google-play dict receipt feeds the product and lands in non_subscriptions",
+ "revoke lapses a live subscription; delete and the 404 envelopes",
+ "expiry is derived on read: a lapsed trial drops its entitlement"
+ ],
"missing": [
"No attribution endpoint (POST /v1/subscribers/{id}/attribution)",
"No offers API (promotional offer creation)",
@@ -194362,10 +194623,11 @@
"No v2 REST surface (customers, subscriptions, entitlements)"
],
"deviations": [
- "Webhook registration endpoint is simulator-only; real RC v1 webhooks are dashboard-configured",
"fetch_token prefixed with invalid is the deterministic bad-receipt 400 path",
"Subscription expiry is derive-on-read; first read past it fires EXPIRATION",
- "POST /v1/subscribers accepts _expires_at seeding to drive EXPIRATION in tests"
+ "POST /v1/subscribers accepts _expires_at seeding to drive EXPIRATION in tests",
+ "pk_ public keys are simulator convention; real RC public keys are appl_/goog_-prefixed",
+ "REST webhook registration does not exist in real RC v1 (dashboard-configured)"
],
"covered": [
{
@@ -194984,9 +195246,18 @@
"api_name": "Twilio SendGrid v3 API",
"api_version": "v3",
"routes": 5,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "the bearer gate rejects missing and unknown keys with SendGrid's grant envelope",
+ "mail send answers 202 with an empty body, an X-Message-Id, and flattened personalizations",
+ "the retrieval endpoint pages with limit and the opaque offset cursor",
+ "the delivery lifecycle derives processed -\u003e delivered (or dropped) on read, exactly once",
+ "the Email Activity query language narrows the list",
+ "event webhook settings round-trip and require a URL when enabled",
+ "deliveries are ECDSA P-256 signed over timestamp + raw body and fire once per recipient stage"
+ ],
"missing": [
"No single-message fetch (GET /v3/messages/{msg_id})",
"No GET of event-webhook settings (POST-only)",
@@ -195000,8 +195271,9 @@
"Webhook ECDSA signature is raw r||s (64 bytes), not Twilio ASN.1 DER encoding",
"simulate_fail: true in send body forces dropped terminal (simulator extension)",
"delivered derives at fixed +3s on first list read, not real async delivery",
- "Webhook deliveries are single stunt-enveloped objects; real SendGrid POSTs a JSON array",
- "Email Activity query subset: =, !=, CONTAINS terms AND-ed over six fields"
+ "Email Activity query subset: =, !=, CONTAINS terms AND-ed over six fields",
+ "Each delivery wraps one event in the transport envelope; real SendGrid batches a JSON array of events",
+ "asm, sandbox_mode, and batch_id are accepted but not modeled"
],
"covered": [
{
@@ -195155,6 +195427,7 @@
"body",
"auth",
"stateful",
+ "errors",
"clock"
]
},
@@ -196230,9 +196503,16 @@
"api_name": "Sign in with Apple",
"api_version": "v2",
"routes": 3,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "authorize redirects with a single-use code plus state and validates its params",
+ "the token exchange mints a real es256 id_token with apple claim shapes",
+ "the served jwks verifies the minted id_token signature",
+ "auth codes are single-use and client_secrets are verified cryptographically",
+ "the refresh grant rotates access tokens and rejects stale or foreign inputs"
+ ],
"missing": [
"No token revocation (POST /auth/revoke)",
"No user endpoint (GET /auth/user for name/email)",
@@ -203705,9 +203985,17 @@
"api_name": "Tenderly Simulation API",
"api_version": "v1",
"routes": 5,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "the access-key gate rejects missing and unknown bearers with the slug envelope",
+ "networks answer the bare array and switch to the paged envelope under perPage",
+ "a plain simulation round-trips the deterministic Tenderly shape",
+ "a value transfer emits the ERC-20 Transfer log and balance overrides",
+ "reverting simulations carry the ABI-encoded Error(string) output",
+ "bundles fan out per simulation and stored results list and retrieve by id"
+ ],
"missing": [
"No contracts endpoints (import/verify/update contracts per project)",
"No alerts or notification-rules API",
@@ -203715,7 +204003,9 @@
],
"deviations": [
"gas_used is derived from input length, not real EVM execution; status defaults to true",
- "An explicit revert:true body flag forces the revert path; real API has no such switch"
+ "An explicit revert:true body flag forces the revert path; real API has no such switch",
+ "Bearer auth where real Tenderly uses the X-Access-Key header",
+ "Responses mix camelCase where Tenderly is snake_case and nests simulation.id"
],
"covered": [
{
@@ -203803,16 +204093,25 @@
"api_version": "1.0",
"routes": 1,
"graphql": true,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "pools collection arguments sort by volume and join token0/token1",
+ "where filters map the graph-node suffix operators",
+ "validation failures and the first cap surface as GraphQL errors",
+ "domains join owner/resolvedAddress; lookups miss as null",
+ "_meta reports the deployment head; Token.pools joins in reverse",
+ "the REST SDL surface is public and rejects unknown bearer keys"
+ ],
"missing": [
"No subgraph deployment/management surface; the two seeded ids are fixed",
"No indexing-status queries beyond _meta (no /status endpoint)",
"No GraphQL subscriptions over websockets; HTTP queries only"
],
"deviations": [
- "One merged schema serves Uniswap and ENS entities; real Graph serves one per deployment"
+ "One merged schema serves Uniswap and ENS entities; real Graph serves one per deployment",
+ "_meta.block.number advances per query; the real head is block-height driven"
],
"covered": [
{
@@ -205793,9 +206092,22 @@
"api_name": "WalletConnect (Relay Protocol v2)",
"api_version": "2.0",
"routes": 7,
- "verification": "boot",
+ "verification": "VM",
"sdks": [],
"behaviors": [],
+ "vm_behaviors": [
+ "every route answers without a projectId (the gate is not wired)",
+ "a wc: URI pairing round-trips its topic, relay protocol, and symKey",
+ "an auto pairing mints a fresh topic and a 64-hex symKey",
+ "proposing requires pairingTopic — and accepts one never paired",
+ "approve acknowledges the session and derives eip155 namespaces",
+ "the session list is a bare array capped by limit",
+ "the approval gate is missing: an unacknowledged session still answers",
+ "wallet requests answer in a JSON-RPC 2.0 envelope with monotonic ids",
+ "signing and transaction methods return synthetic 0x-hex hashes",
+ "extend echoes the fixed session TTL without persisting anything",
+ "disconnect retires the topic and every later call 404s"
+ ],
"missing": [
"No WebSocket relay; the real IRN protocol is WSS JSON-RPC pub/sub, not HTTP REST",
"No session update, ping, or event emission (chainChanged/accountsChanged)",
@@ -205803,7 +206115,10 @@
],
"deviations": [
"Pairing, session approve, and JSON-RPC requests are auto-approved (no wallet device)",
- "personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed"
+ "personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed",
+ "The projectId gate ships but is never wired — every route answers without a credential",
+ "Expiry fields are TTL constants, not absolute unix timestamps; extend persists nothing",
+ "Unacknowledged sessions serve JSON-RPC requests immediately (no approval gate)"
],
"covered": [
{
diff --git a/conformance/matrix.yaml b/conformance/matrix.yaml
index b5036757..15e23896 100644
--- a/conformance/matrix.yaml
+++ b/conformance/matrix.yaml
@@ -49,6 +49,7 @@ adapters:
deviations:
- "Provider JWT verified against one fixed P-256 key whose private half is published"
- "GET /3/device/{token}/notifications is a simulator-only endpoint (no real fetch API)"
+ - "sent_at on the notifications endpoint is a constant; 410 Unregistered is modeled but unreachable"
missing:
- "No apns-topic, apns-push-type, apns-priority, or apns-collapse-id handling"
- "No provider-certificate auth — only JWT provider tokens"
@@ -117,6 +118,8 @@ adapters:
avalara-style:
deviations:
- "Tax is a deterministic split — State 50%, County 25%, City 20%, Special 5% of the rate"
+ - "Void returns a minimal {id, status} envelope; real AvaTax returns the full TransactionModel"
+ - "Re-void is idempotent 200; real AvaTax rejects voiding a Cancelled document"
missing:
- "No company CRUD — GET /v2/companies list only"
- "No address validation (/v2/addresses/resolve)"
@@ -345,6 +348,7 @@ adapters:
- "executions follow a fixed clock: EXECUTING at +1s, COMPLETED at +3s after execute"
- "queries come from a static 3-entry catalog; no real SQL is executed"
- "simulate_fail body flag forces QUERY_STATE_FAILED; real API has no failure trigger"
+ - "Auth is a presence-only Bearer; real Dune uses the x-dune-api-key header"
missing:
- "No query management: GET/POST/PATCH/DELETE /api/v1/query endpoints"
- "No execution cancel (POST /api/v1/execution/{id}/cancel)"
@@ -398,6 +402,8 @@ adapters:
- "inclusion runs on a fixed clock: mempool 0-1s, bundled 1-3s, included at >=3s"
- "eth_sendUserOperation accepts {simulate_fail:true} as a third params element"
- "mock paymaster POST /paymaster/sign mints synthetic sponsorship signatures"
+ - "userOp keeps the v0.6 paymasterAndData field though the adapter is EntryPoint-v0.7-only"
+ - "Error envelopes use generic -32602 rather than AA-prefixed codes (-32500...)"
missing:
- "No eth_chainId, eth_blockNumber, or eth_getBlockByNumber passthrough"
- "No ERC-7677 paymaster RPC (pm_* methods); only local /paymaster/sign"
@@ -429,8 +435,9 @@ adapters:
etherscan-style:
deviations:
- "Auth accepts any non-empty apikey; only a missing key yields the error envelope"
+ - "txlistinternal/tokentx/tokenbalance return hard-coded empty results"
missing:
- - "No txlistinternal, tokentx, or getLogs actions"
+ - "No getLogs action (txlistinternal/tokentx are served, hard-coded empty)"
- "No logs, proxy, or gastracker modules (gasoracle, eth_call passthrough)"
- "No getminedblocks, getblocknobytime, or nodecount actions"
fattureincloud-style:
@@ -631,6 +638,8 @@ adapters:
- "simulate_fail and simulate_reject_reason create fields are stunt-only hooks"
- "Webhook HMAC secret is the public constant stunt_jumio_mock_signing_key"
- "POST /netverify/v2/webhooks is a local stand-in receiver, not a Jumio endpoint"
+ - "Bearer-presence gate; real Jumio uses HTTP Basic against a server-token store"
+ - "Scan references are decimal groups, not UUIDs; extracted PII is fixed synthetic"
missing:
- "No document or selfie image upload/retrieval endpoints"
- "No hosted redirect verification flow; API scan creation only"
@@ -638,6 +647,8 @@ adapters:
linkedin-style:
deviations:
- "Rate-limit injection on POST /v2/ugcPosts publish is a simulator test hook"
+ - "An unknown analytics queryType silently falls back to the base total; real LinkedIn 400s"
+ - "Refresh tokens never expire; only access tokens carry the 60-day expiry"
missing:
- "No ugcPosts delete or edit (DELETE /v2/ugcPosts/{id})"
- "No organizations or company page API (GET /v2/organizations)"
@@ -694,6 +705,9 @@ adapters:
oneinch-style:
deviations:
- "Quotes deterministic from src/dst/amount; same input yields same toAmount and split"
+ - "Quote field is toAmount; real v6.0 returns dstAmount"
+ - "Same-token quotes return amount x pseudo-rate; real API rejects"
+ - "slippage is optional and ignored on swap; real v6.0 requires it"
missing:
- "No Fusion+ order flow (order quotes, submission, status, events)"
- "No Limit Order Protocol endpoints (create, list, history)"
@@ -704,6 +718,8 @@ adapters:
- "simulate_fail yields result consider; real sandbox uses special sandbox documents"
- "Webhook HMAC secret is the public constant stunt_onfido_mock_signing_key"
- "POST /v3.6/webhooks is a local stand-in receiver, not an Onfido endpoint"
+ - "Synthetic sequential ids (app-000001...) where real Onfido uses UUIDs"
+ - "The awaiting_applicant phase is skipped; documents assumed on file"
missing:
- "No SDK token generation (POST /v3.6/sdk_token)"
- "No applicant list, update, delete, or resume endpoints"
@@ -713,6 +729,9 @@ adapters:
opensea-style:
deviations:
- "X-API-KEY accepted as any non-empty value; no real key validation"
+ - "Asset routes serve the deprecated v2 surface; real v2 replaced them with collection-scoped nfts endpoints"
+ - "Identical offers re-create and store duplicate orders (no order_hash dedupe)"
+ - "Create-offer body and response are simulator-specific, not the documented criteria-offer shapes"
missing:
- "No collections list endpoint (GET /api/v2/collections), only get-by-slug"
- "No listing creation or order cancellation (offer creation only)"
@@ -741,6 +760,8 @@ adapters:
- "simulate_fail create flag yields declined instead of completed (stunt-only)"
- "Webhook HMAC secret is the public constant stunt_persona_mock_signing_key"
- "POST /api/inquiry/v1/webhooks is a local stand-in receiver with 5-minute replay window"
+ - "JSON:API attributes are snake_case; real Persona serializes kebab-case"
+ - "Create takes a flat body; real API expects the JSON:API data/attributes wrapper"
missing:
- "No inquiries list endpoint (GET /api/inquiry/v1/inquiries)"
- "No inquiry templates or reports endpoints"
@@ -761,7 +782,10 @@ adapters:
- "No shared albums (list, join, leave, unshare)"
- "No favorites marking or favorite filtering in search"
pinata-style:
- deviations: []
+ deviations:
+ - "Credentials are presence-checked only; any non-empty key pair or Bearer JWT passes"
+ - "Every stored pin is status pinned — unpinned/pending/failed not modeled (no job queue)"
+ - "JSON pin CIDs derive from stunt canonical serialization, not byte-identical to real Pinata"
missing:
- "No pin-by-CID endpoint (POST /pinning/addHashToPinQueue)"
- "No pin-jobs listing (GET /pinning/pinJobs)"
@@ -854,6 +878,8 @@ adapters:
reddit-style:
deviations:
- "User-Agent gate simplified: any UA containing / and ( passes, else 429"
+ - "The authorization_code grant never validates code or redirect_uri — any or missing code mints tokens"
+ - "Post ids are plain sequence strings, not Reddit base36"
missing:
- "No listing endpoints (subreddit hot/new/top, thread comment pages)"
- "No votes or comments (/api/vote, /api/comment)"
@@ -874,10 +900,11 @@ adapters:
- "No broadcasts or tags"
revenuecat-style:
deviations:
- - "Webhook registration endpoint is simulator-only; real RC v1 webhooks are dashboard-configured"
- "fetch_token prefixed with invalid is the deterministic bad-receipt 400 path"
- "Subscription expiry is derive-on-read; first read past it fires EXPIRATION"
- "POST /v1/subscribers accepts _expires_at seeding to drive EXPIRATION in tests"
+ - "pk_ public keys are simulator convention; real RC public keys are appl_/goog_-prefixed"
+ - "REST webhook registration does not exist in real RC v1 (dashboard-configured)"
missing:
- "No attribution endpoint (POST /v1/subscribers/{id}/attribution)"
- "No offers API (promotional offer creation)"
@@ -903,8 +930,9 @@ adapters:
- "Webhook ECDSA signature is raw r||s (64 bytes), not Twilio ASN.1 DER encoding"
- "simulate_fail: true in send body forces dropped terminal (simulator extension)"
- "delivered derives at fixed +3s on first list read, not real async delivery"
- - "Webhook deliveries are single stunt-enveloped objects; real SendGrid POSTs a JSON array"
- "Email Activity query subset: =, !=, CONTAINS terms AND-ed over six fields"
+ - "Each delivery wraps one event in the transport envelope; real SendGrid batches a JSON array of events"
+ - "asm, sandbox_mode, and batch_id are accepted but not modeled"
missing:
- "No single-message fetch (GET /v3/messages/{msg_id})"
- "No GET of event-webhook settings (POST-only)"
@@ -1008,6 +1036,8 @@ adapters:
deviations:
- "gas_used is derived from input length, not real EVM execution; status defaults to true"
- "An explicit revert:true body flag forces the revert path; real API has no such switch"
+ - "Bearer auth where real Tenderly uses the X-Access-Key header"
+ - "Responses mix camelCase where Tenderly is snake_case and nests simulation.id"
missing:
- "No contracts endpoints (import/verify/update contracts per project)"
- "No alerts or notification-rules API"
@@ -1015,6 +1045,7 @@ adapters:
thegraph-style:
deviations:
- "One merged schema serves Uniswap and ENS entities; real Graph serves one per deployment"
+ - "_meta.block.number advances per query; the real head is block-height driven"
missing:
- "No subgraph deployment/management surface; the two seeded ids are fixed"
- "No indexing-status queries beyond _meta (no /status endpoint)"
@@ -1062,6 +1093,9 @@ adapters:
deviations:
- "Pairing, session approve, and JSON-RPC requests are auto-approved (no wallet device)"
- "personal_sign and eth_sendTransaction return synthetic hashes; nothing is signed"
+ - "The projectId gate ships but is never wired — every route answers without a credential"
+ - "Expiry fields are TTL constants, not absolute unix timestamps; extend persists nothing"
+ - "Unacknowledged sessions serve JSON-RPC requests immediately (no approval gate)"
missing:
- "No WebSocket relay; the real IRN protocol is WSS JSON-RPC pub/sub, not HTTP REST"
- "No session update, ping, or event emission (chainChanged/accountsChanged)"
diff --git a/internal/engine/sendgrid_style_test.go b/internal/engine/sendgrid_style_test.go
index 1d15188f..987f6b61 100644
--- a/internal/engine/sendgrid_style_test.go
+++ b/internal/engine/sendgrid_style_test.go
@@ -272,5 +272,6 @@ func sgPostJSON(t *testing.T, rawurl, auth string, body map[string]any) *http.Re
if err != nil {
t.Fatal(err)
}
+ defer resp.Body.Close()
return resp
}
diff --git a/internal/engine/signin_with_apple_style_test.go b/internal/engine/signin_with_apple_style_test.go
index aa03dfcb..05cfd9b5 100644
--- a/internal/engine/signin_with_apple_style_test.go
+++ b/internal/engine/signin_with_apple_style_test.go
@@ -400,6 +400,7 @@ func TestSignInWithAppleStyleAdapter(t *testing.T) {
respMal := siwaGetNoRedirect(t, base+"/auth/authorize?"+
url.Values{"client_id": {"com.test"}, "redirect_uri": {"http://localhost/cb"},
"response_type": {"code"}, "scope": {"email"}, "state": {"s-mal"}}.Encode())
+ defer respMal.Body.Close()
codeMal := siwaExtractParam(respMal.Header.Get("Location"), "code")
malBody, malStatus := siwaPostForm(t, base+"/auth/token", url.Values{
"grant_type": {"authorization_code"},