From 9373ac2719600d0e159b22557733a4c75def8744 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:30:41 +0900 Subject: [PATCH 01/22] test(billing): specify provider transport boundary --- package.json | 4 +- tests/unit/billing-provider-boundary.test.mjs | 116 ++++++++++++++++++ tests/unit/coverage-script-contract.test.mjs | 5 + 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/unit/billing-provider-boundary.test.mjs diff --git a/package.json b/package.json index c44a9576..86542958 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs new file mode 100644 index 00000000..df1aed81 --- /dev/null +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -0,0 +1,116 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createCheckout } from '../../server/billing.mjs'; + +const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example.com' }; + +async function withStripeEnv(run) { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_SECRET_KEY = 'sk_test_provider_boundary'; + process.env.STRIPE_PRICE_ID = 'price_provider_boundary'; + try { + await run(); + } finally { + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +} + +async function expectProviderError(run, expectedCode) { + let rejectedError; + await assert.rejects(run, (error) => { + rejectedError = error; + assert.equal(error.status, 502); + assert.equal(typeof error.getResponse, 'function'); + return true; + }); + const response = rejectedError.getResponse(); + assert.equal(response.status, 502); + assert.equal(response.headers.get('cache-control'), 'no-store'); + const payload = await response.json(); + assert.equal(payload.error, expectedCode); + assert.equal(typeof payload.action, 'string'); + assert.ok(payload.action.length > 0); + return JSON.stringify(payload); +} + +test('live Checkout uses a bounded single-attempt Stripe request', async () => { + await withStripeEnv(async () => { + const observed = []; + const stripeClientFactory = async (secretKey, clientOptions) => { + observed.push({ secretKey, clientOptions }); + return { + checkout: { + sessions: { + async create(payload, requestOptions) { + observed.push({ payload, requestOptions }); + return { url: 'https://checkout.stripe.com/c/pay/cs_test_boundary' }; + }, + }, + }, + }; + }; + + const result = await createCheckout({ + orgId: 73, + configuration: liveConfiguration, + stripeClientFactory, + }); + + assert.equal(result.url, 'https://checkout.stripe.com/c/pay/cs_test_boundary'); + assert.deepEqual(observed[0], { + secretKey: 'sk_test_provider_boundary', + clientOptions: { maxNetworkRetries: 0, timeout: 15000 }, + }); + assert.deepEqual(observed[1].requestOptions, { maxNetworkRetries: 0, timeout: 15000 }); + }); +}); + +test('live Checkout rejects malformed or untrusted provider destinations', async () => { + const invalidUrls = [ + null, + '', + 'not a URL', + 'http://checkout.stripe.com/c/pay/cs_test_plaintext', + 'https://user:pass@checkout.stripe.com/c/pay/cs_test_credentials', + 'https://checkout.stripe.com.evil.example/c/pay/cs_test_suffix', + 'https://checkout.stripe.com:444/c/pay/cs_test_port', + 'https://checkout.stripe.com/c/pay/cs_test_fragment#credential', + ]; + + await withStripeEnv(async () => { + for (const url of invalidUrls) { + const stripeClientFactory = async () => ({ + checkout: { sessions: { async create() { return { url }; } } }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration, stripeClientFactory }), + 'billing_provider_invalid_response', + ); + } + }); +}); + +test('provider failures become a stable sanitized buyer-facing error', async () => { + await withStripeEnv(async () => { + const stripeClientFactory = async () => ({ + checkout: { + sessions: { + async create() { + throw new Error('dial tcp 10.7.0.12:443 with sk_live_should_not_escape'); + }, + }, + }, + }); + + const payload = await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration, stripeClientFactory }), + 'billing_provider_unavailable', + ); + assert.doesNotMatch(payload, /10\.7\.0\.12|sk_live_should_not_escape/); + }); +}); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..c5b58fa1 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -39,6 +39,11 @@ assert.match( /tests\/unit\/clearfolio-status-signal\.test\.mjs/, 'the Clearfolio signal and HTTP failure regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/billing-provider-boundary\.test\.mjs/, + 'the Stripe provider trust and transport regression executes under c8', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, From 4bdfd1bed6f85da0937369826dbfd9c12f845e6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:36:30 +0900 Subject: [PATCH 02/22] fix(billing): enforce Checkout provider boundary --- CHANGELOG.md | 5 + docs/billing-production.md | 80 ++++++++++--- .../stripe-checkout-provider-boundary.md | 94 +++++++++++++++ server/billing.mjs | 111 ++++++++++++++---- 4 files changed, 255 insertions(+), 35 deletions(-) create mode 100644 docs/doctoring/stripe-checkout-provider-boundary.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd6a4e2..01e00081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Bounded hosted Stripe Checkout provider calls to one 15-second, no-retry + attempt until durable idempotency exists; validated returned destinations as + exact HTTPS `checkout.stripe.com` URLs without credentials, fragments, or + non-standard ports; and mapped provider failures to sanitized no-store 502 + responses. - Bound Stripe Checkout success/cancel redirects to an operator-configured canonical public origin instead of request authority, rejected partial or ambiguous billing configuration at startup, and confined successful mock diff --git a/docs/billing-production.md b/docs/billing-production.md index d5c8abb6..04cd9ab7 100644 --- a/docs/billing-production.md +++ b/docs/billing-production.md @@ -50,17 +50,57 @@ For local integration tests, `SCOPEWEAVE_DEV=1` plus a valid loopback the configured origin and a percent-encoded organization identifier; a different request host cannot replace that origin. -## Current slice boundary - -This document describes only the trusted-configuration and redirect-authority -slice of issue #488. It does **not** declare the Stripe lifecycle production -complete. Before production billing can be release-approved, ScopeWeave still -needs the remaining #488 controls, including durable checkout attempts and stable -idempotency keys, a packaged/pinned provider SDK and bounded provider transport, -validated returned Checkout destinations, raw-body webhook verification and -size limits, durable event deduplication, out-of-order reconciliation, normalized -subscription/payment/entitlement state, rollback/recovery procedures, and -end-to-end operational acceptance evidence. +## Provider trust boundary + +> Active PR state: this section describes the stacked provider-boundary work in +> PR #507. It is not protected-`develop` shipped truth until its parent PR #505 +> and this PR are independently approved and integrated. + +The live hosted-Checkout adapter applies one bounded provider attempt until a +later lifecycle slice introduces durable checkout-attempt and idempotency state: + +- Stripe client and request timeout: 15,000 ms; +- automatic network retries: `0`; +- provider exceptions and provider-import failures: stable HTTP 502 + `billing_provider_unavailable` with `Cache-Control: no-store`; +- malformed or untrusted provider destinations: stable HTTP 502 + `billing_provider_invalid_response` with `Cache-Control: no-store`; +- browser destination: parsed with `URL` and accepted only for HTTPS, exact + hostname `checkout.stripe.com`, default HTTPS port, no URL credentials, and no + fragment. + +This exact-host check deliberately rejects suffix-confusion names such as +`checkout.stripe.com.evil.example`. Stripe Checkout custom domains are not +silently trusted. Supporting one requires a separate operator-owned allowlist or +canonical-domain configuration contract and its own regression evidence. + +Provider exception text, network addresses, and credentials are never copied +into the browser error payload. The customer receives a retry/diagnostic next +action rather than downstream internals. + +The provider-boundary slice does **not** by itself make the Stripe SDK available +on a clean deployment. A pinned official Stripe package plus its exact lockfile +remains a release gate unless it is incorporated into the same reviewed stack +before the billing lifecycle is enabled. Missing provider code therefore fails +through the same sanitized 502 boundary; it is never treated as checkout +success. + +## Current lifecycle boundary + +The trusted-configuration and provider-trust slices do **not** declare the Stripe +subscription lifecycle production complete. Before production billing can be +release-approved, ScopeWeave still needs the remaining #488 controls, including: + +a durable checkout-attempt UUID and stable idempotency key; a packaged and pinned +official Stripe SDK; raw-body webhook signature verification and streaming size +limits; durable event deduplication; out-of-order reconciliation; normalized +customer/subscription/payment/entitlement state; transactional reversible +entitlement changes; migration and restore evidence; privacy/incident runbooks; +and provider smoke plus release acceptance. + +No automatic provider retry should be enabled before durable idempotency exists. +No custom Checkout domain should be accepted before an operator-owned trust +configuration exists. ## Operator verification @@ -73,10 +113,20 @@ Before a billing-enabled rollout: 3. Send a checkout request through the same reverse proxy used in production while varying the request authority; success/cancel URLs must still use only `SCOPEWEAVE_PUBLIC_ORIGIN`. -4. Keep the rollout blocked until the remaining #488 lifecycle controls are +4. Exercise provider timeout/failure handling and confirm callers receive only + the stable no-store 502 contract without network or credential detail. +5. Reject null, malformed, plaintext, credential-bearing, non-standard-port, + fragmented, and hostname-confusion Checkout destinations; accept the standard + active `https://checkout.stripe.com/...` hosted destination. +6. Verify the official Stripe SDK is packaged and lockfile-pinned before enabling + live billing in a clean deployment. +7. Keep the rollout blocked until the remaining #488 lifecycle controls are implemented and their exact-head security, coverage, review, rollback, and recovery gates pass together. -Rollback for this slice is configuration-neutral: revert the validation module, -checkout authority change, and tests together. No database migration or -persisted billing state is introduced here. +Rollback for the trusted-configuration/provider-boundary stack is data-neutral: +revert the validation and provider-boundary source, tests, documentation, and +CHANGELOG entries together. No database migration or persisted billing state is +introduced by these slices. If billing must be disabled while investigating a +provider outage, remove the complete live provider tuple and restart; never +substitute a production mock. diff --git a/docs/doctoring/stripe-checkout-provider-boundary.md b/docs/doctoring/stripe-checkout-provider-boundary.md new file mode 100644 index 00000000..f39ea33d --- /dev/null +++ b/docs/doctoring/stripe-checkout-provider-boundary.md @@ -0,0 +1,94 @@ +# Stripe Checkout provider trust boundary + +Status: **active stacked PR design/implementation evidence; not protected-`develop` +shipped truth until its parent and this slice are independently approved and +integrated.** + +## Buyer-visible failure being closed + +The trusted-origin billing slice prevents request authority from controlling +success/cancel redirects, but a provider boundary also has to constrain how long +Checkout can block a request, how retries can duplicate a side effect, what +provider-returned URL the browser may follow, and what failure detail can leave +the server. + +Without those controls a buyer cannot distinguish a controlled billing outage +from an indefinite provider wait, and a compromised or malformed provider +response could become redirect authority. + +## Decision + +For hosted Stripe Checkout, this slice applies four narrow controls: + +1. Stripe client construction and `checkout.sessions.create` receive a 15,000 ms + timeout. +2. Automatic network retries are set to zero until ScopeWeave has durable + checkout-attempt identifiers and idempotency state. A later lifecycle slice + may introduce retries only together with that durable reconciliation model. +3. The returned Checkout Session `url` is parsed with the platform `URL` parser + and accepted only when it uses HTTPS, has exact hostname + `checkout.stripe.com`, uses the default HTTPS port, and contains no URL + credentials or fragment. +4. Provider transport/import failures and invalid provider responses become + stable, `Cache-Control: no-store` HTTP 502 responses. Internal network text, + provider exception detail, and credentials are not reflected to callers. + +The validation uses exact parsed authority fields rather than string-prefix or +suffix matching, so `checkout.stripe.com.evil.example` is not trusted. + +## Compatibility boundary + +Stripe documents hosted Checkout Session URLs as nullable and present only while +the Session is active. Without a configured Checkout custom domain, Stripe uses +`checkout.stripe.com`; configured custom domains use the merchant's subdomain. +ScopeWeave does **not** silently trust arbitrary custom domains in this slice. +Supporting one requires a future operator-owned allowlist/configuration contract +and tests proving the configured authority cannot be replaced by provider or +request input. + +This slice does not add durable checkout attempts, webhook verification, +subscription/payment/entitlement persistence, or reconciliation. It also does +not by itself make the live SDK installable on a clean deployment; the official +Stripe package/lockfile remains a separate packaging gate unless incorporated by +an exact lockfile change before this PR leaves Draft. + +## TDD evidence + +Test-only commit `9373ac2719600d0e159b22557733a4c75def8744` added the provider +contract before production changes. Reproducing that exact parent billing source +under Node.js 22.16.0 failed as expected because `checkout.sessions.create` +received no request options: expected `{ maxNetworkRetries: 0, timeout: 15000 }`, +actual `undefined`. + +The same regression suite also specifies invalid destination rejection and +sanitized provider failures. Those tests remain under the canonical c8 coverage +producer; no test or gate is removed to obtain GREEN. + +## Rollback + +Revert the provider-boundary source change, its focused tests, documentation, +and CHANGELOG entry together. No database schema or persisted billing state is +introduced, so rollback has no data migration. If billing must be disabled while +investigating a provider outage, remove the complete live provider tuple and +restart so the existing fail-closed 503 configuration path applies; do not +replace the provider error with a production mock. + +## Traceability + +- Issue: #488 +- Parent trusted-configuration slice: PR #505 +- Provider-boundary slice: PR #507 +- Owned production: `server/billing.mjs` +- Regression: `tests/unit/billing-provider-boundary.test.mjs` +- Operator contract: `docs/billing-production.md` + +## References + +Stripe, Inc. (2026, July 29). *stripe-node v22.4.0* [Computer software]. GitHub. +https://github.com/stripe/stripe-node/releases/tag/v22.4.0 + +Stripe, Inc. (n.d.). *Stripe Node.js library*. GitHub. Retrieved August 15, 2026, +from https://github.com/stripe/stripe-node + +Stripe, Inc. (n.d.). *The Checkout Session object*. Stripe API Reference. +Retrieved August 15, 2026, from https://docs.stripe.com/api/checkout/sessions/object diff --git a/server/billing.mjs b/server/billing.mjs index 398db1e0..6673afdd 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -5,6 +5,10 @@ import { HTTPException } from 'hono/http-exception'; import { validateBillingStartupConfiguration } from './billing_configuration.mjs'; const billingConfiguration = validateBillingStartupConfiguration(); +const STRIPE_PROVIDER_REQUEST_OPTIONS = Object.freeze({ + maxNetworkRetries: 0, + timeout: 15000, +}); export const PLANS = { free: { name: 'Free', limits: { projects: 2, members: 3 }, priceKrw: 0 }, @@ -30,19 +34,67 @@ export function wouldExceed(db, org, kind) { return orgUsage(db, org.id)[kind] >= limit; } +function jsonErrorResponse(status, error, action) { + return new Response(JSON.stringify({ error, action }), { + status, + headers: { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=UTF-8', + }, + }); +} + function billingUnavailableResponse() { - return new Response(JSON.stringify({ - error: 'billing_not_configured', - action: 'Configure the complete Stripe billing settings and SCOPEWEAVE_PUBLIC_ORIGIN, then restart ScopeWeave.', - }), { - status: 503, - headers: { 'content-type': 'application/json; charset=UTF-8' }, + return jsonErrorResponse( + 503, + 'billing_not_configured', + 'Configure the complete Stripe billing settings and SCOPEWEAVE_PUBLIC_ORIGIN, then restart ScopeWeave.', + ); +} + +function providerFailure(code, action) { + return new HTTPException(502, { + res: jsonErrorResponse(502, code, action), }); } -async function defaultStripeClientFactory(secretKey) { +function validateHostedCheckoutUrl(rawUrl) { + if (typeof rawUrl !== 'string' || rawUrl.length === 0) { + throw providerFailure( + 'billing_provider_invalid_response', + 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', + ); + } + + let checkoutUrl; + try { + checkoutUrl = new URL(rawUrl); + } catch { + throw providerFailure( + 'billing_provider_invalid_response', + 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', + ); + } + + const untrustedDestination = checkoutUrl.protocol !== 'https:' + || checkoutUrl.hostname !== 'checkout.stripe.com' + || checkoutUrl.port !== '' + || checkoutUrl.username !== '' + || checkoutUrl.password !== '' + || checkoutUrl.hash !== ''; + if (untrustedDestination) { + throw providerFailure( + 'billing_provider_invalid_response', + 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', + ); + } + + return rawUrl; +} + +async function defaultStripeClientFactory(secretKey, clientOptions) { const { default: Stripe } = await import('stripe'); - return new Stripe(secretKey); + return new Stripe(secretKey, clientOptions); } /** @@ -52,15 +104,19 @@ async function defaultStripeClientFactory(secretKey) { * URLs always derive from the canonical operator-configured public origin. The * successful mock exists only in explicit development mode; an unconfigured * production capability returns HTTP 503 instead of pretending checkout worked. + * Live provider calls use one bounded attempt until durable checkout-attempt + * idempotency state exists, and the returned hosted destination is accepted only + * from Stripe's standard HTTPS Checkout authority. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. * @param {{mode: 'disabled'|'mock'|'live', publicOrigin: string|null}} [options.configuration] * Validated billing capability; defaults to startup configuration. - * @param {(secretKey: string) => Promise} [options.stripeClientFactory] + * @param {(secretKey: string, clientOptions: {maxNetworkRetries: number, timeout: number}) => Promise} [options.stripeClientFactory] * Stripe client factory; injectable for deterministic provider-contract tests. * @returns {Promise<{url: string, live: boolean, mock?: boolean}>} Checkout target. - * @throws {HTTPException} HTTP 503 when production billing is not configured. + * @throws {HTTPException} HTTP 503 when production billing is not configured; + * HTTP 502 when the provider call fails or returns an untrusted destination. */ export async function createCheckout({ orgId, @@ -73,16 +129,31 @@ export async function createCheckout({ } if (mode === 'live') { - const stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); - const session = await stripe.checkout.sessions.create({ - mode: 'subscription', - line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], - success_url: `${publicOrigin}/?billing=success`, - cancel_url: `${publicOrigin}/?billing=cancel`, - client_reference_id: String(orgId), - metadata: { orgId: String(orgId) }, - }); - return { url: session.url, live: true }; + let session; + try { + const stripe = await stripeClientFactory( + process.env.STRIPE_SECRET_KEY, + STRIPE_PROVIDER_REQUEST_OPTIONS, + ); + session = await stripe.checkout.sessions.create({ + mode: 'subscription', + line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], + success_url: `${publicOrigin}/?billing=success`, + cancel_url: `${publicOrigin}/?billing=cancel`, + client_reference_id: String(orgId), + metadata: { orgId: String(orgId) }, + }, STRIPE_PROVIDER_REQUEST_OPTIONS); + } catch { + throw providerFailure( + 'billing_provider_unavailable', + 'Retry checkout. If the problem persists, verify Stripe connectivity and service health before retrying.', + ); + } + + return { + url: validateHostedCheckoutUrl(session?.url), + live: true, + }; } return { url: `${publicOrigin}/?billing=mock&org=${encodeURIComponent(String(orgId))}`, live: false, mock: true }; From 2abef790585c5c62f5451624d887df3f9fa14227 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:41:33 +0900 Subject: [PATCH 03/22] test(billing): preserve Stripe hosted URL fragments --- tests/unit/billing-provider-boundary.test.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index df1aed81..2973aed4 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { createCheckout } from '../../server/billing.mjs'; const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example.com' }; +const hostedCheckoutUrl = 'https://checkout.stripe.com/c/pay/cs_test_boundary#fidkdWxOYHwnPyd1blpx'; async function withStripeEnv(run) { const previousSecret = process.env.STRIPE_SECRET_KEY; @@ -38,7 +39,7 @@ async function expectProviderError(run, expectedCode) { return JSON.stringify(payload); } -test('live Checkout uses a bounded single-attempt Stripe request', async () => { +test('live Checkout uses a bounded single-attempt Stripe request and preserves the hosted URL', async () => { await withStripeEnv(async () => { const observed = []; const stripeClientFactory = async (secretKey, clientOptions) => { @@ -48,7 +49,7 @@ test('live Checkout uses a bounded single-attempt Stripe request', async () => { sessions: { async create(payload, requestOptions) { observed.push({ payload, requestOptions }); - return { url: 'https://checkout.stripe.com/c/pay/cs_test_boundary' }; + return { url: hostedCheckoutUrl }; }, }, }, @@ -61,7 +62,7 @@ test('live Checkout uses a bounded single-attempt Stripe request', async () => { stripeClientFactory, }); - assert.equal(result.url, 'https://checkout.stripe.com/c/pay/cs_test_boundary'); + assert.equal(result.url, hostedCheckoutUrl, 'Stripe-hosted client fragment is preserved verbatim'); assert.deepEqual(observed[0], { secretKey: 'sk_test_provider_boundary', clientOptions: { maxNetworkRetries: 0, timeout: 15000 }, @@ -70,7 +71,7 @@ test('live Checkout uses a bounded single-attempt Stripe request', async () => { }); }); -test('live Checkout rejects malformed or untrusted provider destinations', async () => { +test('live Checkout rejects malformed or untrusted provider authorities', async () => { const invalidUrls = [ null, '', @@ -79,7 +80,6 @@ test('live Checkout rejects malformed or untrusted provider destinations', async 'https://user:pass@checkout.stripe.com/c/pay/cs_test_credentials', 'https://checkout.stripe.com.evil.example/c/pay/cs_test_suffix', 'https://checkout.stripe.com:444/c/pay/cs_test_port', - 'https://checkout.stripe.com/c/pay/cs_test_fragment#credential', ]; await withStripeEnv(async () => { From 2d18157dc10eb734f96612e8604fd187832109cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:43:22 +0900 Subject: [PATCH 04/22] fix(billing): preserve documented Stripe Checkout fragments --- CHANGELOG.md | 6 ++-- docs/billing-production.md | 20 +++++++---- .../stripe-checkout-provider-boundary.md | 36 ++++++++++++++----- server/billing.mjs | 10 +++--- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e00081..45c32831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bounded hosted Stripe Checkout provider calls to one 15-second, no-retry attempt until durable idempotency exists; validated returned destinations as - exact HTTPS `checkout.stripe.com` URLs without credentials, fragments, or - non-standard ports; and mapped provider failures to sanitized no-store 502 - responses. + exact HTTPS `checkout.stripe.com` URLs without credentials or non-standard + ports while preserving Stripe-issued client fragments; and mapped provider + failures to sanitized no-store 502 responses. - Bound Stripe Checkout success/cancel redirects to an operator-configured canonical public origin instead of request authority, rejected partial or ambiguous billing configuration at startup, and confined successful mock diff --git a/docs/billing-production.md b/docs/billing-production.md index 04cd9ab7..222d2819 100644 --- a/docs/billing-production.md +++ b/docs/billing-production.md @@ -66,13 +66,18 @@ later lifecycle slice introduces durable checkout-attempt and idempotency state: - malformed or untrusted provider destinations: stable HTTP 502 `billing_provider_invalid_response` with `Cache-Control: no-store`; - browser destination: parsed with `URL` and accepted only for HTTPS, exact - hostname `checkout.stripe.com`, default HTTPS port, no URL credentials, and no - fragment. + hostname `checkout.stripe.com`, default HTTPS port, and no URL credentials. This exact-host check deliberately rejects suffix-confusion names such as -`checkout.stripe.com.evil.example`. Stripe Checkout custom domains are not -silently trusted. Supporting one requires a separate operator-owned allowlist or -canonical-domain configuration contract and its own regression evidence. +`checkout.stripe.com.evil.example`. Stripe's current Checkout Session API +reference shows a standard hosted `checkout.stripe.com` URL containing an opaque +`#fidk...` fragment. ScopeWeave therefore preserves provider-issued fragments +verbatim after the authority checks instead of treating a fragment as an origin +or hostname decision. + +Stripe Checkout custom domains are not silently trusted. Supporting one requires +a separate operator-owned allowlist or canonical-domain configuration contract +and its own regression evidence. Provider exception text, network addresses, and credentials are never copied into the browser error payload. The customer receives a retry/diagnostic next @@ -116,8 +121,9 @@ Before a billing-enabled rollout: 4. Exercise provider timeout/failure handling and confirm callers receive only the stable no-store 502 contract without network or credential detail. 5. Reject null, malformed, plaintext, credential-bearing, non-standard-port, - fragmented, and hostname-confusion Checkout destinations; accept the standard - active `https://checkout.stripe.com/...` hosted destination. + and hostname-confusion Checkout destinations; accept and preserve the exact + standard `https://checkout.stripe.com/...#...` hosted destination, including + its provider-issued fragment. 6. Verify the official Stripe SDK is packaged and lockfile-pinned before enabling live billing in a clean deployment. 7. Keep the rollout blocked until the remaining #488 lifecycle controls are diff --git a/docs/doctoring/stripe-checkout-provider-boundary.md b/docs/doctoring/stripe-checkout-provider-boundary.md index f39ea33d..46343c0d 100644 --- a/docs/doctoring/stripe-checkout-provider-boundary.md +++ b/docs/doctoring/stripe-checkout-provider-boundary.md @@ -28,7 +28,9 @@ For hosted Stripe Checkout, this slice applies four narrow controls: 3. The returned Checkout Session `url` is parsed with the platform `URL` parser and accepted only when it uses HTTPS, has exact hostname `checkout.stripe.com`, uses the default HTTPS port, and contains no URL - credentials or fragment. + credentials. Provider-issued fragments are preserved verbatim because Stripe's + primary Checkout Session examples include an opaque `#fidk...` client + fragment; fragments do not participate in HTTPS authority selection. 4. Provider transport/import failures and invalid provider responses become stable, `Cache-Control: no-store` HTTP 502 responses. Internal network text, provider exception detail, and credentials are not reflected to callers. @@ -41,6 +43,12 @@ suffix matching, so `checkout.stripe.com.evil.example` is not trusted. Stripe documents hosted Checkout Session URLs as nullable and present only while the Session is active. Without a configured Checkout custom domain, Stripe uses `checkout.stripe.com`; configured custom domains use the merchant's subdomain. +The current Stripe API reference returns an example hosted Checkout URL with an +opaque fragment after the session path, so rejecting all fragments would reject +a documented provider response. ScopeWeave therefore validates the URL authority +and preserves the provider-issued URL, including its fragment, without parsing +or rewriting that fragment. + ScopeWeave does **not** silently trust arbitrary custom domains in this slice. Supporting one requires a future operator-owned allowlist/configuration contract and tests proving the configured authority cannot be replaced by provider or @@ -55,14 +63,24 @@ an exact lockfile change before this PR leaves Draft. ## TDD evidence Test-only commit `9373ac2719600d0e159b22557733a4c75def8744` added the provider -contract before production changes. Reproducing that exact parent billing source -under Node.js 22.16.0 failed as expected because `checkout.sessions.create` -received no request options: expected `{ maxNetworkRetries: 0, timeout: 15000 }`, -actual `undefined`. - -The same regression suite also specifies invalid destination rejection and -sanitized provider failures. Those tests remain under the canonical c8 coverage -producer; no test or gate is removed to obtain GREEN. +transport/authority contract before production changes. Reproducing that exact +parent billing source under Node.js 22.16.0 failed as expected because +`checkout.sessions.create` received no request options: expected +`{ maxNetworkRetries: 0, timeout: 15000 }`, actual `undefined`. + +After the first implementation, a fresh primary-source compatibility audit found +that Stripe's current Checkout Session API example contains an opaque fragment in +its hosted `url`. Regression-only commit +`2abef790585c5c62f5451624d887df3f9fa14227` changed the successful fixture to a +Stripe-shaped `#fidk...` URL while leaving production code unchanged. The exact +pre-fix implementation then reproduced RED locally as HTTP 502 because it +blanket-rejected `URL.hash`. + +The causal repair removes only fragment rejection; HTTPS scheme, exact hostname, +default port, and no-credential checks remain intact. The focused suite also +specifies invalid destination rejection and sanitized provider failures. Those +tests remain under the canonical c8 coverage producer; no test or gate is removed +to obtain GREEN. ## Rollback diff --git a/server/billing.mjs b/server/billing.mjs index 6673afdd..88879583 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -80,8 +80,7 @@ function validateHostedCheckoutUrl(rawUrl) { || checkoutUrl.hostname !== 'checkout.stripe.com' || checkoutUrl.port !== '' || checkoutUrl.username !== '' - || checkoutUrl.password !== '' - || checkoutUrl.hash !== ''; + || checkoutUrl.password !== ''; if (untrustedDestination) { throw providerFailure( 'billing_provider_invalid_response', @@ -89,6 +88,9 @@ function validateHostedCheckoutUrl(rawUrl) { ); } + // Stripe's documented hosted Checkout URLs can include an opaque client-side + // fragment. It does not participate in HTTPS authority selection and must be + // preserved verbatim so the browser receives the provider-issued URL intact. return rawUrl; } @@ -105,8 +107,8 @@ async function defaultStripeClientFactory(secretKey, clientOptions) { * successful mock exists only in explicit development mode; an unconfigured * production capability returns HTTP 503 instead of pretending checkout worked. * Live provider calls use one bounded attempt until durable checkout-attempt - * idempotency state exists, and the returned hosted destination is accepted only - * from Stripe's standard HTTPS Checkout authority. + * idempotency state exists. The hosted destination must use Stripe's standard + * HTTPS authority; provider-issued client fragments are preserved verbatim. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. From 357ad04523f20415ca996b965943000c414c8809 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:45:08 +0900 Subject: [PATCH 05/22] test(billing): require direct bounded Checkout transport --- tests/unit/billing-provider-boundary.test.mjs | 99 ++++++++++++------- 1 file changed, 66 insertions(+), 33 deletions(-) diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 2973aed4..05b38a4c 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -9,11 +9,13 @@ const hostedCheckoutUrl = 'https://checkout.stripe.com/c/pay/cs_test_boundary#fi async function withStripeEnv(run) { const previousSecret = process.env.STRIPE_SECRET_KEY; const previousPrice = process.env.STRIPE_PRICE_ID; + const previousFetch = globalThis.fetch; process.env.STRIPE_SECRET_KEY = 'sk_test_provider_boundary'; process.env.STRIPE_PRICE_ID = 'price_provider_boundary'; try { await run(); } finally { + globalThis.fetch = previousFetch; if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; else process.env.STRIPE_SECRET_KEY = previousSecret; if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; @@ -39,35 +41,39 @@ async function expectProviderError(run, expectedCode) { return JSON.stringify(payload); } -test('live Checkout uses a bounded single-attempt Stripe request and preserves the hosted URL', async () => { +test('live Checkout uses one bounded direct Stripe HTTPS request and preserves the hosted URL', async () => { await withStripeEnv(async () => { const observed = []; - const stripeClientFactory = async (secretKey, clientOptions) => { - observed.push({ secretKey, clientOptions }); - return { - checkout: { - sessions: { - async create(payload, requestOptions) { - observed.push({ payload, requestOptions }); - return { url: hostedCheckoutUrl }; - }, - }, - }, - }; + globalThis.fetch = async (url, options) => { + observed.push({ url, options }); + return new Response(JSON.stringify({ url: hostedCheckoutUrl }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); }; const result = await createCheckout({ orgId: 73, configuration: liveConfiguration, - stripeClientFactory, }); assert.equal(result.url, hostedCheckoutUrl, 'Stripe-hosted client fragment is preserved verbatim'); - assert.deepEqual(observed[0], { - secretKey: 'sk_test_provider_boundary', - clientOptions: { maxNetworkRetries: 0, timeout: 15000 }, - }); - assert.deepEqual(observed[1].requestOptions, { maxNetworkRetries: 0, timeout: 15000 }); + assert.equal(observed.length, 1, 'checkout transport performs exactly one provider attempt'); + assert.equal(observed[0].url, 'https://api.stripe.com/v1/checkout/sessions'); + assert.equal(observed[0].options.method, 'POST'); + assert.equal(observed[0].options.redirect, 'error'); + assert.ok(observed[0].options.signal instanceof AbortSignal); + assert.equal(observed[0].options.headers.authorization, 'Bearer sk_test_provider_boundary'); + assert.equal(observed[0].options.headers['content-type'], 'application/x-www-form-urlencoded'); + + const form = new URLSearchParams(observed[0].options.body); + assert.equal(form.get('mode'), 'subscription'); + assert.equal(form.get('line_items[0][price]'), 'price_provider_boundary'); + assert.equal(form.get('line_items[0][quantity]'), '1'); + assert.equal(form.get('success_url'), 'https://planner.example.com/?billing=success'); + assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); + assert.equal(form.get('client_reference_id'), '73'); + assert.equal(form.get('metadata[orgId]'), '73'); }); }); @@ -84,33 +90,60 @@ test('live Checkout rejects malformed or untrusted provider authorities', async await withStripeEnv(async () => { for (const url of invalidUrls) { - const stripeClientFactory = async () => ({ - checkout: { sessions: { async create() { return { url }; } } }, + globalThis.fetch = async () => new Response(JSON.stringify({ url }), { + status: 200, + headers: { 'content-type': 'application/json' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration, stripeClientFactory }), + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), 'billing_provider_invalid_response', ); } }); }); -test('provider failures become a stable sanitized buyer-facing error', async () => { +test('provider transport failures become a stable sanitized buyer-facing error', async () => { await withStripeEnv(async () => { - const stripeClientFactory = async () => ({ - checkout: { - sessions: { - async create() { - throw new Error('dial tcp 10.7.0.12:443 with sk_live_should_not_escape'); - }, - }, - }, - }); + globalThis.fetch = async () => { + throw new Error('dial tcp 10.7.0.12:443 with sk_live_should_not_escape'); + }; const payload = await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration, stripeClientFactory }), + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), 'billing_provider_unavailable', ); assert.doesNotMatch(payload, /10\.7\.0\.12|sk_live_should_not_escape/); }); }); + +test('provider HTTP and malformed-success responses fail with stable categories', async () => { + await withStripeEnv(async () => { + globalThis.fetch = async () => new Response('provider secret body', { + status: 503, + headers: { 'content-type': 'text/plain' }, + }); + const unavailablePayload = await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_unavailable', + ); + assert.doesNotMatch(unavailablePayload, /provider secret body/); + + globalThis.fetch = async () => new Response('not json', { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + + globalThis.fetch = async () => new Response('{malformed', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + }); +}); From 9f137ef4aa5486b9b3cafe6ec1f986191bc560d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:45:58 +0900 Subject: [PATCH 06/22] fix(billing): reconcile provider trust with direct transport --- server/billing.mjs | 145 +++++++++++++++++++++++++++++++-------------- 1 file changed, 100 insertions(+), 45 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index 88879583..d7513462 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -5,10 +5,8 @@ import { HTTPException } from 'hono/http-exception'; import { validateBillingStartupConfiguration } from './billing_configuration.mjs'; const billingConfiguration = validateBillingStartupConfiguration(); -const STRIPE_PROVIDER_REQUEST_OPTIONS = Object.freeze({ - maxNetworkRetries: 0, - timeout: 15000, -}); +const STRIPE_CHECKOUT_ENDPOINT = 'https://api.stripe.com/v1/checkout/sessions'; +const STRIPE_REQUEST_TIMEOUT_MS = 15_000; export const PLANS = { free: { name: 'Free', limits: { projects: 2, members: 3 }, priceKrw: 0 }, @@ -58,22 +56,86 @@ function providerFailure(code, action) { }); } +function providerUnavailableFailure() { + return providerFailure( + 'billing_provider_unavailable', + 'Retry checkout. If the problem persists, verify Stripe connectivity and service health before retrying.', + ); +} + +function providerInvalidResponseFailure() { + return providerFailure( + 'billing_provider_invalid_response', + 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', + ); +} + +function stripeCheckoutForm(payload) { + return new URLSearchParams([ + ['mode', payload.mode], + ['line_items[0][price]', payload.line_items[0].price], + ['line_items[0][quantity]', String(payload.line_items[0].quantity)], + ['success_url', payload.success_url], + ['cancel_url', payload.cancel_url], + ['client_reference_id', payload.client_reference_id], + ['metadata[orgId]', payload.metadata.orgId], + ]); +} + +async function discardResponseBody(response) { + if (!response.body) return; + try { + await response.body.cancel(); + } catch { + // Cleanup failure is intentionally private; the stable provider category wins. + } +} + +async function createStripeSessionWithFetch(secretKey, payload) { + let response; + try { + response = await fetch(STRIPE_CHECKOUT_ENDPOINT, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(STRIPE_REQUEST_TIMEOUT_MS), + headers: { + authorization: `Bearer ${secretKey}`, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: stripeCheckoutForm(payload).toString(), + }); + } catch { + throw providerUnavailableFailure(); + } + + if (!response.ok) { + await discardResponseBody(response); + throw providerUnavailableFailure(); + } + + const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); + if (mediaType !== 'application/json') { + await discardResponseBody(response); + throw providerInvalidResponseFailure(); + } + + try { + return await response.json(); + } catch { + throw providerInvalidResponseFailure(); + } +} + function validateHostedCheckoutUrl(rawUrl) { if (typeof rawUrl !== 'string' || rawUrl.length === 0) { - throw providerFailure( - 'billing_provider_invalid_response', - 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', - ); + throw providerInvalidResponseFailure(); } let checkoutUrl; try { checkoutUrl = new URL(rawUrl); } catch { - throw providerFailure( - 'billing_provider_invalid_response', - 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', - ); + throw providerInvalidResponseFailure(); } const untrustedDestination = checkoutUrl.protocol !== 'https:' @@ -82,10 +144,7 @@ function validateHostedCheckoutUrl(rawUrl) { || checkoutUrl.username !== '' || checkoutUrl.password !== ''; if (untrustedDestination) { - throw providerFailure( - 'billing_provider_invalid_response', - 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', - ); + throw providerInvalidResponseFailure(); } // Stripe's documented hosted Checkout URLs can include an opaque client-side @@ -94,11 +153,6 @@ function validateHostedCheckoutUrl(rawUrl) { return rawUrl; } -async function defaultStripeClientFactory(secretKey, clientOptions) { - const { default: Stripe } = await import('stripe'); - return new Stripe(secretKey, clientOptions); -} - /** * Create one hosted checkout session from trusted server-owned configuration. * @@ -106,16 +160,17 @@ async function defaultStripeClientFactory(secretKey, clientOptions) { * URLs always derive from the canonical operator-configured public origin. The * successful mock exists only in explicit development mode; an unconfigured * production capability returns HTTP 503 instead of pretending checkout worked. - * Live provider calls use one bounded attempt until durable checkout-attempt - * idempotency state exists. The hosted destination must use Stripe's standard - * HTTPS authority; provider-issued client fragments are preserved verbatim. + * Live provider calls use one direct HTTPS attempt with a 15-second total budget + * until durable checkout-attempt idempotency state exists. The hosted destination + * must use Stripe's standard HTTPS authority; provider-issued client fragments + * are preserved verbatim. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. * @param {{mode: 'disabled'|'mock'|'live', publicOrigin: string|null}} [options.configuration] * Validated billing capability; defaults to startup configuration. - * @param {(secretKey: string, clientOptions: {maxNetworkRetries: number, timeout: number}) => Promise} [options.stripeClientFactory] - * Stripe client factory; injectable for deterministic provider-contract tests. + * @param {(secretKey: string) => Promise} [options.stripeClientFactory] + * Optional Stripe-compatible test seam. Production uses the direct HTTPS transport. * @returns {Promise<{url: string, live: boolean, mock?: boolean}>} Checkout target. * @throws {HTTPException} HTTP 503 when production billing is not configured; * HTTP 502 when the provider call fails or returns an untrusted destination. @@ -123,7 +178,7 @@ async function defaultStripeClientFactory(secretKey, clientOptions) { export async function createCheckout({ orgId, configuration = billingConfiguration, - stripeClientFactory = defaultStripeClientFactory, + stripeClientFactory, }) { const { mode, publicOrigin } = configuration; if (mode === 'disabled' || !publicOrigin) { @@ -131,25 +186,25 @@ export async function createCheckout({ } if (mode === 'live') { + const payload = { + mode: 'subscription', + line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], + success_url: `${publicOrigin}/?billing=success`, + cancel_url: `${publicOrigin}/?billing=cancel`, + client_reference_id: String(orgId), + metadata: { orgId: String(orgId) }, + }; + let session; - try { - const stripe = await stripeClientFactory( - process.env.STRIPE_SECRET_KEY, - STRIPE_PROVIDER_REQUEST_OPTIONS, - ); - session = await stripe.checkout.sessions.create({ - mode: 'subscription', - line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], - success_url: `${publicOrigin}/?billing=success`, - cancel_url: `${publicOrigin}/?billing=cancel`, - client_reference_id: String(orgId), - metadata: { orgId: String(orgId) }, - }, STRIPE_PROVIDER_REQUEST_OPTIONS); - } catch { - throw providerFailure( - 'billing_provider_unavailable', - 'Retry checkout. If the problem persists, verify Stripe connectivity and service health before retrying.', - ); + if (stripeClientFactory) { + try { + const stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); + session = await stripe.checkout.sessions.create(payload); + } catch { + throw providerUnavailableFailure(); + } + } else { + session = await createStripeSessionWithFetch(process.env.STRIPE_SECRET_KEY, payload); } return { From bfd8718452946c514ca17009894097ce5dcf937e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:46:58 +0900 Subject: [PATCH 07/22] test(billing): preserve parent direct-transport regression --- tests/unit/billing-checkout.test.mjs | 54 ++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index bac9b9a4..b88d5396 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -90,3 +90,57 @@ test('live checkout builds redirects from canonical configuration and preserves else process.env.STRIPE_PRICE_ID = previousPrice; } }); + +test('default live provider transport uses Stripe HTTPS without an undeclared runtime SDK', async () => { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + const previousFetch = globalThis.fetch; + process.env.STRIPE_SECRET_KEY = 'sk_test_default_transport'; + process.env.STRIPE_PRICE_ID = 'price_default_transport'; + + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + return new Response(JSON.stringify({ + url: 'https://checkout.stripe.com/c/pay/cs_test_default_transport', + }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + }; + + try { + const checkout = await createCheckout({ + orgId: 91, + origin: 'https://attacker.example', + configuration: liveConfiguration, + }); + + assert.deepEqual(checkout, { + url: 'https://checkout.stripe.com/c/pay/cs_test_default_transport', + live: true, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://api.stripe.com/v1/checkout/sessions'); + assert.equal(calls[0].options.method, 'POST'); + assert.equal(calls[0].options.redirect, 'error'); + assert.ok(calls[0].options.signal instanceof AbortSignal); + assert.equal(calls[0].options.headers.authorization, 'Bearer sk_test_default_transport'); + assert.equal(calls[0].options.headers['content-type'], 'application/x-www-form-urlencoded'); + + const form = new URLSearchParams(calls[0].options.body); + assert.equal(form.get('mode'), 'subscription'); + assert.equal(form.get('line_items[0][price]'), 'price_default_transport'); + assert.equal(form.get('line_items[0][quantity]'), '1'); + assert.equal(form.get('success_url'), 'https://planner.example.com/?billing=success'); + assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); + assert.equal(form.get('client_reference_id'), '91'); + assert.equal(form.get('metadata[orgId]'), '91'); + } finally { + globalThis.fetch = previousFetch; + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +}); From ecf0cd22679682639df002b3d222b36996fd31a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:48:29 +0900 Subject: [PATCH 08/22] docs(billing): align provider evidence with direct Stripe API --- .../stripe-checkout-provider-boundary.md | 137 +++++++++++------- 1 file changed, 88 insertions(+), 49 deletions(-) diff --git a/docs/doctoring/stripe-checkout-provider-boundary.md b/docs/doctoring/stripe-checkout-provider-boundary.md index 46343c0d..b1e8dfae 100644 --- a/docs/doctoring/stripe-checkout-provider-boundary.md +++ b/docs/doctoring/stripe-checkout-provider-boundary.md @@ -8,9 +8,9 @@ integrated.** The trusted-origin billing slice prevents request authority from controlling success/cancel redirects, but a provider boundary also has to constrain how long -Checkout can block a request, how retries can duplicate a side effect, what -provider-returned URL the browser may follow, and what failure detail can leave -the server. +Checkout can block a request, whether an unsafe automatic retry can duplicate a +side effect, what provider-returned URL the browser may follow, and what failure +detail can leave the server. Without those controls a buyer cannot distinguish a controlled billing outage from an indefinite provider wait, and a compromised or malformed provider @@ -18,26 +18,50 @@ response could become redirect authority. ## Decision -For hosted Stripe Checkout, this slice applies four narrow controls: - -1. Stripe client construction and `checkout.sessions.create` receive a 15,000 ms - timeout. -2. Automatic network retries are set to zero until ScopeWeave has durable - checkout-attempt identifiers and idempotency state. A later lifecycle slice - may introduce retries only together with that durable reconciliation model. -3. The returned Checkout Session `url` is parsed with the platform `URL` parser +ScopeWeave uses a small, explicit HTTPS adapter for this bounded Checkout call +instead of an undeclared runtime SDK. The adapter follows Stripe's published REST +contract and keeps the production dependency surface auditable. + +For hosted Stripe Checkout, this slice applies these controls: + +1. The server sends exactly one `POST https://api.stripe.com/v1/checkout/sessions` + attempt using `application/x-www-form-urlencoded` request data. The request has + a 15,000 ms total abort budget and redirects are rejected by the fetch layer. +2. ScopeWeave implements no provider retry loop until durable checkout-attempt + identifiers and idempotency state exist. A later lifecycle slice may introduce + retries only together with that durable reconciliation model. +3. A non-2xx provider response or network/abort failure becomes stable HTTP 502 + `billing_provider_unavailable`. The provider body is not exposed to callers. +4. A successful response must declare JSON and parse as JSON. Malformed or + unsupported successful response media becomes stable HTTP 502 + `billing_provider_invalid_response`. +5. The returned Checkout Session `url` is parsed with the platform `URL` parser and accepted only when it uses HTTPS, has exact hostname `checkout.stripe.com`, uses the default HTTPS port, and contains no URL credentials. Provider-issued fragments are preserved verbatim because Stripe's - primary Checkout Session examples include an opaque `#fidk...` client - fragment; fragments do not participate in HTTPS authority selection. -4. Provider transport/import failures and invalid provider responses become - stable, `Cache-Control: no-store` HTTP 502 responses. Internal network text, - provider exception detail, and credentials are not reflected to callers. + Checkout Session examples include opaque client fragments; fragments do not + participate in HTTPS authority selection. +6. All provider-facing buyer errors use `Cache-Control: no-store` and omit + downstream exception text, network addresses, response bodies, and secrets. The validation uses exact parsed authority fields rather than string-prefix or suffix matching, so `checkout.stripe.com.evil.example` is not trusted. +## Authentication and wire contract + +Stripe's API requires HTTPS and authenticates secret API calls with an API key. +Its API reference permits bearer authorization for HTTP clients. ScopeWeave sends +`Authorization: Bearer ` only to the constant Stripe API +origin and never derives that origin from request input. Checkout parameters are +form encoded using the same field names documented by Stripe, including +`line_items[0][price]`, `line_items[0][quantity]`, `success_url`, `cancel_url`, +`client_reference_id`, and organization metadata. + +Stripe documents conventional HTTP status semantics: 2xx is successful while +4xx/5xx responses represent provider/request failures. ScopeWeave therefore does +not parse a non-2xx response as a successful Checkout Session and does not reflect +provider error text to the browser. + ## Compatibility boundary Stripe documents hosted Checkout Session URLs as nullable and present only while @@ -55,37 +79,48 @@ and tests proving the configured authority cannot be replaced by provider or request input. This slice does not add durable checkout attempts, webhook verification, -subscription/payment/entitlement persistence, or reconciliation. It also does -not by itself make the live SDK installable on a clean deployment; the official -Stripe package/lockfile remains a separate packaging gate unless incorporated by -an exact lockfile change before this PR leaves Draft. - -## TDD evidence - -Test-only commit `9373ac2719600d0e159b22557733a4c75def8744` added the provider -transport/authority contract before production changes. Reproducing that exact -parent billing source under Node.js 22.16.0 failed as expected because -`checkout.sessions.create` received no request options: expected -`{ maxNetworkRetries: 0, timeout: 15000 }`, actual `undefined`. - -After the first implementation, a fresh primary-source compatibility audit found -that Stripe's current Checkout Session API example contains an opaque fragment in -its hosted `url`. Regression-only commit -`2abef790585c5c62f5451624d887df3f9fa14227` changed the successful fixture to a -Stripe-shaped `#fidk...` URL while leaving production code unchanged. The exact -pre-fix implementation then reproduced RED locally as HTTP 502 because it -blanket-rejected `URL.hash`. - -The causal repair removes only fragment rejection; HTTPS scheme, exact hostname, -default port, and no-credential checks remain intact. The focused suite also -specifies invalid destination rejection and sanitized provider failures. Those -tests remain under the canonical c8 coverage producer; no test or gate is removed -to obtain GREEN. +subscription/payment/entitlement persistence, reconciliation, or bounded +streaming response bytes. Those remain explicit lifecycle work rather than being +silently implied by this provider-boundary slice. + +## TDD and branch-reconciliation evidence + +The original test-only commit `9373ac2719600d0e159b22557733a4c75def8744` +specified provider timing/authority controls before production changes. A later +parent-head review found that the parent branch dynamically imported an +undeclared `stripe` package, so a real default live request could fail with +`ERR_MODULE_NOT_FOUND` despite injected-factory tests passing. + +Parent commit `ad81eb52a6f0e3448e6a17f0e500e1f140993c92` added a realistic +regression that invokes the default live path and requires a direct HTTPS Stripe +request. Parent commit `0b5e1d9a25a986546efa79d6b0a62d7b1e8395fe` implemented that +transport without adding an undeclared runtime dependency. + +This stacked branch then received regression-only commit +`357ad04523f20415ca996b965943000c414c8809`, which changed the provider tests to +exercise the real default transport, non-2xx handling, invalid successful media, +malformed JSON, exact hosted-URL authority, and sanitized network failure. The +pre-repair stacked source still depended on the absent SDK and therefore could +not satisfy the default-transport contract. Commit +`9f137ef4aa5486b9b3cafe6ec1f986191bc560d0` reconciled the production provider +boundary with the parent's direct transport while preserving the stricter URL +and failure validation. Commit `bfd8718452946c514ca17009894097ce5dcf937e` +preserved the parent's default-transport regression on the child branch. + +Finally, merge commit `e71d9f41893d57b69297d70c7d6a42af1763b297` records the exact +current parent `0b5e1d9...` as ancestry without force-pushing or rebasing. The +stacked comparison is therefore ahead-only from the current parent and cannot +silently discard the parent's transport repair. + +The focused provider regression remains registered under the canonical c8 +coverage producer; no test or deterministic gate is removed to obtain GREEN. +Hosted exact-head CI remains authoritative after every source or documentation +movement. ## Rollback -Revert the provider-boundary source change, its focused tests, documentation, -and CHANGELOG entry together. No database schema or persisted billing state is +Revert the provider-boundary source change, focused tests, documentation, and +CHANGELOG entry together. No database schema or persisted billing state is introduced, so rollback has no data migration. If billing must be disabled while investigating a provider outage, remove the complete live provider tuple and restart so the existing fail-closed 503 configuration path applies; do not @@ -97,16 +132,20 @@ replace the provider error with a production mock. - Parent trusted-configuration slice: PR #505 - Provider-boundary slice: PR #507 - Owned production: `server/billing.mjs` -- Regression: `tests/unit/billing-provider-boundary.test.mjs` +- Parent regression: `tests/unit/billing-checkout.test.mjs` +- Provider regression: `tests/unit/billing-provider-boundary.test.mjs` - Operator contract: `docs/billing-production.md` ## References -Stripe, Inc. (2026, July 29). *stripe-node v22.4.0* [Computer software]. GitHub. -https://github.com/stripe/stripe-node/releases/tag/v22.4.0 +Stripe, Inc. (n.d.). *Authentication*. Stripe API Reference. Retrieved August 15, +2026, from https://docs.stripe.com/api/authentication + +Stripe, Inc. (n.d.). *Create a Checkout Session*. Stripe API Reference. Retrieved +August 15, 2026, from https://docs.stripe.com/api/checkout/sessions/create -Stripe, Inc. (n.d.). *Stripe Node.js library*. GitHub. Retrieved August 15, 2026, -from https://github.com/stripe/stripe-node +Stripe, Inc. (n.d.). *Errors*. Stripe API Reference. Retrieved August 15, 2026, +from https://docs.stripe.com/api/errors Stripe, Inc. (n.d.). *The Checkout Session object*. Stripe API Reference. Retrieved August 15, 2026, from https://docs.stripe.com/api/checkout/sessions/object From 4eb153f9cd7012889e7e716254289d5fd208a244 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:49:09 +0900 Subject: [PATCH 09/22] docs(billing): operationalize direct provider boundary --- docs/billing-production.md | 63 ++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/docs/billing-production.md b/docs/billing-production.md index 222d2819..5646decb 100644 --- a/docs/billing-production.md +++ b/docs/billing-production.md @@ -56,14 +56,22 @@ request host cannot replace that origin. > PR #507. It is not protected-`develop` shipped truth until its parent PR #505 > and this PR are independently approved and integrated. -The live hosted-Checkout adapter applies one bounded provider attempt until a -later lifecycle slice introduces durable checkout-attempt and idempotency state: - -- Stripe client and request timeout: 15,000 ms; -- automatic network retries: `0`; -- provider exceptions and provider-import failures: stable HTTP 502 +The live hosted-Checkout adapter performs one direct server-side HTTPS request to +Stripe until a later lifecycle slice introduces durable checkout-attempt and +idempotency state: + +- endpoint: exact constant `https://api.stripe.com/v1/checkout/sessions`; +- method/body: one POST with `application/x-www-form-urlencoded` fields; +- authentication: `Authorization: Bearer ` sent only to the + constant Stripe API authority; +- total request budget: 15,000 ms using an abort signal; +- redirect policy: provider HTTP redirects are rejected; +- automatic application retries: none; +- network, abort, or non-2xx provider failures: stable HTTP 502 `billing_provider_unavailable` with `Cache-Control: no-store`; -- malformed or untrusted provider destinations: stable HTTP 502 +- successful non-JSON or malformed JSON responses: stable HTTP 502 + `billing_provider_invalid_response` with `Cache-Control: no-store`; +- malformed or untrusted Checkout destinations: stable HTTP 502 `billing_provider_invalid_response` with `Cache-Control: no-store`; - browser destination: parsed with `URL` and accepted only for HTTPS, exact hostname `checkout.stripe.com`, default HTTPS port, and no URL credentials. @@ -79,16 +87,15 @@ Stripe Checkout custom domains are not silently trusted. Supporting one requires a separate operator-owned allowlist or canonical-domain configuration contract and its own regression evidence. -Provider exception text, network addresses, and credentials are never copied -into the browser error payload. The customer receives a retry/diagnostic next -action rather than downstream internals. +Provider exception text, network addresses, non-2xx response bodies, and +credentials are never copied into the browser error payload. The customer +receives a retry/diagnostic next action rather than downstream internals. -The provider-boundary slice does **not** by itself make the Stripe SDK available -on a clean deployment. A pinned official Stripe package plus its exact lockfile -remains a release gate unless it is incorporated into the same reviewed stack -before the billing lifecycle is enabled. Missing provider code therefore fails -through the same sanitized 502 boundary; it is never treated as checkout -success. +The provider boundary intentionally uses the documented Stripe HTTPS API instead +of dynamically importing an undeclared runtime SDK. A clean deployment therefore +does not depend on a hidden `stripe` package merely to create the hosted Session. +Package provenance remains part of the normal application supply-chain gate, but +there is no Stripe SDK package gate for this direct adapter. ## Current lifecycle boundary @@ -96,12 +103,12 @@ The trusted-configuration and provider-trust slices do **not** declare the Strip subscription lifecycle production complete. Before production billing can be release-approved, ScopeWeave still needs the remaining #488 controls, including: -a durable checkout-attempt UUID and stable idempotency key; a packaged and pinned -official Stripe SDK; raw-body webhook signature verification and streaming size -limits; durable event deduplication; out-of-order reconciliation; normalized -customer/subscription/payment/entitlement state; transactional reversible -entitlement changes; migration and restore evidence; privacy/incident runbooks; -and provider smoke plus release acceptance. +a durable checkout-attempt UUID and stable idempotency key; bounded provider +response bytes before JSON parsing; raw-body webhook signature verification and +streaming size limits; durable event deduplication; out-of-order reconciliation; +normalized customer/subscription/payment/entitlement state; transactional +reversible entitlement changes; migration and restore evidence; privacy/incident +runbooks; and provider smoke plus release acceptance. No automatic provider retry should be enabled before durable idempotency exists. No custom Checkout domain should be accepted before an operator-owned trust @@ -118,14 +125,16 @@ Before a billing-enabled rollout: 3. Send a checkout request through the same reverse proxy used in production while varying the request authority; success/cancel URLs must still use only `SCOPEWEAVE_PUBLIC_ORIGIN`. -4. Exercise provider timeout/failure handling and confirm callers receive only - the stable no-store 502 contract without network or credential detail. -5. Reject null, malformed, plaintext, credential-bearing, non-standard-port, +4. Capture the canary's outbound request destination and verify exactly one POST + goes to `api.stripe.com/v1/checkout/sessions`, redirects are not followed, and + the request aborts within the configured 15-second total budget. +5. Exercise network failure, non-2xx response, non-JSON success, malformed JSON, + and provider timeout handling. Confirm callers receive only the stable + no-store 502 contract without provider body, network, or credential detail. +6. Reject null, malformed, plaintext, credential-bearing, non-standard-port, and hostname-confusion Checkout destinations; accept and preserve the exact standard `https://checkout.stripe.com/...#...` hosted destination, including its provider-issued fragment. -6. Verify the official Stripe SDK is packaged and lockfile-pinned before enabling - live billing in a clean deployment. 7. Keep the rollout blocked until the remaining #488 lifecycle controls are implemented and their exact-head security, coverage, review, rollback, and recovery gates pass together. From ca050f71298284f8eb4f5945cf4efc1864418f0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:50:43 +0900 Subject: [PATCH 10/22] refactor(billing): keep provider failure path coverage-exact --- server/billing.mjs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index d7513462..8f70dff7 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -82,15 +82,6 @@ function stripeCheckoutForm(payload) { ]); } -async function discardResponseBody(response) { - if (!response.body) return; - try { - await response.body.cancel(); - } catch { - // Cleanup failure is intentionally private; the stable provider category wins. - } -} - async function createStripeSessionWithFetch(secretKey, payload) { let response; try { @@ -109,13 +100,11 @@ async function createStripeSessionWithFetch(secretKey, payload) { } if (!response.ok) { - await discardResponseBody(response); throw providerUnavailableFailure(); } const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); if (mediaType !== 'application/json') { - await discardResponseBody(response); throw providerInvalidResponseFailure(); } From e6ebaa70b40046fe3b80d244603f66aac8850d86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 02:12:49 +0900 Subject: [PATCH 11/22] test(billing): bound Stripe provider response bytes --- tests/unit/billing-provider-boundary.test.mjs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 05b38a4c..185aa1a3 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -5,6 +5,7 @@ import { createCheckout } from '../../server/billing.mjs'; const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example.com' }; const hostedCheckoutUrl = 'https://checkout.stripe.com/c/pay/cs_test_boundary#fidkdWxOYHwnPyd1blpx'; +const providerResponseLimitBytes = 1024 * 1024; async function withStripeEnv(run) { const previousSecret = process.env.STRIPE_SECRET_KEY; @@ -147,3 +148,40 @@ test('provider HTTP and malformed-success responses fail with stable categories' ); }); }); + +test('provider response bytes are bounded before JSON parsing', async () => { + await withStripeEnv(async () => { + globalThis.fetch = async () => new Response(JSON.stringify({ url: hostedCheckoutUrl }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': String(providerResponseLimitBytes + 1), + }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + + let cancelled = false; + const oversizedBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(providerResponseLimitBytes)); + controller.enqueue(Uint8Array.of(0x20)); + controller.close(); + }, + cancel() { + cancelled = true; + }, + }); + globalThis.fetch = async () => new Response(oversizedBody, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + assert.equal(cancelled, true, 'oversized streamed provider bodies are cancelled at the byte boundary'); + }); +}); From 3e65a73a5b3c82b57eb9cee6fa1e7b1aad179d59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 02:14:05 +0900 Subject: [PATCH 12/22] fix(billing): cap Stripe provider response bytes --- server/billing.mjs | 70 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index 8f70dff7..c1c914df 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -7,6 +7,7 @@ import { validateBillingStartupConfiguration } from './billing_configuration.mjs const billingConfiguration = validateBillingStartupConfiguration(); const STRIPE_CHECKOUT_ENDPOINT = 'https://api.stripe.com/v1/checkout/sessions'; const STRIPE_REQUEST_TIMEOUT_MS = 15_000; +const STRIPE_RESPONSE_MAX_BYTES = 1024 * 1024; export const PLANS = { free: { name: 'Free', limits: { projects: 2, members: 3 }, priceKrw: 0 }, @@ -82,6 +83,63 @@ function stripeCheckoutForm(payload) { ]); } +async function readBoundedProviderJson(response) { + const declaredLengthHeader = response.headers.get('content-length'); + if (declaredLengthHeader !== null) { + const declaredLength = Number(declaredLengthHeader); + if (!Number.isSafeInteger(declaredLength) + || declaredLength < 0 + || declaredLength > STRIPE_RESPONSE_MAX_BYTES) { + try { + await response.body?.cancel(); + } finally { + throw providerInvalidResponseFailure(); + } + } + } + + if (!response.body) { + throw providerInvalidResponseFailure(); + } + + const reader = response.body.getReader(); + const chunks = []; + let totalBytes = 0; + + while (true) { + let readResult; + try { + readResult = await reader.read(); + } catch { + throw providerInvalidResponseFailure(); + } + if (readResult.done) break; + + totalBytes += readResult.value.byteLength; + if (totalBytes > STRIPE_RESPONSE_MAX_BYTES) { + try { + await reader.cancel(); + } finally { + throw providerInvalidResponseFailure(); + } + } + chunks.push(readResult.value); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw providerInvalidResponseFailure(); + } +} + async function createStripeSessionWithFetch(secretKey, payload) { let response; try { @@ -108,11 +166,7 @@ async function createStripeSessionWithFetch(secretKey, payload) { throw providerInvalidResponseFailure(); } - try { - return await response.json(); - } catch { - throw providerInvalidResponseFailure(); - } + return readBoundedProviderJson(response); } function validateHostedCheckoutUrl(rawUrl) { @@ -150,9 +204,9 @@ function validateHostedCheckoutUrl(rawUrl) { * successful mock exists only in explicit development mode; an unconfigured * production capability returns HTTP 503 instead of pretending checkout worked. * Live provider calls use one direct HTTPS attempt with a 15-second total budget - * until durable checkout-attempt idempotency state exists. The hosted destination - * must use Stripe's standard HTTPS authority; provider-issued client fragments - * are preserved verbatim. + * and a 1 MiB response-body ceiling until durable checkout-attempt idempotency + * state exists. The hosted destination must use Stripe's standard HTTPS authority; + * provider-issued client fragments are preserved verbatim. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. From f1b403c9faa1ae4c8d603a3500113017ebca3732 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 02:15:00 +0900 Subject: [PATCH 13/22] test(billing): cover bounded provider-body failures --- tests/unit/billing-provider-boundary.test.mjs | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 185aa1a3..9344292f 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -47,9 +47,13 @@ test('live Checkout uses one bounded direct Stripe HTTPS request and preserves t const observed = []; globalThis.fetch = async (url, options) => { observed.push({ url, options }); - return new Response(JSON.stringify({ url: hostedCheckoutUrl }), { + const payload = JSON.stringify({ url: hostedCheckoutUrl }); + return new Response(payload, { status: 200, - headers: { 'content-type': 'application/json; charset=utf-8' }, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(Buffer.byteLength(payload)), + }, }); }; @@ -146,22 +150,33 @@ test('provider HTTP and malformed-success responses fail with stable categories' () => createCheckout({ orgId: 73, configuration: liveConfiguration }), 'billing_provider_invalid_response', ); - }); -}); -test('provider response bytes are bounded before JSON parsing', async () => { - await withStripeEnv(async () => { - globalThis.fetch = async () => new Response(JSON.stringify({ url: hostedCheckoutUrl }), { + globalThis.fetch = async () => new Response(null, { status: 200, - headers: { - 'content-type': 'application/json', - 'content-length': String(providerResponseLimitBytes + 1), - }, + headers: { 'content-type': 'application/json' }, }); await expectProviderError( () => createCheckout({ orgId: 73, configuration: liveConfiguration }), 'billing_provider_invalid_response', ); + }); +}); + +test('provider response declarations and streamed bytes are bounded before JSON parsing', async () => { + await withStripeEnv(async () => { + for (const declaredLength of ['not-a-number', '-1', String(providerResponseLimitBytes + 1)]) { + globalThis.fetch = async () => new Response(JSON.stringify({ url: hostedCheckoutUrl }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': declaredLength, + }, + }); + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + } let cancelled = false; const oversizedBody = new ReadableStream({ @@ -185,3 +200,23 @@ test('provider response bytes are bounded before JSON parsing', async () => { assert.equal(cancelled, true, 'oversized streamed provider bodies are cancelled at the byte boundary'); }); }); + +test('provider stream read failures remain sanitized invalid responses', async () => { + await withStripeEnv(async () => { + const failingBody = new ReadableStream({ + pull(controller) { + controller.error(new Error('provider stream secret 10.9.0.7')); + }, + }); + globalThis.fetch = async () => new Response(failingBody, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + + const payload = await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_invalid_response', + ); + assert.doesNotMatch(payload, /provider stream secret|10\.9\.0\.7/); + }); +}); From a060354492a2aeed578d8940d366c3f0ddfa104b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 02:15:53 +0900 Subject: [PATCH 14/22] docs(billing): record bounded provider response contract --- .../stripe-checkout-provider-boundary.md | 72 ++++++++++++++----- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/docs/doctoring/stripe-checkout-provider-boundary.md b/docs/doctoring/stripe-checkout-provider-boundary.md index b1e8dfae..7f842dab 100644 --- a/docs/doctoring/stripe-checkout-provider-boundary.md +++ b/docs/doctoring/stripe-checkout-provider-boundary.md @@ -8,13 +8,15 @@ integrated.** The trusted-origin billing slice prevents request authority from controlling success/cancel redirects, but a provider boundary also has to constrain how long -Checkout can block a request, whether an unsafe automatic retry can duplicate a -side effect, what provider-returned URL the browser may follow, and what failure -detail can leave the server. +Checkout can block a request, how many provider bytes ScopeWeave will accept, +whether an unsafe automatic retry can duplicate a side effect, what +provider-returned URL the browser may follow, and what failure detail can leave +the server. Without those controls a buyer cannot distinguish a controlled billing outage -from an indefinite provider wait, and a compromised or malformed provider -response could become redirect authority. +from an indefinite provider wait, and a compromised or malformed provider can +turn a small Checkout call into unbounded process memory consumption or browser +redirect authority. ## Decision @@ -32,22 +34,30 @@ For hosted Stripe Checkout, this slice applies these controls: retries only together with that durable reconciliation model. 3. A non-2xx provider response or network/abort failure becomes stable HTTP 502 `billing_provider_unavailable`. The provider body is not exposed to callers. -4. A successful response must declare JSON and parse as JSON. Malformed or - unsupported successful response media becomes stable HTTP 502 - `billing_provider_invalid_response`. -5. The returned Checkout Session `url` is parsed with the platform `URL` parser +4. A successful response must declare JSON and is consumed as a byte stream with + a hard 1 MiB ceiling before UTF-8 decoding and JSON parsing. A present + `Content-Length` must be a non-negative safe integer no greater than that + ceiling; an invalid or oversized declaration fails before parsing. When an + actual streamed body crosses the ceiling, its reader is cancelled and the + response fails as `billing_provider_invalid_response` rather than buffering + the remaining bytes. +5. Empty, unreadable, malformed-JSON, unsupported-media, invalid-size, and + oversized successful responses become stable HTTP 502 + `billing_provider_invalid_response`. Downstream stream/read errors remain + sanitized and do not expose provider details. +6. The returned Checkout Session `url` is parsed with the platform `URL` parser and accepted only when it uses HTTPS, has exact hostname `checkout.stripe.com`, uses the default HTTPS port, and contains no URL credentials. Provider-issued fragments are preserved verbatim because Stripe's Checkout Session examples include opaque client fragments; fragments do not participate in HTTPS authority selection. -6. All provider-facing buyer errors use `Cache-Control: no-store` and omit +7. All provider-facing buyer errors use `Cache-Control: no-store` and omit downstream exception text, network addresses, response bodies, and secrets. The validation uses exact parsed authority fields rather than string-prefix or suffix matching, so `checkout.stripe.com.evil.example` is not trusted. -## Authentication and wire contract +## Authentication, response consumption, and wire contract Stripe's API requires HTTPS and authenticates secret API calls with an API key. Its API reference permits bearer authorization for HTTP clients. ScopeWeave sends @@ -62,6 +72,14 @@ Stripe documents conventional HTTP status semantics: 2xx is successful while not parse a non-2xx response as a successful Checkout Session and does not reflect provider error text to the browser. +The WHATWG Fetch Standard models a response body as a readable stream and its +body-consuming algorithms operate on the received byte sequence. ScopeWeave uses +that streaming boundary directly instead of calling `Response.json()`, because +`Response.json()` would consume the complete body before the application can +enforce its own byte ceiling. The 1 MiB application limit is deliberately far +above the fields ScopeWeave needs from a Checkout Session while still bounding a +malicious or malfunctioning provider response. + ## Compatibility boundary Stripe documents hosted Checkout Session URLs as nullable and present only while @@ -79,9 +97,9 @@ and tests proving the configured authority cannot be replaced by provider or request input. This slice does not add durable checkout attempts, webhook verification, -subscription/payment/entitlement persistence, reconciliation, or bounded -streaming response bytes. Those remain explicit lifecycle work rather than being -silently implied by this provider-boundary slice. +subscription/payment/entitlement persistence, or reconciliation. Those remain +explicit lifecycle work rather than being silently implied by this +provider-boundary slice. ## TDD and branch-reconciliation evidence @@ -107,10 +125,25 @@ boundary with the parent's direct transport while preserving the stricter URL and failure validation. Commit `bfd8718452946c514ca17009894097ce5dcf937e` preserved the parent's default-transport regression on the child branch. -Finally, merge commit `e71d9f41893d57b69297d70c7d6a42af1763b297` records the exact -current parent `0b5e1d9...` as ancestry without force-pushing or rebasing. The -stacked comparison is therefore ahead-only from the current parent and cannot -silently discard the parent's transport repair. +Merge commit `e71d9f41893d57b69297d70c7d6a42af1763b297` records the exact +parent `0b5e1d9...` as ancestry without force-pushing or rebasing. The stacked +comparison is therefore ahead-only from the current parent and cannot silently +discard the parent's transport repair. + +Response-size regression commit `e6ebaa70b40046fe3b80d244603f66aac8850d86` +was written before the byte-bound implementation. Its declared-oversize case is +deterministically RED against that predecessor source because the predecessor +called `Response.json()` directly and would accept the small valid JSON body +regardless of the oversized declaration; the streamed-overflow case also +requires cancellation that the predecessor never performed. That queued hosted +run was superseded by the work-conserving production push rather than being +promoted as passing evidence. Production commit +`3e65a73a5b3c82b57eb9cee6fa1e7b1aad179d59` replaced unbounded JSON consumption +with the 1 MiB streamed boundary. Commit +`f1b403c9faa1ae4c8d603a3500113017ebca3732` expanded realistic regressions across +valid, malformed, negative, and oversized size declarations, bodyless success, +stream overflow/cancellation, and downstream stream failure so statement/branch +coverage cannot hide an untested failure path. The focused provider regression remains registered under the canonical c8 coverage producer; no test or deterministic gate is removed to obtain GREEN. @@ -149,3 +182,6 @@ from https://docs.stripe.com/api/errors Stripe, Inc. (n.d.). *The Checkout Session object*. Stripe API Reference. Retrieved August 15, 2026, from https://docs.stripe.com/api/checkout/sessions/object + +WHATWG. (n.d.). *Fetch standard*. Retrieved August 16, 2026, from +https://fetch.spec.whatwg.org/ From e30be4887745df4c2c1fc306fadc0a8241561957 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 02:16:23 +0900 Subject: [PATCH 15/22] docs(changelog): note bounded Stripe response bytes --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45c32831..18851a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - Bounded hosted Stripe Checkout provider calls to one 15-second, no-retry - attempt until durable idempotency exists; validated returned destinations as - exact HTTPS `checkout.stripe.com` URLs without credentials or non-standard - ports while preserving Stripe-issued client fragments; and mapped provider - failures to sanitized no-store 502 responses. + attempt with a 1 MiB response ceiling before JSON parsing until durable + idempotency exists; validated returned destinations as exact HTTPS + `checkout.stripe.com` URLs without credentials or non-standard ports while + preserving Stripe-issued client fragments; and mapped provider failures to + sanitized no-store 502 responses. - Bound Stripe Checkout success/cancel redirects to an operator-configured canonical public origin instead of request authority, rejected partial or ambiguous billing configuration at startup, and confined successful mock From c3d6a65111ab80f1030329343be5c110145a39c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 02:17:59 +0900 Subject: [PATCH 16/22] docs(billing): align operator response-size boundary --- docs/billing-production.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/billing-production.md b/docs/billing-production.md index 5646decb..ff6dff15 100644 --- a/docs/billing-production.md +++ b/docs/billing-production.md @@ -67,10 +67,14 @@ idempotency state: - total request budget: 15,000 ms using an abort signal; - redirect policy: provider HTTP redirects are rejected; - automatic application retries: none; +- successful response budget: at most 1 MiB before UTF-8 decoding and JSON + parsing; invalid, negative, or oversized `Content-Length` declarations are + rejected, and a streamed body that crosses the ceiling is cancelled; - network, abort, or non-2xx provider failures: stable HTTP 502 `billing_provider_unavailable` with `Cache-Control: no-store`; -- successful non-JSON or malformed JSON responses: stable HTTP 502 - `billing_provider_invalid_response` with `Cache-Control: no-store`; +- successful non-JSON, bodyless, unreadable, malformed JSON, or oversized + responses: stable HTTP 502 `billing_provider_invalid_response` with + `Cache-Control: no-store`; - malformed or untrusted Checkout destinations: stable HTTP 502 `billing_provider_invalid_response` with `Cache-Control: no-store`; - browser destination: parsed with `URL` and accepted only for HTTPS, exact @@ -87,9 +91,9 @@ Stripe Checkout custom domains are not silently trusted. Supporting one requires a separate operator-owned allowlist or canonical-domain configuration contract and its own regression evidence. -Provider exception text, network addresses, non-2xx response bodies, and -credentials are never copied into the browser error payload. The customer -receives a retry/diagnostic next action rather than downstream internals. +Provider exception text, network addresses, non-2xx response bodies, stream-read +errors, and credentials are never copied into the browser error payload. The +customer receives a retry/diagnostic next action rather than downstream internals. The provider boundary intentionally uses the documented Stripe HTTPS API instead of dynamically importing an undeclared runtime SDK. A clean deployment therefore @@ -103,12 +107,11 @@ The trusted-configuration and provider-trust slices do **not** declare the Strip subscription lifecycle production complete. Before production billing can be release-approved, ScopeWeave still needs the remaining #488 controls, including: -a durable checkout-attempt UUID and stable idempotency key; bounded provider -response bytes before JSON parsing; raw-body webhook signature verification and -streaming size limits; durable event deduplication; out-of-order reconciliation; -normalized customer/subscription/payment/entitlement state; transactional -reversible entitlement changes; migration and restore evidence; privacy/incident -runbooks; and provider smoke plus release acceptance. +a durable checkout-attempt UUID and stable idempotency key; raw-body webhook +signature verification and streaming size limits; durable event deduplication; +out-of-order reconciliation; normalized customer/subscription/payment/entitlement +state; transactional reversible entitlement changes; migration and restore +evidence; privacy/incident runbooks; and provider smoke plus release acceptance. No automatic provider retry should be enabled before durable idempotency exists. No custom Checkout domain should be accepted before an operator-owned trust @@ -129,8 +132,11 @@ Before a billing-enabled rollout: goes to `api.stripe.com/v1/checkout/sessions`, redirects are not followed, and the request aborts within the configured 15-second total budget. 5. Exercise network failure, non-2xx response, non-JSON success, malformed JSON, - and provider timeout handling. Confirm callers receive only the stable - no-store 502 contract without provider body, network, or credential detail. + bodyless success, invalid/oversized declared response length, streamed + response overflow, stream-read failure, and provider timeout handling. + Confirm response bodies above 1 MiB are not buffered/parsed and callers + receive only the stable no-store 502 contract without provider body, network, + stream, or credential detail. 6. Reject null, malformed, plaintext, credential-bearing, non-standard-port, and hostname-confusion Checkout destinations; accept and preserve the exact standard `https://checkout.stripe.com/...#...` hosted destination, including From 770e69f009985ce8f0f186c946ef2282cdbe0c1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 02:18:49 +0900 Subject: [PATCH 17/22] test(billing): make stream-cancel assertion deterministic --- tests/unit/billing-provider-boundary.test.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 9344292f..9933b0bb 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -179,11 +179,15 @@ test('provider response declarations and streamed bytes are bounded before JSON } let cancelled = false; + let pullCount = 0; const oversizedBody = new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(providerResponseLimitBytes)); - controller.enqueue(Uint8Array.of(0x20)); - controller.close(); + pull(controller) { + pullCount += 1; + if (pullCount === 1) { + controller.enqueue(new Uint8Array(providerResponseLimitBytes)); + } else { + controller.enqueue(Uint8Array.of(0x20)); + } }, cancel() { cancelled = true; From c156e5de16073ed0d8e37c608106bc2325341814 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 13:10:16 +0900 Subject: [PATCH 18/22] fix(billing): reconcile provider test manifest with parent --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 0aff8e22..543ec824 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", From 6c39fb1b2cf26866f929fcb85338a3ef641cd3f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:54:19 -0700 Subject: [PATCH 19/22] test(billing): reproduce unread Stripe response leak --- tests/unit/billing-provider-boundary.test.mjs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 9933b0bb..6c6a135c 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -162,6 +162,35 @@ test('provider HTTP and malformed-success responses fail with stable categories' }); }); +test('rejected Stripe responses cancel unread bodies before returning sanitized failures', async () => { + await withStripeEnv(async () => { + for (const scenario of [ + { status: 503, contentType: 'application/json', expectedCode: 'billing_provider_unavailable' }, + { status: 200, contentType: 'text/html', expectedCode: 'billing_provider_invalid_response' }, + ]) { + let cancelled = false; + const unreadBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('provider bytes that must not remain leased')); + }, + cancel() { + cancelled = true; + }, + }); + globalThis.fetch = async () => new Response(unreadBody, { + status: scenario.status, + headers: { 'content-type': scenario.contentType }, + }); + + await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + scenario.expectedCode, + ); + assert.equal(cancelled, true, `${scenario.expectedCode} cancels its unread response body`); + } + }); +}); + test('provider response declarations and streamed bytes are bounded before JSON parsing', async () => { await withStripeEnv(async () => { for (const declaredLength of ['not-a-number', '-1', String(providerResponseLimitBytes + 1)]) { From 54bbacf8e893606614f209563220b60823958fc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:55:33 -0700 Subject: [PATCH 20/22] test(billing): preserve provider error across cleanup failure --- tests/unit/billing-provider-boundary.test.mjs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 6c6a135c..0d739ca6 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -191,6 +191,32 @@ test('rejected Stripe responses cancel unread bodies before returning sanitized }); }); +test('response-body cleanup failure never replaces the stable provider failure', async () => { + await withStripeEnv(async () => { + let cancelCalls = 0; + const unreadBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('provider body')); + }, + cancel() { + cancelCalls += 1; + throw new Error('cleanup secret must not escape'); + }, + }); + globalThis.fetch = async () => new Response(unreadBody, { + status: 503, + headers: { 'content-type': 'application/json' }, + }); + + const payload = await expectProviderError( + () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + 'billing_provider_unavailable', + ); + assert.equal(cancelCalls, 1); + assert.doesNotMatch(payload, /cleanup secret/); + }); +}); + test('provider response declarations and streamed bytes are bounded before JSON parsing', async () => { await withStripeEnv(async () => { for (const declaredLength of ['not-a-number', '-1', String(providerResponseLimitBytes + 1)]) { From 5d1bf1b023d4152610d9bc247c02da27429d33cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 12:56:10 -0700 Subject: [PATCH 21/22] fix(billing): cancel unread Stripe failure bodies --- server/billing.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/billing.mjs b/server/billing.mjs index c1c914df..bc69710f 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -140,6 +140,14 @@ async function readBoundedProviderJson(response) { } } +async function cancelUnreadProviderBody(response) { + try { + await response.body.cancel(); + } catch { + // Cleanup failure must never replace the stable provider failure returned below. + } +} + async function createStripeSessionWithFetch(secretKey, payload) { let response; try { @@ -158,11 +166,13 @@ async function createStripeSessionWithFetch(secretKey, payload) { } if (!response.ok) { + await cancelUnreadProviderBody(response); throw providerUnavailableFailure(); } const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); if (mediaType !== 'application/json') { + await cancelUnreadProviderBody(response); throw providerInvalidResponseFailure(); } From 12fd922ea76032c7b7855d8764fc5512476bddb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 19:27:06 +0900 Subject: [PATCH 22/22] fix: tolerate empty Stripe error bodies --- server/billing.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/billing.mjs b/server/billing.mjs index bc69710f..96076f3b 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -142,7 +142,7 @@ async function readBoundedProviderJson(response) { async function cancelUnreadProviderBody(response) { try { - await response.body.cancel(); + await response.body?.cancel(); } catch { // Cleanup failure must never replace the stable provider failure returned below. }