From d77e1fa47c2ebc5480c1c78efb779f6dd866c433 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Tue, 18 Aug 2026 07:43:33 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20SDK=20conformance=20wave=203=20?= =?UTF-8?q?=E2=80=94=20Node=20SDKs=20via=20bun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New suites under conformance/node/ (just conformance-node; bun added to CI) that boot the REAL stunt binary — stunt up, runtime file, probe-bound ports — so they double as end-to-end tests of the CLI: - stripe-node: typed creates, autoPagingEach walking has_more, PI create+confirm, webhook verification through constructEventAsync (signature verifies, data.object parses, tamper rejected). - octokit: issue CRUD and octokit.paginate following the adapter's Link headers page by page. - twilio-node: lifecycle driven by SDK fetches (queued->sent/ delivered), the +15005550001 magic number -> failed, and callbacks verified by twilio-node's own validateRequest. Findings: - REAL BUG: github-style's Link pagination headers hardcoded https://api.github.com absolute URLs — clients that FOLLOW the header (octokit.paginate) were sent to the real GitHub and 401'd; go-github survived only because it parses page params without following. Link targets now build from the serving host (req["host"]), like a real GitHub Enterprise. - just hazard: conformance-node used /tmp/stunt-ci, which never writes — only the lint recipes do — so the recipe could run a stale binary from an old lint run (that's exactly what produced a session-long ECONNRESET mystery: the stale pre-v0.45 binary still had the form-parser panic). The recipe builds fresh now. - Known-environment quirk documented in the suite: under bun, stripe-node's fetch layer drops the url form param when it names a live same-process listener (reproduced with a fresh client against an echo server, independent of stunt; plain bun fetch does not do it). Registration tries the SDK first and falls back to a raw one-shot POST; everything else is SDK-driven. 24 expects across 3 suites, green twice consecutively; Go conformance + full main-module suite green. --- .github/workflows/ci.yml | 6 + CHANGELOG.md | 32 ++++ README.md | 3 +- adapters/github-style/scripts/lib.star | 9 +- conformance/node/.gitignore | 1 + conformance/node/bun.lock | 247 +++++++++++++++++++++++++ conformance/node/helpers.ts | 123 ++++++++++++ conformance/node/package.json | 13 ++ conformance/node/tests/github.test.ts | 64 +++++++ conformance/node/tests/stripe.test.ts | 139 ++++++++++++++ conformance/node/tests/twilio.test.ts | 132 +++++++++++++ justfile | 13 ++ 12 files changed, 780 insertions(+), 2 deletions(-) create mode 100644 conformance/node/.gitignore create mode 100644 conformance/node/bun.lock create mode 100644 conformance/node/helpers.ts create mode 100644 conformance/node/package.json create mode 100644 conformance/node/tests/github.test.ts create mode 100644 conformance/node/tests/stripe.test.ts create mode 100644 conformance/node/tests/twilio.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bfb2436..db54603b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,3 +52,9 @@ jobs: - name: SDK conformance (real provider SDKs vs adapters) run: just conformance + + - name: Set up bun (Node SDK conformance) + uses: oven-sh/setup-bun@v2 + + - name: Node SDK conformance (stripe-node, octokit, twilio-node) + run: just conformance-node diff --git a/CHANGELOG.md b/CHANGELOG.md index 766211f9..744c61e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,38 @@ All notable changes to **stunt** are documented here. The format is based on ## [Unreleased] +## [0.49.0] — 2026-08-18 + +### Testing + +- **SDK conformance wave 3 — the Node SDKs, via bun.** New suites under + `conformance/node/` (`just conformance-node`; bun added to CI) that + boot the **real `stunt` binary** — `stunt up`, runtime file, ports — + so they double as end-to-end tests of the CLI itself: + - **stripe-node** — typed creates, `autoPagingEach` walking + `has_more`, PaymentIntent confirm, and webhook verification through + the SDK's `constructEventAsync` HMAC validator (signature verifies, + `data.object` parses, tampered payloads rejected). + - **octokit** — issue CRUD and `octokit.paginate` following the + adapter's `Link` headers page by page. + - **twilio-node** — the message lifecycle driven by SDK fetches + (`queued → sent/delivered`), the `+15005550001` magic number → + `failed`, and status callbacks verified by twilio-node's own + `validateRequest`. + +### Adapters + +- **github-style's `Link` pagination headers pointed at the real + `api.github.com`** — hardcoded absolute URLs. Clients that follow the + header (`octokit.paginate`) were sent to the real GitHub and 401'd; + go-github survived only because it parses the page params without + following the URL. Link targets are now built from the serving host + (`req["host"]`), which is also what a real GitHub Enterprise does. +- Known-environment note (documented in the suite): under bun, + stripe-node's fetch layer intermittently drops the `url` form param + when it names a live same-process listener — the webhook registration + in the Node suite goes via a raw POST; everything else is SDK-driven. + ## [0.48.0] — 2026-08-17 ### Testing diff --git a/README.md b/README.md index 4b847d29..900f565a 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,8 @@ passes an adversarial input-safety sweep (garbage params, null/malformed bodies, cursor/limit param names — never a 5xx), coverage-guided fuzzing of the engine's parsers and dispatch (`just fuzz`), and conformance suites that drive **real provider SDKs** — stripe-go, aws-sdk-go-v2, go-github, twilio-go, go-shopify, google-api-go-client — end-to-end against -the adapters (`just conformance`). Highlights: +the adapters (`just conformance`), plus Node suites driving **stripe-node, octokit, and +twilio-node** through the real `stunt` binary (`just conformance-node`). Highlights: | Adapter | Simulates | Backing | |---|---|---| diff --git a/adapters/github-style/scripts/lib.star b/adapters/github-style/scripts/lib.star index d4b6f695..57b7bb19 100644 --- a/adapters/github-style/scripts/lib.star +++ b/adapters/github-style/scripts/lib.star @@ -425,7 +425,14 @@ def _list_page(req, docs): next_link = None if next_cursor != None: next_page = _to_int(next_cursor) // per_page + 1 - base = "https://api.github.com" + req.get("path", "") + # The Link target must point at THIS server: clients that follow + # the header (octokit.paginate) would be sent to the real + # api.github.com otherwise. req["host"] is the serving host. + host = req.get("host", "") + if host == None or host == "": + host = "api.github.com" + scheme = "http" if host.startswith("127.0.0.1") or host.startswith("localhost") else "https" + base = scheme + "://" + host + req.get("path", "") next_link = "<" + base + "?per_page=" + str(per_page) + "&page=" + str(next_page) + '>; rel="next"' return page_docs, next_link diff --git a/conformance/node/.gitignore b/conformance/node/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/conformance/node/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/conformance/node/bun.lock b/conformance/node/bun.lock new file mode 100644 index 00000000..d89fd470 --- /dev/null +++ b/conformance/node/bun.lock @@ -0,0 +1,247 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "stunt-node-conformance", + "dependencies": { + "octokit": "^5.0.0", + "stripe": "^19.0.0", + "twilio": "^5.0.0", + }, + }, + }, + "packages": { + "@octokit/app": ["@octokit/app@16.1.4", "", { "dependencies": { "@octokit/auth-app": "^8.3.0", "@octokit/auth-unauthenticated": "^7.0.4", "@octokit/core": "^7.0.7", "@octokit/oauth-app": "^8.0.4", "@octokit/plugin-paginate-rest": "^15.0.0", "@octokit/types": "^17.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-g70WONQyGoBgqIJtv4O0MUEfOF1iJpTfFt3qAW7HaiwiJ6k607xg/gBezCa4tuq6BpO6qXn7qvq8v9tjWtgGQg=="], + + "@octokit/auth-app": ["@octokit/auth-app@8.3.0", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.4", "@octokit/auth-oauth-user": "^6.0.3", "@octokit/request": "^10.0.13", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-/UaKmJCsOc5XBZwhnFiGNdLH/FkDF8lYtBn1QlKxtX7IpgaRB/XOXjixFtERAknyUZHxp3oDuoiG0En4VptJSg=="], + + "@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.4", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.4", "@octokit/auth-oauth-user": "^6.0.3", "@octokit/request": "^10.0.13", "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Pe3du5LrC6dlv10b0RbarBlJtIT1Urq8BFoQklK8WmBw8YKnGgrANn7cQmHiA1v8AVihgi7YutV8MncP57I4og=="], + + "@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.4", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.3", "@octokit/request": "^10.0.13", "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-M/+34rmkxMvUnnTo/uqNeVRCXJoJ+5N34eNTbL1KMtIyJpedCTsu/iFL1cWdL+w5hQMlYt6xzXoyux2gn0OVnw=="], + + "@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.3", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.4", "@octokit/oauth-methods": "^6.0.3", "@octokit/request": "^10.0.13", "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-t4OKUhrI5britmpRiSzPAz1TJPyUN/Hw1HEE3Hz/uElxcmnf/YCRSKtFRNc5gy4yJFTlXgk34H1lvxiotQJQvQ=="], + + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/auth-unauthenticated": ["@octokit/auth-unauthenticated@7.0.4", "", { "dependencies": { "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0" } }, "sha512-j4zgVdP8C8C53PNsj4LLI+WFtoykaQARwwrIBILTdxwZ2Ljaa9YUxb1yQ7/seGVnmWPdC3OBefX3LLbh28gKtw=="], + + "@octokit/core": ["@octokit/core@7.0.7", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.4", "@octokit/request": "^10.0.13", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA=="], + + "@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.4", "", { "dependencies": { "@octokit/request": "^10.0.13", "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg=="], + + "@octokit/oauth-app": ["@octokit/oauth-app@8.0.4", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.4", "@octokit/auth-oauth-user": "^6.0.3", "@octokit/auth-unauthenticated": "^7.0.4", "@octokit/core": "^7.0.7", "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/oauth-methods": "^6.0.3", "@types/aws-lambda": "^8.10.83", "universal-user-agent": "^7.0.0" } }, "sha512-Ji5JpRwAJcbOJkO4ij0tW6+GrQ8efpICnAca3o5xI8/HUNjqJu3hhG4cCZdXAHMKaOaQTFX0Yl+cpu61Rsx94A=="], + + "@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="], + + "@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.4", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.13", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0" } }, "sha512-96RsnxS7Hk/BQhUA1Qo2pmcYP6LWQRWMdo7bVbNE7ZCkFLhSUYBXVUj9tVnvhl4jzbwHYt/dcIG+ioY427b2sg=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/openapi-webhooks-types": ["@octokit/openapi-webhooks-types@12.1.0", "", {}, "sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA=="], + + "@octokit/plugin-paginate-graphql": ["@octokit/plugin-paginate-graphql@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-crfpnIoFiBtRkvPqOyLOsw12XsveYuY2ieP6uYDosoUegBJpSVxGwut9sxUgFFcll3VTOTqpUf8yGd8x1OmAkQ=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/plugin-retry": ["@octokit/plugin-retry@8.1.1", "", { "dependencies": { "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=7" } }, "sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg=="], + + "@octokit/plugin-throttling": ["@octokit/plugin-throttling@11.0.5", "", { "dependencies": { "@octokit/types": "^17.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": "^7.0.0" } }, "sha512-LIdrkrUv+DWbKeg/49rGuFJ3SU0d3hUS+B4MhNZLepBoNUFXms8Ic9edJjrlx+zycqJHjrMRudVpVb/bAXM2Lw=="], + + "@octokit/request": ["@octokit/request@10.0.14", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.12", "universal-user-agent": "^7.0.2" } }, "sha512-bgWgiSfFS689/AxQDarU+b3Qu1FYewfVr5vI/jhV84s7GQ3C2OsdRxukGGYKB37MCgwcS50UrfBLlHzhiG13sw=="], + + "@octokit/request-error": ["@octokit/request-error@7.1.1", "", { "dependencies": { "@octokit/types": "^17.0.0" } }, "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA=="], + + "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/webhooks": ["@octokit/webhooks@14.2.0", "", { "dependencies": { "@octokit/openapi-webhooks-types": "12.1.0", "@octokit/request-error": "^7.0.0", "@octokit/webhooks-methods": "^6.0.0" } }, "sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw=="], + + "@octokit/webhooks-methods": ["@octokit/webhooks-methods@6.0.0", "", {}, "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ=="], + + "@types/aws-lambda": ["@types/aws-lambda@8.10.162", "", {}, "sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw=="], + + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.19.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw=="], + + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + + "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "json-with-bigint": ["json-with-bigint@3.5.12", "", {}, "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "octokit": ["octokit@5.0.5", "", { "dependencies": { "@octokit/app": "^16.1.2", "@octokit/core": "^7.0.6", "@octokit/oauth-app": "^8.0.3", "@octokit/plugin-paginate-graphql": "^6.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0", "@octokit/plugin-retry": "^8.0.3", "@octokit/plugin-throttling": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw=="], + + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "stripe": ["stripe@19.3.1", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-5NXhLxTZ+4uO1wnsmNysILVuyeZ1Xia7niz/8ykBkGJkCcrY2WyQZwcfYuWZmZEJtWr2+0j49JXwNC6y9CHL7Q=="], + + "toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="], + + "twilio": ["twilio@5.13.1", "", { "dependencies": { "axios": "^1.13.5", "dayjs": "^1.11.9", "https-proxy-agent": "^5.0.0", "jsonwebtoken": "^9.0.3", "qs": "^6.14.1", "scmp": "^2.1.0", "xmlbuilder": "^13.0.2" } }, "sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ=="], + + "universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + + "xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="], + + "@octokit/app/@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@15.0.0", "", { "dependencies": { "@octokit/types": "^17.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-lw9A9YL5s4VPj+VEx8uMxaxQm2YTgrPDkrLEuZuu18R8TK3oPcXF4K6t52nx6xn1RcogZC9i188sAr/4XYJaQQ=="], + + "@octokit/app/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/auth-app/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/auth-oauth-app/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/auth-oauth-device/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/auth-oauth-user/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/auth-unauthenticated/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/core/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/endpoint/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/graphql/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/oauth-methods/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/plugin-retry/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/plugin-throttling/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/request/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/request-error/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="], + + "@octokit/app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/auth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/auth-oauth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/auth-oauth-device/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/auth-oauth-user/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/auth-unauthenticated/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/endpoint/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/graphql/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/oauth-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/plugin-retry/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/plugin-throttling/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + + "@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@28.0.0", "", {}, "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ=="], + } +} diff --git a/conformance/node/helpers.ts b/conformance/node/helpers.ts new file mode 100644 index 00000000..79c57325 --- /dev/null +++ b/conformance/node/helpers.ts @@ -0,0 +1,123 @@ +// Node-side conformance harness: boots the REAL stunt binary (`stunt up`) +// against a generated manifest, waits for the runtime file, and exposes the +// service base URL plus a webhook sink. This doubles as an end-to-end test +// of the CLI itself — the Go suites use the engine directly; these suites +// go through the actual product entry point. +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +export type Delivery = { url: string; body: string; headers: Record }; + +export type Harness = { + base: string; + sinkUrl: string; + deliveries: () => Delivery[]; + waitFor: (pred: (d: Delivery) => boolean, ms?: number) => Promise; + stop: () => Promise; +}; + +const STUNT_BIN = process.env.STUNT_BIN ?? "stunt"; +const repoRoot = resolve(import.meta.dir, "..", ".."); + +export async function bootAdapter(adapter: string): Promise { + // The sink records every delivery — body, headers, and URL (the + // signature formulas MAC the URL too). + const deliveries: Delivery[] = []; + const sink = Bun.serve({ + port: 0, + async fetch(req) { + const u = new URL(req.url); + const headers: Record = {}; + req.headers.forEach((v, k) => (headers[k] = v)); + deliveries.push({ + url: `http://${req.headers.get("host")}${u.pathname}${u.search}`, + body: req.method === "POST" ? await req.text() : "", + headers, + }); + return new Response(null, { status: 200 }); + }, + }); + + const dir = mkdtempSync(join(tmpdir(), "stunt-node-conf-")); + // Port mode requires a concrete base_port (0 is rejected by manifest + // validation — the Go suites use the engine directly and bypass it). + // Probe-bind a free one to avoid collisions. + const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const basePort = probe.port; + probe.stop(true); + writeFileSync( + join(dir, "stunt.yaml"), + `version: 1 +network: + mode: port + base_port: ${basePort} +services: + svc: + adapter: ${join(repoRoot, "adapters", adapter)} + config: + webhook_url: ${sink.url} +`, + ); + + const proc = Bun.spawn([STUNT_BIN, "up", "--manifest", join(dir, "stunt.yaml")], { + stdout: "ignore", + stderr: "ignore", + }); + + // Poll the runtime file for the assigned address. + const runtimePath = join(dir, ".stunt", "runtime", "up.json"); + const deadline = Date.now() + 20_000; + let base = ""; + while (Date.now() < deadline) { + if (existsSync(runtimePath)) { + try { + const rt = JSON.parse(readFileSync(runtimePath, "utf8")); + if (rt.addresses?.length > 0) { + base = rt.addresses[0]; + break; + } + } catch {} + } + await Bun.sleep(100); + } + if (!base) { + proc.kill(); + throw new Error(`stunt up did not write ${runtimePath} within 20s`); + } + + // Readiness: any HTTP answer means the listener is live. + for (let i = 0; i < 100; i++) { + try { + await fetch(base + "/"); + break; + } catch { + await Bun.sleep(100); + } + } + + return { + base, + sinkUrl: sink.url, + deliveries: () => [...deliveries], + async waitFor(pred, ms = 10_000) { + const end = Date.now() + ms; + while (Date.now() < end) { + const hit = deliveries.find(pred); + if (hit) return hit; + await Bun.sleep(100); + } + throw new Error(`no matching delivery arrived within ${ms}ms (${deliveries.length} seen)`); + }, + async stop() { + proc.kill(); + await proc.exited; + sink.stop(true); + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +export function expect(cond: unknown, msg: string): asserts cond { + if (!cond) throw new Error(msg); +} diff --git a/conformance/node/package.json b/conformance/node/package.json new file mode 100644 index 00000000..70ceff06 --- /dev/null +++ b/conformance/node/package.json @@ -0,0 +1,13 @@ +{ + "name": "stunt-node-conformance", + "private": true, + "description": "SDK conformance suites driving official Node SDKs against booted stunt adapters (run via `just conformance-node`).", + "scripts": { + "test": "bun test" + }, + "dependencies": { + "stripe": "^19.0.0", + "octokit": "^5.0.0", + "twilio": "^5.0.0" + } +} diff --git a/conformance/node/tests/github.test.ts b/conformance/node/tests/github.test.ts new file mode 100644 index 00000000..eb54fcac --- /dev/null +++ b/conformance/node/tests/github.test.ts @@ -0,0 +1,64 @@ +// octokit conformance: baseUrl override + the seeded PAT, issue creates, +// and octokit.paginate walking the Link headers. +import { describe, expect, test } from "bun:test"; +import { Octokit } from "octokit"; +import { bootAdapter } from "../helpers"; + +describe("octokit against github-style", () => { + test( + "issue CRUD + paginate over Link headers", + async () => { + const h = await bootAdapter("github-style"); + try { + const octokit = new Octokit({ + auth: "ghp_pat_token_mock", + baseUrl: h.base, + }); + + const OWNER = "octocat", + REPO = "hello-world"; + + for (let i = 1; i <= 5; i++) { + const { data: issue } = await octokit.rest.issues.create({ + owner: OWNER, + repo: REPO, + title: `node-conformance ${i}`, + body: "filed by octokit against stunt", + }); + expect(issue.number).toBeGreaterThan(0); + } + + // paginate() follows the Link header through every page. + const issues = await octokit.paginate(octokit.rest.issues.listForRepo, { + owner: OWNER, + repo: REPO, + per_page: 2, + }); + const ours = issues.filter((i) => String(i.title).startsWith("node-conformance")); + expect(ours.length).toBe(5); + expect(issues.length).toBeGreaterThanOrEqual(6); // + the seeded issue + + // Comment + state transition through the SDK. + const target = ours[0]; + const { data: cmt } = await octokit.rest.issues.createComment({ + owner: OWNER, + repo: REPO, + issue_number: target.number, + body: "octokit comment", + }); + expect(cmt.body).toBe("octokit comment"); + + const { data: closed } = await octokit.rest.issues.update({ + owner: OWNER, + repo: REPO, + issue_number: target.number, + state: "closed", + }); + expect(closed.state).toBe("closed"); + } finally { + await h.stop(); + } + }, + { timeout: 60_000 }, + ); +}); diff --git a/conformance/node/tests/stripe.test.ts b/conformance/node/tests/stripe.test.ts new file mode 100644 index 00000000..9813cfb4 --- /dev/null +++ b/conformance/node/tests/stripe.test.ts @@ -0,0 +1,139 @@ +// stripe-node conformance: the seam proven by dogfooding (host/port/ +// protocol — the SDK rejects a full apiBase key), typed creates, +// autoPagingEach walking has_more, and webhooks.constructEvent verifying +// the delivery HMAC + parsing data.object. +import { describe, expect, test } from "bun:test"; +import Stripe from "stripe"; +import { request } from "node:http"; +import { bootAdapter } from "../helpers"; + +// rawPost performs a one-off form POST on a fresh connection +// (agent:false) — independent of any client library's connection state. +function rawPost(urlStr: string, body: string): Promise { + return new Promise((resolve, reject) => { + const u = new URL(urlStr); + const req = request( + { hostname: u.hostname, port: u.port, path: u.pathname, method: "POST", agent: false }, + (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode ?? 0)); + }, + ); + req.on("error", reject); + req.setHeader("content-type", "application/x-www-form-urlencoded"); + req.setHeader("authorization", "Bearer sk_test_node_conformance"); + req.end(body); + }); +} + +const SECRET = "whsec_stunt_mock_0123456789abcdef0123456789abcdef"; + +describe("stripe-node against stripe-style", () => { + test( + "full client + webhook lifecycle", + async () => { + const h = await bootAdapter("stripe-style"); + try { + const u = new URL(h.base); + const stripe = new Stripe("sk_test_node_conformance", { + host: u.hostname, + port: Number(u.port), + protocol: "http", + telemetry: false, + maxNetworkRetries: 0, + }); + + // ===== Typed create/get (form+bracket encoded by the SDK) ===== + const cus = await stripe.customers.create({ + name: "Grace Hopper", + email: "grace@synth.example", + metadata: { source: "stripe-node-conformance" }, + }); + expect(cus.object).toBe("customer"); + expect(cus.name).toBe("Grace Hopper"); + const got = await stripe.customers.retrieve(cus.id); + expect((got as Stripe.Customer).email).toBe("grace@synth.example"); + + // ===== autoPagingEach walks has_more pages ===== + for (let i = 0; i < 3; i++) { + await stripe.customers.create({ name: `paging ${i}` }); + } + let seen = 0; + await stripe.customers.list({ limit: 2 }).autoPagingEach(() => { + seen++; + if (seen > 10) return false; // safety valve + }); + expect(seen).toBeGreaterThanOrEqual(4); + + // ===== Webhook registration, delivery verified by the SDK's own + // constructEvent ===== + // NOTE: registration goes via a raw POST rather than + // stripe.webhookEndpoints.create — under bun, stripe-node's fetch + // layer intermittently drops the `url` form param when it names a + // live same-process listener (the sink), sending only the other + // params; reproduced with a fresh client against an echo server, + // independent of stunt (plain bun fetch does not do this, and the + // SDK's own encode step has the param). Everything after + // registration — the trigger, the delivery, the verification — + // stays SDK-driven. + // With a fresh binary the SDK path may work — try it, fall back + // to the raw POST if the url param vanishes again. + let registeredViaSDK = false; + try { + const viaSDK = await stripe.webhookEndpoints.create({ + url: h.sinkUrl, + enabled_events: ["payment_intent.succeeded"], + }); + registeredViaSDK = viaSDK.id != null && viaSDK.url === h.sinkUrl; + } catch { + registeredViaSDK = false; + } + if (!registeredViaSDK) { + const regStatus = await rawPost( + h.base + "/v1/webhook_endpoints", + new URLSearchParams({ + url: h.sinkUrl, + "enabled_events[0]": "payment_intent.succeeded", + }).toString(), + ); + expect(regStatus).toBeGreaterThanOrEqual(200); + expect(regStatus).toBeLessThan(300); + } + + const pm = await stripe.paymentMethods.create({ + type: "card", + card: { token: "tok_visa" }, + }); + const pi = await stripe.paymentIntents.create({ + amount: 1234, + currency: "usd", + payment_method: pm.id, + confirm: true, + }); + expect(pi.status).toBe("succeeded"); + + const d = await h.waitFor((x) => x.body.includes("payment_intent.succeeded")); + const event = await stripe.webhooks.constructEventAsync( + d.body, + d.headers["stripe-signature"], + SECRET, + ); + expect(event.type).toBe("payment_intent.succeeded"); + expect((event.data.object as Record).status).toBe("succeeded"); + + // A tampered payload must FAIL the same verifier. + const tampered = d.body.replace("succeeded", "tampered!"); + let threw = false; + try { + await stripe.webhooks.constructEventAsync(tampered, d.headers["stripe-signature"], SECRET); + } catch { + threw = true; + } + expect(threw).toBe(true); + } finally { + await h.stop(); + } + }, + { timeout: 60_000 }, + ); +}); diff --git a/conformance/node/tests/twilio.test.ts b/conformance/node/tests/twilio.test.ts new file mode 100644 index 00000000..31e17b82 --- /dev/null +++ b/conformance/node/tests/twilio.test.ts @@ -0,0 +1,132 @@ +// twilio-node conformance: a custom httpClient (the SDK's documented +// injection point) rewrites scheme+host to the adapter — Twilio signs no +// request URLs, so this is sound. Lifecycle via SDK fetches, and callback +// verification through twilio-node's own validateRequest. +import { describe, expect, test } from "bun:test"; +import twilio from "twilio"; +import { bootAdapter, type Harness } from "../helpers"; + +const SID = "AC0123456789abcdef0123456789abcdef".replace("AC0", "AC" + "0"); +const AUTH_TOKEN = "feed0000face1111beef2222cafe3333"; + +// FetchBackedClient implements the RequestClient contract +// ({statusCode, body, headers}) against the stunt base. +class FetchBackedClient { + constructor(private base: string) {} + async request(opts: Record) { + const u = new URL(opts.uri); + const url = new URL(u.pathname + u.search, this.base); + if (opts.params) { + for (const [k, v] of Object.entries(opts.params)) url.searchParams.set(k, String(v)); + } + const headers: Record = {}; + for (const [k, v] of Object.entries(opts.headers ?? {})) if (v != null) headers[k] = String(v); + if (opts.username && opts.password) { + headers.Authorization = + "Basic " + Buffer.from(`${opts.username}:${opts.password}`).toString("base64"); + } + let body: string | undefined; + const method = String(opts.method ?? "get").toUpperCase(); + if (opts.data && headers["Content-Type"] === "application/x-www-form-urlencoded") { + body = new URLSearchParams( + Object.entries(opts.data).map(([k, v]) => [k, String(v)]), + ).toString(); + } else if (opts.data != null) { + body = JSON.stringify(opts.data); + } + const res = await fetch(url, { method, headers, body }); + const text = await res.text(); + let parsed: unknown = text; + try { + parsed = JSON.parse(text); + } catch {} + const outHeaders: Record = {}; + res.headers.forEach((v, k) => (outHeaders[k] = v)); + return { statusCode: res.status, body: parsed, headers: outHeaders }; + } +} + +async function pollStatus(h: Harness, sid: string, want: string[]): Promise { + const deadline = Date.now() + 10_000; + let status = ""; + while (Date.now() < deadline) { + const res = await fetch(`${h.base}/2010-04-01/Accounts/${SID}/Messages/${sid}.json`, { + headers: { Authorization: "Basic " + Buffer.from(`${SID}:${AUTH_TOKEN}`).toString("base64") }, + }); + const msg = (await res.json()) as Record; + if (want.includes(msg.status)) { + status = msg.status; + break; + } + await Bun.sleep(300); + } + return status; +} + +describe("twilio-node against twilio-style", () => { + test( + "lifecycle + signed callbacks via validateRequest", + async () => { + const h = await bootAdapter("twilio-style"); + try { + const client = twilio(SID, AUTH_TOKEN, { + httpClient: new FetchBackedClient(h.base) as any, + lazyLoading: false, + }); + + // ===== Create + SDK fetches drive the lifecycle ===== + const msg = await client.api.v2010.accounts(SID).messages.create({ + to: "+15550002222", + from: "+15550001111", + body: "node conformance hello", + }); + expect(msg.sid).toMatch(/^SM/); + + // Poll via the SDK until terminal (drives derive-on-read + // transitions, which fire the callbacks). + const deadline = Date.now() + 10_000; + let status = ""; + while (Date.now() < deadline) { + const m = await client.api.v2010.accounts(SID).messages(msg.sid).fetch(); + if (["delivered", "sent", "failed"].includes(m.status)) { + status = m.status; + break; + } + await Bun.sleep(300); + } + expect(["sent", "delivered"]).toContain(status); + + // ===== Callbacks verified by twilio-node's own validateRequest ===== + const first = await h.waitFor((d) => d.headers["x-twilio-signature"] != null); + const params = Object.fromEntries(new URLSearchParams(first.body)); + const url = first.url; + const ok = twilio.validateRequest(AUTH_TOKEN, first.headers["x-twilio-signature"], url, params); + expect(ok).toBe(true); + + // Every callback that arrives must verify. + await h.waitFor((d) => d !== first && d.headers["x-twilio-signature"] != null, 8_000).catch( + () => null, + ); + for (const d of h.deliveries()) { + if (!d.headers["x-twilio-signature"]) continue; + const p = Object.fromEntries(new URLSearchParams(d.body)); + expect(twilio.validateRequest(AUTH_TOKEN, d.headers["x-twilio-signature"], d.url, p)).toBe( + true, + ); + } + + // ===== The magic invalid number -> failed ===== + const bad = await client.api.v2010.accounts(SID).messages.create({ + to: "+15005550001", + from: "+15550001111", + body: "should fail", + }); + const badStatus = await pollStatus(h, bad.sid, ["failed", "sent"]); + expect(badStatus).toBe("failed"); + } finally { + await h.stop(); + } + }, + { timeout: 90_000 }, + ); +}); diff --git a/justfile b/justfile index ab439f05..0466bbaf 100644 --- a/justfile +++ b/justfile @@ -92,6 +92,19 @@ test: conformance: cd conformance && go test ./... -count=1 -race -v +# Node SDK conformance: stripe-node, octokit, twilio-node via bun, +# booting the REAL stunt binary (also end-to-end tests the CLI). Requires +# bun on PATH; builds the binary to /tmp first. +conformance-node: + #!/bin/sh + set -e + command -v bun >/dev/null || { echo "bun not found on PATH"; exit 1; } + # Build fresh — `just build` produces no binary, and /tmp/stunt-ci may + # hold a stale artifact from an old lint run. + go build -ldflags "{{ldflags}}" -o /tmp/stunt-ci ./cmd/stunt + cd conformance/node && bun install --frozen-lockfile 2>/dev/null || bun install + STUNT_BIN=/tmp/stunt-ci bun test + # Coverage-guided fuzzing — each target for the given time (default 30s; # pass just fuzz 2m for longer rounds). The fuzz seed corpora also run as # regular tests in `just test`, so discovered inputs stay pinned forever. From 40b806892db7b382d4381808b041524ee221b287 Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Tue, 18 Aug 2026 08:03:03 +0300 Subject: [PATCH 2/2] fix(review): Link scheme/filter fidelity, harness hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #66, all fixed: - Link scheme: handlers now read X-Forwarded-Proto (the subdomain proxy sets it) with the loopback-host inference only as the port-mode fallback — the heuristic alone was wrong for no-TLS subdomain mode. Same fix applied to shopify's page_info URL (hardcoded https) and dune's next_uri (hardcoded http). - github Link round-trips every filter/query param except page — a Link-following client no longer loses state=closed&q= between pages. A missing host now omits the Link instead of redirecting to the real api.github.com. - justfile: bun install --frozen-lockfile fails loudly (the silent fallback could silently resolve newer semver in CI). - harness: stunt-up stderr is captured and included in boot failures (was a bare 20s timeout); failure paths stop the sink and remove the tmpdir; readiness fetches carry a 500ms abort and a hard failure after the loop (was silent proceed); the port probe stays bound until just before spawn. - suites: stripe paging asserts distinct ids with no valve trip (catches both has_more=false and ignored starting_after); the registration fallback warns when it fires; twilio drives to delivered and REQUIRES the second callback (a dropped unsigned terminal callback now fails instead of slipping through); timeouts bumped to 120s for CI headroom. 27 expects green twice consecutively; Go conformance + engine suites on all three touched adapters green. --- adapters/dune-style/scripts/lib.star | 5 ++++- adapters/github-style/scripts/lib.star | 23 ++++++++++++++++---- adapters/shopify-style/scripts/lib.star | 8 ++++++- conformance/node/helpers.ts | 29 +++++++++++++++++++------ conformance/node/tests/github.test.ts | 2 +- conformance/node/tests/stripe.test.ts | 23 +++++++++++++++----- conformance/node/tests/twilio.test.ts | 16 ++++++++------ justfile | 2 +- 8 files changed, 80 insertions(+), 28 deletions(-) diff --git a/adapters/dune-style/scripts/lib.star b/adapters/dune-style/scripts/lib.star index b77d6b44..74f145ff 100644 --- a/adapters/dune-style/scripts/lib.star +++ b/adapters/dune-style/scripts/lib.star @@ -288,7 +288,10 @@ def _next_uri(req, exec_id, offset, limit): host = req.get("host", "") if host == None: host = "" - return ("http://" + host + "/api/v1/execution/" + exec_id + 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" + return (scheme + "://" + host + "/api/v1/execution/" + exec_id + "/results?offset=" + str(offset) + "&limit=" + str(limit)) # _pad2 zero-pads to 2 digits. diff --git a/adapters/github-style/scripts/lib.star b/adapters/github-style/scripts/lib.star index 57b7bb19..8d6dc8a9 100644 --- a/adapters/github-style/scripts/lib.star +++ b/adapters/github-style/scripts/lib.star @@ -427,13 +427,28 @@ def _list_page(req, docs): next_page = _to_int(next_cursor) // per_page + 1 # The Link target must point at THIS server: clients that follow # the header (octokit.paginate) would be sent to the real - # api.github.com otherwise. req["host"] is the serving host. + # api.github.com otherwise. The subdomain proxy sets + # X-Forwarded-Proto; port mode is plain http on loopback. With no + # host at all, omit the Link rather than redirect to production. host = req.get("host", "") if host == None or host == "": - host = "api.github.com" - scheme = "http" if host.startswith("127.0.0.1") or host.startswith("localhost") else "https" + return page_docs, None + 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 "https" base = scheme + "://" + host + req.get("path", "") - next_link = "<" + base + "?per_page=" + str(per_page) + "&page=" + str(next_page) + '>; rel="next"' + # Round-trip every filter/query param except page — a Link-following + # client must not lose state=closed&q=... between pages. + keep = [] + q = req.get("query", {}) + if q != None: + for k in q: + if k == "page": + continue + keep.append(k + "=" + q[k]) + keep.append("per_page=" + str(per_page)) + keep.append("page=" + str(next_page)) + next_link = "<" + base + "?" + "&".join(keep) + '>; rel="next"' return page_docs, next_link # _gh_link_headers returns a headers dict carrying the Link rel="next" value, diff --git a/adapters/shopify-style/scripts/lib.star b/adapters/shopify-style/scripts/lib.star index 3ec381a2..2838698e 100644 --- a/adapters/shopify-style/scripts/lib.star +++ b/adapters/shopify-style/scripts/lib.star @@ -328,7 +328,13 @@ def _list_page(req, docs): def _next_link(req, next_cursor, limit): if next_cursor == None: return None - scheme = "https" + # The proxy sets X-Forwarded-Proto; port mode is plain http on + # loopback. Clients that FOLLOW the page_info URL (not just parse the + # token) need the real scheme. + scheme = req.get("headers", {}).get("x-forwarded-proto", "") + if scheme == None or scheme == "": + host0 = req.get("host", "") or "" + scheme = "http" if host0.startswith("127.0.0.1") or host0.startswith("localhost") else "https" host = req.get("host", "") if host == None: host = "" diff --git a/conformance/node/helpers.ts b/conformance/node/helpers.ts index 79c57325..bad8fae3 100644 --- a/conformance/node/helpers.ts +++ b/conformance/node/helpers.ts @@ -42,10 +42,10 @@ export async function bootAdapter(adapter: string): Promise { const dir = mkdtempSync(join(tmpdir(), "stunt-node-conf-")); // Port mode requires a concrete base_port (0 is rejected by manifest // validation — the Go suites use the engine directly and bypass it). - // Probe-bind a free one to avoid collisions. + // Probe-bind a free one; the socket stays open until just before the + // spawn to keep the race window small. const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); const basePort = probe.port; - probe.stop(true); writeFileSync( join(dir, "stunt.yaml"), `version: 1 @@ -60,10 +60,24 @@ services: `, ); + const errBuf: string[] = []; const proc = Bun.spawn([STUNT_BIN, "up", "--manifest", join(dir, "stunt.yaml")], { stdout: "ignore", - stderr: "ignore", + stderr: "pipe", }); + (async () => { + const t = await new Response(proc.stderr).text(); + errBuf.push(t); + })(); + probe.stop(true); + + const cleanupFail = (msg: string): never => { + proc.kill(); + sink.stop(true); + rmSync(dir, { recursive: true, force: true }); + const tail = errBuf.join("").trim().split("\n").slice(-6).join("\n"); + throw new Error(tail ? `${msg}\nstunt up stderr tail:\n${tail}` : msg); + }; // Poll the runtime file for the assigned address. const runtimePath = join(dir, ".stunt", "runtime", "up.json"); @@ -82,16 +96,17 @@ services: await Bun.sleep(100); } if (!base) { - proc.kill(); - throw new Error(`stunt up did not write ${runtimePath} within 20s`); + cleanupFail(`stunt up did not write ${runtimePath} within 20s`); } - // Readiness: any HTTP answer means the listener is live. + // Readiness: any HTTP answer means the listener is live. Bounded per + // attempt; a boot that never answers is a failure, not silence. for (let i = 0; i < 100; i++) { try { - await fetch(base + "/"); + await fetch(base + "/", { signal: AbortSignal.timeout(500) }); break; } catch { + if (i === 99) cleanupFail(`adapter never answered on ${base}`); await Bun.sleep(100); } } diff --git a/conformance/node/tests/github.test.ts b/conformance/node/tests/github.test.ts index eb54fcac..185fcb79 100644 --- a/conformance/node/tests/github.test.ts +++ b/conformance/node/tests/github.test.ts @@ -59,6 +59,6 @@ describe("octokit against github-style", () => { await h.stop(); } }, - { timeout: 60_000 }, + { timeout: 120_000 }, ); }); diff --git a/conformance/node/tests/stripe.test.ts b/conformance/node/tests/stripe.test.ts index 9813cfb4..3f7187e0 100644 --- a/conformance/node/tests/stripe.test.ts +++ b/conformance/node/tests/stripe.test.ts @@ -58,12 +58,22 @@ describe("stripe-node against stripe-style", () => { for (let i = 0; i < 3; i++) { await stripe.customers.create({ name: `paging ${i}` }); } - let seen = 0; - await stripe.customers.list({ limit: 2 }).autoPagingEach(() => { - seen++; - if (seen > 10) return false; // safety valve + // Distinct ids pin BOTH failure modes: has_more always false + // (stops early) and starting_after ignored (duplicates forever). + const ids: string[] = []; + let valved = false; + await stripe.customers.list({ limit: 2 }).autoPagingEach((c: { id: string }) => { + ids.push(c.id); + if (ids.length > 10) { + valved = true; + return false; + } }); - expect(seen).toBeGreaterThanOrEqual(4); + // The engine seeds a couple of customers, so the exact count is + // not fixed — no-duplicates + no-valve pins both failure modes. + expect(valved).toBe(false); + expect(ids.length).toBeGreaterThanOrEqual(4); + expect(new Set(ids).size).toBe(ids.length); // ===== Webhook registration, delivery verified by the SDK's own // constructEvent ===== @@ -89,6 +99,7 @@ describe("stripe-node against stripe-style", () => { registeredViaSDK = false; } if (!registeredViaSDK) { + console.warn("webhookEndpoints.create fell back to raw POST (bun/stripe-node url-param quirk?)"); const regStatus = await rawPost( h.base + "/v1/webhook_endpoints", new URLSearchParams({ @@ -134,6 +145,6 @@ describe("stripe-node against stripe-style", () => { await h.stop(); } }, - { timeout: 60_000 }, + { timeout: 120_000 }, ); }); diff --git a/conformance/node/tests/twilio.test.ts b/conformance/node/tests/twilio.test.ts index 31e17b82..ea1cced7 100644 --- a/conformance/node/tests/twilio.test.ts +++ b/conformance/node/tests/twilio.test.ts @@ -86,15 +86,17 @@ describe("twilio-node against twilio-style", () => { // transitions, which fire the callbacks). const deadline = Date.now() + 10_000; let status = ""; + // Drive to DELIVERED (not just sent): the delivered callback only + // fires on the read that derives the second transition. while (Date.now() < deadline) { const m = await client.api.v2010.accounts(SID).messages(msg.sid).fetch(); - if (["delivered", "sent", "failed"].includes(m.status)) { + if (["delivered", "failed"].includes(m.status)) { status = m.status; break; } await Bun.sleep(300); } - expect(["sent", "delivered"]).toContain(status); + expect(status).toBe("delivered"); // ===== Callbacks verified by twilio-node's own validateRequest ===== const first = await h.waitFor((d) => d.headers["x-twilio-signature"] != null); @@ -103,10 +105,10 @@ describe("twilio-node against twilio-style", () => { const ok = twilio.validateRequest(AUTH_TOKEN, first.headers["x-twilio-signature"], url, params); expect(ok).toBe(true); - // Every callback that arrives must verify. - await h.waitFor((d) => d !== first && d.headers["x-twilio-signature"] != null, 8_000).catch( - () => null, - ); + // The terminal (delivered) callback MUST arrive — the loop only + // verifies what's present, so a dropped unsigned second callback + // must fail here, not slip through. + await h.waitFor((d) => d !== first && d.headers["x-twilio-signature"] != null, 8_000); for (const d of h.deliveries()) { if (!d.headers["x-twilio-signature"]) continue; const p = Object.fromEntries(new URLSearchParams(d.body)); @@ -127,6 +129,6 @@ describe("twilio-node against twilio-style", () => { await h.stop(); } }, - { timeout: 90_000 }, + { timeout: 120_000 }, ); }); diff --git a/justfile b/justfile index 0466bbaf..437da9f5 100644 --- a/justfile +++ b/justfile @@ -102,7 +102,7 @@ conformance-node: # Build fresh — `just build` produces no binary, and /tmp/stunt-ci may # hold a stale artifact from an old lint run. go build -ldflags "{{ldflags}}" -o /tmp/stunt-ci ./cmd/stunt - cd conformance/node && bun install --frozen-lockfile 2>/dev/null || bun install + cd conformance/node && bun install --frozen-lockfile STUNT_BIN=/tmp/stunt-ci bun test # Coverage-guided fuzzing — each target for the given time (default 30s;