diff --git a/CHANGELOG.md b/CHANGELOG.md index 949f9345..fc544cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. +- Added a bounded authoritative Stripe Subscription read boundary that validates + exact subscription and tenant identity, normalizes immutable provider lifecycle + facts without granting entitlement, and propagates `orgId` onto the underlying + Subscription created by Checkout so later reconciliation can fail closed on + missing or cross-tenant provider metadata. - Persist verified Stripe webhook event metadata and per-delivery replay evidence after raw-body signature verification without retaining the signed raw body; exact event-ID/hash duplicates are idempotent, conflicting bytes and malformed diff --git a/docs/doctoring/stripe-subscription-authoritative-read.md b/docs/doctoring/stripe-subscription-authoritative-read.md new file mode 100644 index 00000000..dac6a784 --- /dev/null +++ b/docs/doctoring/stripe-subscription-authoritative-read.md @@ -0,0 +1,127 @@ +# Authoritative Stripe subscription read boundary + +## Status and authority + +**Status: active stacked PR evidence, not protected-`develop` shipped truth.** + +This record belongs to PR #525 and is stacked on PR #521's verified webhook evidence ledger. The protected `develop` branch remains the shipped authority until the whole stack is independently reviewed, protected-integrated, and revalidated against its final exact heads. + +Issue #488 remains open for durable subscription-state reconciliation, monotonic lifecycle/application policy, normalized entitlement persistence, operator recovery, retention/export controls, and release acceptance. This slice deliberately provides only the provider-read trust boundary needed by those later steps. + +## Buyer and control objective + +Stripe does not guarantee webhook event delivery order. A signed webhook event therefore proves authenticity of one delivery but cannot, by itself, prove that its subscription snapshot is the latest state. ScopeWeave needs a distinct read boundary that can retrieve the current provider resource before durable lifecycle or entitlement mutation. + +The boundary implemented by `server/stripe_subscription_provider.mjs` has four acquisition-grade responsibilities: + +1. make exactly one bounded HTTPS GET for a known Stripe Subscription ID; +2. reject malformed, contradictory, oversized, or non-JSON successful responses before they can become reconciliation evidence; +3. bind the returned Subscription to the expected ScopeWeave organization using exact `metadata.orgId` equality; +4. return immutable normalized provider facts while making no local entitlement decision and performing no persistence mutation. + +## Checkout-to-subscription tenant binding + +ScopeWeave creates Stripe Checkout Sessions in `subscription` mode. Checkout Session metadata and Subscription metadata are separate provider objects. To make a later authoritative Subscription read tenant-verifiable, the live checkout request now sends the organization binding in both locations: + +- `metadata[orgId]` on the Checkout Session; and +- `subscription_data[metadata][orgId]` on the Subscription created by Checkout. + +Both the injected SDK-compatible seam and the direct `application/x-www-form-urlencoded` HTTPS transport have executable assertions for this behavior. The authoritative reader accepts the provider object only when its own Subscription metadata contains a string `orgId` that exactly equals the expected positive ScopeWeave organization ID. Missing metadata, alternate textual representations, wrong tenants, and non-string values fail closed. + +Metadata is treated only as a tenant-binding claim carried by the provider object. It does not grant a plan or entitlement by itself. + +## Bounded provider-read contract + +`fetchStripeSubscriptionAuthoritative(...)` validates local authority before transport and then performs one direct GET to the Subscription API. + +Local authority validation requires: + +- a positive safe-integer organization ID; +- a bounded Stripe `sub_...` subscription identifier; +- a non-empty bounded server-owned Stripe secret; +- callable transport and timeout seams; +- an actual `AbortSignal` from the timeout seam. + +The provider call uses a hard-coded HTTPS Stripe API authority, `GET`, redirect rejection, and a 15-second request budget. Successful responses must be `application/json` and are capped at 256 KiB by both declared `Content-Length` and incremental stream accounting before JSON parsing. Invalid UTF-8, invalid JSON, malformed stream reads, oversized bodies, and contradictory provider values collapse to a stable sanitized provider-response error. + +Non-success provider response bodies are cancelled without parsing. HTTP 404 remains distinguishable from transient/unavailable provider failures so a later reconciliation layer can decide whether absence is meaningful without exposing provider response text. + +## Normalized immutable provider facts + +The returned snapshot is frozen and contains only bounded reconciliation facts: + +- subscription ID and customer ID; +- ScopeWeave organization ID verified against Subscription metadata; +- Stripe subscription status as provider data; +- cancel-at-period-end flag; +- current period start/end timestamps; +- nullable canceled, ended, and trial-end timestamps; +- nullable latest-invoice ID; +- one to 100 price IDs from subscription items. + +The current Stripe status vocabulary accepted by this boundary is `incomplete`, `incomplete_expired`, `trialing`, `active`, `past_due`, `canceled`, `unpaid`, and `paused`. These values are deliberately preserved as external provider state rather than collapsed into a local `active/inactive` entitlement decision. + +The snapshot rejects impossible period ordering, unsafe timestamps, expanded objects where an identifier is expected, unknown statuses, missing items, unbounded item collections, and invalid identifiers. A later policy layer must explicitly translate a valid provider snapshot into durable ScopeWeave lifecycle and entitlement transitions. + +## Security and privacy boundary + +This slice minimizes provider material retained in memory and does not persist the Stripe response. It intentionally does **not** expose or retain: + +- Stripe secret keys; +- arbitrary Stripe error bodies or network diagnostics; +- raw webhook payloads; +- customer PII beyond the bounded provider identifiers needed for reconciliation; +- local session/authentication material; +- any inferred entitlement decision. + +`StripeSubscriptionProviderError` carries a stable ScopeWeave code only. Tenant mismatch is explicit but does not echo the expected or received tenant identity. Unexpected network, timeout-factory, or provider transport failures are sanitized as unavailable-provider evidence. + +The design supports purpose-bound authorization and tenant isolation without blanket PII masking: the expected local organization authority must already be known by the caller, and the provider object must independently assert the same tenant before it can progress to reconciliation. + +## API-version contract boundary + +Stripe documents that direct API requests use the account's default API version unless a request explicitly supplies `Stripe-Version`. ScopeWeave's current direct REST adapters do not yet pin or operator-configure that header. This PR therefore does **not** claim that provider response schemas are version-invariant across Stripe account upgrades. + +That is a deliberate follow-up boundary, not something to silently fix by hard-coding today's newest Stripe version into an in-flight billing stack. A safe versioning slice must define the supported provider version, compatibility tests/migration path, rollback behavior, and operator upgrade procedure before enforcing a header. Until then, this reader remains fail-closed when an account-version response falls outside its validated contract. + +## TDD and causal verification trace + +The authoritative-read contract began as a RED test importing an absent production module. Once the narrow production implementation and Subscription metadata propagation were added, hosted validation exposed two independent integration defects that were corrected without weakening any gate. + +### Coverage-contract failure + +On contributor head `c5838867c13d43ffbb21ca6d05866491666aa954`, hosted `unit-and-api` failed because the canonical c8 producer did not instrument `server/stripe_subscription_provider.mjs` or execute the new focused provider tests. Commit `38c7b232b17d5b766f62c5cb14e6fe3a6eb45fa5` repaired the coverage contract by adding the provider module to `--include` and both new billing tests to the canonical coverage cases. + +### Subscription-metadata regression + +The next hosted run reached the wider unit suite and failed `tests/unit/billing-checkout.test.mjs`: production correctly propagated `subscription_data.metadata.orgId`, while the inherited expected SDK payload still described the older Checkout-only metadata shape. Commit `344955437d81340a5b1f997d45912f866efa37e2` updated both SDK-style and direct-form assertions to require Subscription tenant metadata. On that exact contributor revision, repository-native `unit-and-api`, dependency review, OSV paths, and cloud E2E completed successfully. + +Those hosted runs are causal test evidence, not final merge-grade exact-head evidence under the repository's current evidence policy. This stack still inherits the older default pull-request checkout behavior, which can execute GitHub's synthetic merge ref. PR #523 separately repairs repository-owned workflows to attest the immutable contributor SHA. After that control is protected-shipped, this stack must be reconciled to the live base and all applicable deterministic evidence rerun on the exact contributor heads. + +## Acceptance trace + +Executable contracts include: + +- `tests/unit/stripe-subscription-provider.test.mjs` — exact bounded GET, immutable normalization, full current status vocabulary, tenant mismatch, malformed snapshots, sanitized provider failure, body bounds, and local-authority validation; +- `tests/unit/stripe-subscription-metadata-propagation.test.mjs` — Subscription metadata propagation through both SDK-compatible and direct REST Checkout transports; +- `tests/unit/billing-checkout.test.mjs` — wider Checkout regression proving the same tenant metadata contract remains part of normal live checkout behavior; +- `tests/unit/coverage-script-contract.test.mjs` — requires the authoritative provider module and focused tests to remain in the canonical owned-production coverage producer; +- `package.json` — includes the provider module in c8 owned-production instrumentation and both focused suites in normal/coverage execution. + +Any head, parent, or protected-base movement invalidates the head-specific evidence above until freshly reconciled. + +## Rollback and recovery + +Before protected integration, rollback is source-only: remove the provider reader, Subscription metadata propagation, focused tests, coverage registration, this doctoring record, and its active-PR changelog entry together. Do not retain a reader whose tenant-binding precondition is no longer produced by Checkout. + +After future lifecycle persistence is protected-shipped, rollback must preserve durable billing evidence and must not restore webhook-order assumptions or grant entitlements directly from a signed event payload. Recovery must re-fetch authoritative provider state and replay policy from one explicitly verified local/provider point. + +## References + +Stripe. (n.d.). *Create a Checkout Session*. Stripe API Reference. https://docs.stripe.com/api/checkout/sessions/create + +Stripe. (n.d.). *Receive Stripe events in your webhook endpoint*. Stripe Documentation. https://docs.stripe.com/webhooks + +Stripe. (n.d.). *The Subscription object*. Stripe API Reference. https://docs.stripe.com/api/subscriptions/object + +Stripe. (n.d.). *Versioning*. Stripe API Reference. https://docs.stripe.com/api/versioning diff --git a/package.json b/package.json index ba16382f..8e88e19c 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/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/stripe-webhook.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/toast-accessibility.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/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.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-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && npm run test:api", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && 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-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/toast-accessibility.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/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.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-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.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 tests/e2e/toast-accessibility.spec.js", diff --git a/server/billing.mjs b/server/billing.mjs index bf6ff2b6..652ac478 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -113,6 +113,7 @@ function stripeCheckoutForm(payload) { ['cancel_url', payload.cancel_url], ['client_reference_id', payload.client_reference_id], ['metadata[orgId]', payload.metadata.orgId], + ['subscription_data[metadata][orgId]', payload.subscription_data.metadata.orgId], ]); } @@ -300,7 +301,10 @@ function markKnownProviderFailure(repository, attemptId, error) { * the attempt pending so a later call reuses the same key; known 4xx responses * close the attempt so a deliberate later checkout gets fresh provider authority. * The hosted destination must use Stripe's standard HTTPS authority; provider- - * issued client fragments are preserved verbatim. + * issued client fragments are preserved verbatim. Subscription-mode Checkout + * copies the organization binding onto both the Checkout Session and the + * underlying Stripe Subscription so later authoritative reads can fail closed on + * cross-tenant or missing provider metadata. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. @@ -342,13 +346,15 @@ export async function createCheckout({ throw checkoutStateFailure(); } + const organizationId = String(orgId); const payload = { mode: 'subscription', line_items: [{ price: priceId, quantity: 1 }], success_url: `${publicOrigin}/?billing=success`, cancel_url: `${publicOrigin}/?billing=cancel`, - client_reference_id: String(orgId), - metadata: { orgId: String(orgId) }, + client_reference_id: organizationId, + metadata: { orgId: organizationId }, + subscription_data: { metadata: { orgId: organizationId } }, }; let session; diff --git a/server/stripe_subscription_provider.mjs b/server/stripe_subscription_provider.mjs new file mode 100644 index 00000000..45cf8893 --- /dev/null +++ b/server/stripe_subscription_provider.mjs @@ -0,0 +1,308 @@ +const STRIPE_SUBSCRIPTION_ENDPOINT = 'https://api.stripe.com/v1/subscriptions/'; +const STRIPE_REQUEST_TIMEOUT_MS = 15_000; +const STRIPE_RESPONSE_MAX_BYTES = 256 * 1024; +const MAX_PROVIDER_ID_LENGTH = 255; +const MAX_SECRET_KEY_LENGTH = 1024; +const MAX_SUBSCRIPTION_ITEMS = 100; +const SUBSCRIPTION_ID_PATTERN = /^sub_[A-Za-z0-9_]+$/u; +const STRIPE_SUBSCRIPTION_STATUSES = new Set([ + 'incomplete', + 'incomplete_expired', + 'trialing', + 'active', + 'past_due', + 'canceled', + 'unpaid', + 'paused', +]); + +/** + * Stable, sanitized error raised when authoritative Stripe subscription reads fail. + * + * The error intentionally exposes only a bounded application code. Provider + * response bodies, network diagnostics, secret keys, and tenant identifiers are + * never copied into its message so HTTP/operator adapters can map failures without + * turning provider diagnostics into an information-disclosure channel. + */ +export class StripeSubscriptionProviderError extends Error { + /** @param {string} code - Stable ScopeWeave billing-provider error code. */ + constructor(code) { + super(code); + this.name = 'StripeSubscriptionProviderError'; + this.code = code; + } +} + +function providerError(code) { + return new StripeSubscriptionProviderError(code); +} + +function invalidProviderResponse() { + return providerError('billing_subscription_provider_invalid_response'); +} + +function providerUnavailable() { + return providerError('billing_subscription_provider_unavailable'); +} + +function providerNotFound() { + return providerError('billing_subscription_provider_not_found'); +} + +function tenantMismatch() { + return providerError('billing_subscription_tenant_mismatch'); +} + +function positiveInteger(value, name) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new TypeError(`${name} must be a positive integer`); + } + return parsed; +} + +function requiredString(value, name, maximumLength) { + if (typeof value !== 'string') { + throw new TypeError(`${name} must be a non-empty string`); + } + const normalized = value.trim(); + if (normalized.length === 0 || normalized.length > maximumLength) { + throw new TypeError(`${name} must be a non-empty string no longer than ${maximumLength} characters`); + } + return normalized; +} + +function providerIdentifier(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PROVIDER_ID_LENGTH) { + throw invalidProviderResponse(); + } + return value; +} + +function nullableProviderIdentifier(value) { + if (value === null) return null; + return providerIdentifier(value); +} + +function nonNegativeTimestamp(value) { + if (!Number.isSafeInteger(value) || value < 0) throw invalidProviderResponse(); + return value; +} + +function nullableTimestamp(value) { + if (value === null) return null; + return nonNegativeTimestamp(value); +} + +async function cancelProviderBody(response) { + try { + await response.body?.cancel(); + } catch { + // Cancellation is best-effort cleanup only. The sanitized causal error below + // remains authoritative and must not be replaced by stream implementation detail. + } +} + +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) { + await cancelProviderBody(response); + throw invalidProviderResponse(); + } + } + + if (!response.body) throw invalidProviderResponse(); + + const reader = response.body.getReader(); + const chunks = []; + let totalBytes = 0; + while (true) { + let result; + try { + result = await reader.read(); + } catch { + throw invalidProviderResponse(); + } + if (result.done) break; + + totalBytes += result.value.byteLength; + if (totalBytes > STRIPE_RESPONSE_MAX_BYTES) { + try { + await reader.cancel(); + } catch { + // Preserve the bounded invalid-response classification even if the stream + // implementation also rejects cancellation. + } + throw invalidProviderResponse(); + } + chunks.push(result.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('utf-8', { fatal: true }).decode(bytes)); + } catch { + throw invalidProviderResponse(); + } +} + +function normalizeAuthoritativeSubscription(payload, requestedSubscriptionId, organizationId) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw invalidProviderResponse(); + } + if (payload.id !== requestedSubscriptionId || payload.object !== 'subscription') { + throw invalidProviderResponse(); + } + + const customerId = providerIdentifier(payload.customer); + if (!STRIPE_SUBSCRIPTION_STATUSES.has(payload.status)) throw invalidProviderResponse(); + if (typeof payload.cancel_at_period_end !== 'boolean') throw invalidProviderResponse(); + + const metadata = payload.metadata; + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + throw tenantMismatch(); + } + if (Object.hasOwn(metadata, 'orgId') && typeof metadata.orgId !== 'string') { + throw invalidProviderResponse(); + } + if (metadata.orgId !== String(organizationId)) throw tenantMismatch(); + + const currentPeriodStartSec = nonNegativeTimestamp(payload.current_period_start); + const currentPeriodEndSec = nonNegativeTimestamp(payload.current_period_end); + if (currentPeriodEndSec < currentPeriodStartSec) throw invalidProviderResponse(); + + if (!payload.items || typeof payload.items !== 'object' || Array.isArray(payload.items) + || !Array.isArray(payload.items.data) + || payload.items.data.length === 0 + || payload.items.data.length > MAX_SUBSCRIPTION_ITEMS) { + throw invalidProviderResponse(); + } + const priceIds = payload.items.data.map((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item) + || !item.price || typeof item.price !== 'object' || Array.isArray(item.price)) { + throw invalidProviderResponse(); + } + return providerIdentifier(item.price.id); + }); + + const snapshot = { + subscriptionId: requestedSubscriptionId, + customerId, + organizationId, + status: payload.status, + cancelAtPeriodEnd: payload.cancel_at_period_end, + currentPeriodStartSec, + currentPeriodEndSec, + canceledAtSec: nullableTimestamp(payload.canceled_at), + endedAtSec: nullableTimestamp(payload.ended_at), + trialEndSec: nullableTimestamp(payload.trial_end), + latestInvoiceId: nullableProviderIdentifier(payload.latest_invoice), + priceIds: Object.freeze(priceIds), + }; + return Object.freeze(snapshot); +} + +/** + * Fetch and normalize the latest authoritative Stripe Subscription for one tenant. + * + * Stripe webhook deliveries are authenticated evidence and reconciliation triggers, + * not an ordering guarantee. This boundary therefore performs one direct bounded + * provider GET, validates the returned subscription identity, and requires the + * underlying Subscription's `metadata.orgId` to match the ScopeWeave organization + * exactly before returning lifecycle data. It makes no entitlement decision and + * performs no local persistence mutation. + * + * @param {object} input - Provider authority and deterministic dependency seams. + * @param {string|number} input.organizationId - Positive ScopeWeave organization ID. + * @param {string} input.subscriptionId - Stripe `sub_...` identifier to retrieve. + * @param {string} [input.secretKey=process.env.STRIPE_SECRET_KEY] - Server-owned Stripe secret. + * @param {typeof fetch} [input.fetchImpl=globalThis.fetch] - HTTPS transport seam. + * @param {() => AbortSignal} [input.timeoutSignalFactory] - Bounded request signal factory. + * @returns {Promise>} Frozen provider snapshot suitable for a separate reconciliation policy layer. + * @throws {TypeError} For malformed local authority/dependency inputs. + * @throws {StripeSubscriptionProviderError} For sanitized provider, tenant, or response failures. + */ +export async function fetchStripeSubscriptionAuthoritative({ + organizationId, + subscriptionId, + secretKey = process.env.STRIPE_SECRET_KEY, + fetchImpl = globalThis.fetch, + timeoutSignalFactory = () => AbortSignal.timeout(STRIPE_REQUEST_TIMEOUT_MS), +}) { + const organization = positiveInteger(organizationId, 'organizationId'); + const subscription = requiredString(subscriptionId, 'subscriptionId', MAX_PROVIDER_ID_LENGTH); + if (!SUBSCRIPTION_ID_PATTERN.test(subscription)) { + throw new TypeError('subscriptionId must be a Stripe subscription identifier'); + } + const key = requiredString(secretKey, 'secretKey', MAX_SECRET_KEY_LENGTH); + if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function'); + if (typeof timeoutSignalFactory !== 'function') { + throw new TypeError('timeoutSignalFactory must be a function'); + } + + let signal; + try { + signal = timeoutSignalFactory(); + } catch { + throw providerUnavailable(); + } + if (!(signal instanceof AbortSignal)) { + throw new TypeError('timeoutSignalFactory must return an AbortSignal'); + } + + let response; + try { + response = await fetchImpl(`${STRIPE_SUBSCRIPTION_ENDPOINT}${encodeURIComponent(subscription)}`, { + method: 'GET', + redirect: 'error', + signal, + headers: { + authorization: `Bearer ${key}`, + accept: 'application/json', + }, + }); + } catch { + throw providerUnavailable(); + } + + if (!response || typeof response.ok !== 'boolean' || !response.headers) { + throw invalidProviderResponse(); + } + if (!response.ok) { + await cancelProviderBody(response); + if (response.status === 404) throw providerNotFound(); + throw providerUnavailable(); + } + + const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); + if (mediaType !== 'application/json') { + await cancelProviderBody(response); + throw invalidProviderResponse(); + } + + const payload = await readBoundedProviderJson(response); + return normalizeAuthoritativeSubscription(payload, subscription, organization); +} diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index 3e009290..c66f407c 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -119,6 +119,7 @@ test('live checkout binds SDK-style calls to the durable idempotency identity', cancel_url: 'https://planner.example.com/?billing=cancel', client_reference_id: '73', metadata: { orgId: '73' }, + subscription_data: { metadata: { orgId: '73' } }, }, requestOptions: { idempotencyKey: 'idem-test-001' }, }]); @@ -190,6 +191,7 @@ test('default live provider transport sends the persisted Stripe Idempotency-Key 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'); + assert.equal(form.get('subscription_data[metadata][orgId]'), '91'); assert.deepEqual(attemptRepository.events.at(-1), { type: 'success', input: { diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 84759a95..bd17c057 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -59,6 +59,11 @@ assert.match( /--include=server\/stripe_webhook_event_ledger\.mjs/, 'the verified Stripe webhook event ledger is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_subscription_provider\.mjs/, + 'the authoritative Stripe subscription reader is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, @@ -114,6 +119,16 @@ assert.match( /tests\/unit\/stripe-webhook-recorder-integration\.test\.mjs/, 'normal unit CI executes the verified-event recorder integration regression', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-subscription-provider\.test\.mjs/, + 'the authoritative Stripe subscription reader regression executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-subscription-metadata-propagation\.test\.mjs/, + 'the subscription tenant-metadata propagation regression executes under c8', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, diff --git a/tests/unit/stripe-subscription-metadata-propagation.test.mjs b/tests/unit/stripe-subscription-metadata-propagation.test.mjs new file mode 100644 index 00000000..09c5b689 --- /dev/null +++ b/tests/unit/stripe-subscription-metadata-propagation.test.mjs @@ -0,0 +1,101 @@ +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' }; + +function attemptRepository() { + return { + startAttempt() { + return { + attemptId: 'attempt-subscription-metadata', + idempotencyKey: 'idem-subscription-metadata', + state: 'pending', + reused: false, + }; + }, + markProviderSucceeded() {}, + markProviderFailed() {}, + }; +} + +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_subscription_metadata'; + process.env.STRIPE_PRICE_ID = 'price_subscription_metadata'; + 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; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +} + +test('subscription Checkout carries the organization binding onto the created Stripe Subscription', async () => { + await withStripeEnv(async () => { + let observedPayload; + let observedOptions; + const result = await createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository: attemptRepository(), + stripeClientFactory: async () => ({ + checkout: { + sessions: { + async create(payload, options) { + observedPayload = payload; + observedOptions = options; + return { + id: 'cs_test_subscription_metadata', + url: 'https://checkout.stripe.com/c/pay/cs_test_subscription_metadata', + }; + }, + }, + }, + }), + }); + + assert.equal(result.live, true); + assert.equal(observedPayload.client_reference_id, '73'); + assert.deepEqual(observedPayload.metadata, { orgId: '73' }); + assert.deepEqual(observedPayload.subscription_data, { + metadata: { orgId: '73' }, + }); + assert.deepEqual(observedOptions, { + idempotencyKey: 'idem-subscription-metadata', + }); + }); +}); + +test('direct Checkout form sends the tenant binding to the Stripe Subscription transport field', async () => { + await withStripeEnv(async () => { + let observedBody; + globalThis.fetch = async (_url, options) => { + observedBody = options.body; + return new Response(JSON.stringify({ + id: 'cs_test_direct_subscription_metadata', + url: 'https://checkout.stripe.com/c/pay/cs_test_direct_subscription_metadata', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + const result = await createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository: attemptRepository(), + }); + + assert.equal(result.live, true); + const form = new URLSearchParams(observedBody); + assert.equal(form.get('metadata[orgId]'), '73'); + assert.equal(form.get('subscription_data[metadata][orgId]'), '73'); + }); +}); diff --git a/tests/unit/stripe-subscription-provider.test.mjs b/tests/unit/stripe-subscription-provider.test.mjs new file mode 100644 index 00000000..db7a7724 --- /dev/null +++ b/tests/unit/stripe-subscription-provider.test.mjs @@ -0,0 +1,341 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + StripeSubscriptionProviderError, + fetchStripeSubscriptionAuthoritative, +} from '../../server/stripe_subscription_provider.mjs'; + +const baseSubscription = Object.freeze({ + id: 'sub_authoritative123', + object: 'subscription', + customer: 'cus_scopeweave73', + status: 'active', + metadata: { orgId: '73' }, + cancel_at_period_end: false, + current_period_start: 1_800_000_000, + current_period_end: 1_802_592_000, + canceled_at: null, + ended_at: null, + trial_end: null, + latest_invoice: 'in_latest73', + items: { + data: [ + { price: { id: 'price_scopeweave_pro' } }, + { price: { id: 'price_scopeweave_addon' } }, + ], + }, +}); + +function jsonResponse(payload, init = {}) { + const text = JSON.stringify(payload); + return new Response(text, { + status: 200, + ...init, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(Buffer.byteLength(text)), + ...(init.headers || {}), + }, + }); +} + +async function expectProviderError(run, expectedCode) { + await assert.rejects(run, (error) => { + assert.ok(error instanceof StripeSubscriptionProviderError); + assert.equal(error.code, expectedCode); + assert.equal(error.message, expectedCode); + assert.doesNotMatch(error.message, /sk_(?:test|live)|provider body|10\.8\.0\.7/); + return true; + }); +} + +test('authoritative subscription read performs one bounded exact Stripe GET and returns a frozen normalized snapshot', async () => { + const observed = []; + const signal = AbortSignal.abort('test-only'); + const snapshot = await fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: 'sub_authoritative123', + secretKey: 'sk_test_authoritative_read', + fetchImpl: async (url, options) => { + observed.push({ url, options }); + return jsonResponse(baseSubscription); + }, + timeoutSignalFactory: () => signal, + }); + + assert.equal(observed.length, 1); + assert.equal(observed[0].url, 'https://api.stripe.com/v1/subscriptions/sub_authoritative123'); + assert.equal(observed[0].options.method, 'GET'); + assert.equal(observed[0].options.redirect, 'error'); + assert.equal(observed[0].options.signal, signal); + assert.deepEqual(observed[0].options.headers, { + authorization: 'Bearer sk_test_authoritative_read', + accept: 'application/json', + }); + assert.deepEqual(snapshot, { + subscriptionId: 'sub_authoritative123', + customerId: 'cus_scopeweave73', + organizationId: 73, + status: 'active', + cancelAtPeriodEnd: false, + currentPeriodStartSec: 1_800_000_000, + currentPeriodEndSec: 1_802_592_000, + canceledAtSec: null, + endedAtSec: null, + trialEndSec: null, + latestInvoiceId: 'in_latest73', + priceIds: ['price_scopeweave_pro', 'price_scopeweave_addon'], + }); + assert.equal(Object.isFrozen(snapshot), true); + assert.equal(Object.isFrozen(snapshot.priceIds), true); +}); + +test('all current Stripe subscription statuses remain data, not local entitlement decisions', async () => { + const statuses = [ + 'incomplete', + 'incomplete_expired', + 'trialing', + 'active', + 'past_due', + 'canceled', + 'unpaid', + 'paused', + ]; + + for (const status of statuses) { + const snapshot = await fetchStripeSubscriptionAuthoritative({ + organizationId: '73', + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_statuses', + fetchImpl: async () => jsonResponse({ ...baseSubscription, status }), + }); + assert.equal(snapshot.status, status); + assert.equal(snapshot.organizationId, 73); + } +}); + +test('provider metadata is a routing hint only until authoritative tenant binding matches exactly', async () => { + await expectProviderError( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 74, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_tenant_mismatch', + fetchImpl: async () => jsonResponse(baseSubscription), + }), + 'billing_subscription_tenant_mismatch', + ); + + for (const metadata of [null, [], {}, { orgId: '' }, { orgId: '073' }, { orgId: 73 }]) { + await expectProviderError( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_tenant_shape', + fetchImpl: async () => jsonResponse({ ...baseSubscription, metadata }), + }), + metadata && !Array.isArray(metadata) && metadata.orgId === 73 + ? 'billing_subscription_provider_invalid_response' + : 'billing_subscription_tenant_mismatch', + ); + } +}); + +test('malformed or contradictory provider snapshots fail closed before reconciliation can persist them', async () => { + const invalidPayloads = [ + null, + [], + { ...baseSubscription, id: 'sub_other' }, + { ...baseSubscription, object: 'customer' }, + { ...baseSubscription, customer: '' }, + { ...baseSubscription, status: 'mystery' }, + { ...baseSubscription, cancel_at_period_end: 'false' }, + { ...baseSubscription, current_period_start: -1 }, + { ...baseSubscription, current_period_end: Number.MAX_SAFE_INTEGER + 1 }, + { ...baseSubscription, current_period_start: 20, current_period_end: 19 }, + { ...baseSubscription, canceled_at: 'yesterday' }, + { ...baseSubscription, latest_invoice: { id: 'in_expanded' } }, + { ...baseSubscription, items: null }, + { ...baseSubscription, items: { data: [] } }, + { ...baseSubscription, items: { data: [{ price: { id: '' } }] } }, + { ...baseSubscription, items: { data: Array.from({ length: 101 }, () => ({ price: { id: 'price_x' } })) } }, + ]; + + for (const payload of invalidPayloads) { + await expectProviderError( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_invalid_snapshot', + fetchImpl: async () => jsonResponse(payload), + }), + 'billing_subscription_provider_invalid_response', + ); + } +}); + +test('provider failures are sanitized, bodies are not parsed, and missing subscriptions stay distinct from transient failures', async () => { + for (const [status, expectedCode] of [ + [404, 'billing_subscription_provider_not_found'], + [429, 'billing_subscription_provider_unavailable'], + [500, 'billing_subscription_provider_unavailable'], + ]) { + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new TextEncoder().encode('provider body sk_live_secret 10.8.0.7')); + }, + cancel() { + cancelled = true; + }, + }); + await expectProviderError( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_failure', + fetchImpl: async () => new Response(body, { + status, + headers: { 'content-type': 'application/json' }, + }), + }), + expectedCode, + ); + assert.equal(cancelled, true, `HTTP ${status} response body is cancelled without parsing`); + } + + await expectProviderError( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_network', + fetchImpl: async () => { + throw new Error('dial tcp 10.8.0.7 with sk_live_secret'); + }, + }), + 'billing_subscription_provider_unavailable', + ); +}); + +test('successful provider bodies require JSON and remain bounded before parsing', async () => { + await expectProviderError( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_media', + fetchImpl: async () => new Response('no', { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + }), + 'billing_subscription_provider_invalid_response', + ); + + for (const declaredLength of ['not-a-number', '-1', String((256 * 1024) + 1)]) { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{}')); + }, + cancel() { + cancelled = true; + }, + }); + await expectProviderError( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_length', + fetchImpl: async () => new Response(body, { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-length': declaredLength, + }, + }), + }), + 'billing_subscription_provider_invalid_response', + ); + assert.equal(cancelled, true); + } + + let oversizedCancelled = false; + let pullCount = 0; + const oversizedBody = new ReadableStream({ + pull(controller) { + pullCount += 1; + controller.enqueue(new Uint8Array(pullCount === 1 ? 256 * 1024 : 1)); + }, + cancel() { + oversizedCancelled = true; + }, + }); + await expectProviderError( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_stream_bound', + fetchImpl: async () => new Response(oversizedBody, { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + }), + 'billing_subscription_provider_invalid_response', + ); + assert.equal(oversizedCancelled, true); + + await expectProviderError( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_json', + fetchImpl: async () => new Response('{invalid', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + }), + 'billing_subscription_provider_invalid_response', + ); +}); + +test('invalid local authority inputs fail before provider transport', async () => { + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return jsonResponse(baseSubscription); + }; + + for (const input of [ + { organizationId: 0, subscriptionId: baseSubscription.id, secretKey: 'sk_test_local' }, + { organizationId: 73, subscriptionId: '', secretKey: 'sk_test_local' }, + { organizationId: 73, subscriptionId: 'cus_wrong_type', secretKey: 'sk_test_local' }, + { organizationId: 73, subscriptionId: 'sub_bad/slash', secretKey: 'sk_test_local' }, + { organizationId: 73, subscriptionId: baseSubscription.id, secretKey: '' }, + ]) { + await assert.rejects( + () => fetchStripeSubscriptionAuthoritative({ ...input, fetchImpl }), + TypeError, + ); + } + + await assert.rejects( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_local', + fetchImpl: null, + }), + TypeError, + ); + await assert.rejects( + () => fetchStripeSubscriptionAuthoritative({ + organizationId: 73, + subscriptionId: baseSubscription.id, + secretKey: 'sk_test_local', + fetchImpl, + timeoutSignalFactory: null, + }), + TypeError, + ); + assert.equal(calls, 0); +});