diff --git a/CHANGELOG.md b/CHANGELOG.md index 9234de5f..a2217eb5 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 +- Added a bounded authoritative Stripe Invoice reader that verifies exact tenant- + bound Invoice, Customer, and Subscription identities, supports current Basil + and legacy Subscription provenance without trusting metadata alone, bounds + provider transport/response parsing, and returns immutable payment evidence + without granting or persisting entitlement. - Added a deterministic Stripe entitlement-policy boundary over authoritative Subscription and Invoice evidence: paid `active` access requires an exact paid Invoice match, `past_due` never provisions or extends access, terminal states diff --git a/docs/doctoring/stripe-invoice-authoritative-read.md b/docs/doctoring/stripe-invoice-authoritative-read.md new file mode 100644 index 00000000..72eab7d9 --- /dev/null +++ b/docs/doctoring/stripe-invoice-authoritative-read.md @@ -0,0 +1,80 @@ +# Authoritative Stripe Invoice read boundary + +## Status and authority + +**Status: active stacked PR evidence, not protected-`develop` shipped truth.** + +This record belongs to the bounded #488 Invoice-read slice stacked on the current Stripe entitlement-policy branch. Protected `develop` remains the shipped authority until the entire prerequisite stack is independently reviewed, protected-integrated, and revalidated on its final exact heads. + +The slice adds payment evidence only. It does not persist Invoice observations, mutate an organization plan, write entitlement claims, grant session/API capability, or make an Invoice webhook authoritative by arrival order. + +## Buyer and control objective + +The entitlement policy already requires authoritative paid-Invoice evidence before an `active` Stripe Subscription can grant or extend paid access. A webhook payload is insufficient for that decision because delivery can be delayed, duplicated, or reordered. ScopeWeave therefore needs a separate provider-read boundary that retrieves the exact Invoice named by the current authoritative Subscription and verifies its tenant-bound identities before the Invoice can become policy evidence. + +`server/stripe_invoice_provider.mjs` owns that boundary. It requires server-owned organization, Invoice, Subscription, and Customer authority and performs exactly one bounded `GET /v1/invoices/{invoice}` against Stripe's fixed HTTPS API origin. + +## Provider contract + +The reader: + +- accepts only a positive safe-integer ScopeWeave organization ID and bounded `in_...`, `sub_...`, and `cus_...` provider identities; +- uses one hard-coded `https://api.stripe.com/v1/invoices/` GET, `redirect: "error"`, a 15-second abort budget, and no application retry loop; +- sends the server-owned Stripe secret only to that fixed authority; +- requires a successful JSON response and enforces a 256 KiB ceiling from both `Content-Length` and streamed bytes before JSON parsing; +- decodes UTF-8 fatally and maps malformed transport/provider data to stable sanitized application errors; +- distinguishes a provider 404 from transient/unavailable provider failure without exposing response bodies or network diagnostics; +- verifies exact Invoice ID, Customer ID, and Subscription ID before returning evidence; and +- returns an immutable normalized payment fact containing only bounded lifecycle, amount, currency, and timestamp fields needed by the later policy/persistence layers. + +No response field can choose another ScopeWeave tenant. Subscription metadata is supplementary mismatch evidence only; exact server-owned Customer and Subscription identities remain mandatory. + +## Stripe API-version compatibility boundary + +ScopeWeave's current direct Stripe REST adapters intentionally inherit the Stripe account's configured default API version rather than silently pinning a new provider version inside one feature slice. That makes Invoice provenance shape an explicit compatibility concern. + +Stripe's `2025-03-31.basil` breaking change introduced `invoice.parent` and moved Subscription provenance from the deprecated top-level `invoice.subscription` / `invoice.subscription_details` fields to `invoice.parent.subscription_details.subscription`, after verifying `invoice.parent.type === "subscription_details"`. The reader therefore accepts both generations: + +- current Basil-style `parent.subscription_details.subscription`; and +- legacy pre-Basil top-level `subscription` plus optional `subscription_details.metadata`. + +If both shapes are present, they must identify the same Subscription. A non-Subscription parent, malformed parent/details object, missing Subscription identity, or disagreement between old and new shapes fails closed. A future provider representation outside these validated contracts also fails closed. Explicit `Stripe-Version` migration remains a separately tested operator compatibility change with rollback evidence rather than an implicit behavior change here. + +## Invoice lifecycle evidence + +Stripe documents Invoice statuses `draft`, `open`, `paid`, `uncollectible`, and `void`, with payment moving an Invoice to `paid`. The reader accepts only those states and requires the provider `paid` boolean to agree with `status === "paid"`. It also requires `status_transitions.paid_at` exactly when the Invoice is paid. This catches contradictory provider representations before they reach entitlement policy. + +Currency must be a three-letter lowercase code. `amount_due`, `amount_paid`, `amount_remaining`, `created`, and any `paid_at` timestamp must be non-negative safe integers. This slice preserves the provider amounts as evidence and does not infer tax, revenue recognition, refund, chargeback, or accounting policy from them. + +## TDD and executable evidence + +Test-only commit `a83b002210c2448f7cdfaaab94b506ab1c581473` registered `tests/unit/stripe-invoice-provider.test.mjs` before the production module existed, so the new contract initially failed at module resolution rather than obtaining a false green. + +The completed focused regression set exercises: + +- one exact HTTPS GET and bounded dependency seams; +- current Basil and legacy pre-Basil Subscription provenance; +- conflicting dual-shape provenance; +- exact Customer, Subscription, and optional tenant-metadata mismatch rejection; +- lifecycle/status/paid/paid-at contradictions; +- malformed identifiers, currency, amounts, timestamps, media type, JSON, and provider envelopes; +- 404, transient failure, stream-read failure, declared oversize, streamed oversize, and cancellation failure; and +- sanitized errors that never echo provider bodies, credentials, or network diagnostics. + +Private focused execution after implementation produced 100% line, branch, and function coverage for `server/stripe_invoice_provider.mjs`. Hosted repository-native CI, security, dependency, supply-chain, and review evidence remains authoritative for PR integration and must be regenerated on the unchanged final contributor head. + +## Privacy, rollback, and recovery + +The boundary returns no customer email, postal address, hosted Invoice URL, payment method, secret, raw response, or arbitrary metadata. The expected tenant IDs are already server-owned routing authority; optional `orgId` metadata is used only to detect contradiction. + +Rollback removes the Invoice reader, its focused tests/coverage registration, this doctoring record, and the matching Unreleased changelog entry together. It does not require a database migration because this slice introduces no persisted Invoice relation or entitlement mutation. A provider-read failure therefore leaves all existing entitlement state untouched and available for later operator reconciliation. + +## References + +Stripe. (n.d.). *Retrieve an invoice*. Stripe API Reference. https://docs.stripe.com/api/invoices/retrieve + +Stripe. (n.d.). *The Invoice object*. Stripe API Reference. https://docs.stripe.com/api/invoices/object + +Stripe. (2025, March 31). *Invoicing resources now specify how they were generated*. Stripe Documentation. https://docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects + +Stripe. (n.d.). *Status transitions and finalization*. Stripe Documentation. https://docs.stripe.com/invoicing/integration/workflow-transitions diff --git a/package.json b/package.json index 72c50c32..f60d2174 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/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.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 --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.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 && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.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/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.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 --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_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 && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.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/stripe_invoice_provider.mjs b/server/stripe_invoice_provider.mjs new file mode 100644 index 00000000..855598c8 --- /dev/null +++ b/server/stripe_invoice_provider.mjs @@ -0,0 +1,246 @@ +const STRIPE_INVOICE_ENDPOINT = 'https://api.stripe.com/v1/invoices/'; +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 INVOICE_ID_PATTERN = /^in_[A-Za-z0-9_]+$/u; +const SUBSCRIPTION_ID_PATTERN = /^sub_[A-Za-z0-9_]+$/u; +const CUSTOMER_ID_PATTERN = /^cus_[A-Za-z0-9_]+$/u; +const CURRENCY_PATTERN = /^[a-z]{3}$/u; +const STRIPE_INVOICE_STATUSES = new Set(['draft', 'open', 'paid', 'uncollectible', 'void']); + +/** + * Stable sanitized failure from the authoritative Stripe Invoice read boundary. + * Provider response bodies, network diagnostics, credentials, and tenant details + * are deliberately excluded from the public message. + */ +export class StripeInvoiceProviderError extends Error { + /** @param {string} code - Stable ScopeWeave invoice-provider error code. */ + constructor(code) { + super(code); + this.name = 'StripeInvoiceProviderError'; + this.code = code; + } +} + +function providerError(code) { return new StripeInvoiceProviderError(code); } +function invalidProviderResponse() { return providerError('billing_invoice_provider_invalid_response'); } +function providerUnavailable() { return providerError('billing_invoice_provider_unavailable'); } +function providerNotFound() { return providerError('billing_invoice_provider_not_found'); } +function tenantMismatch() { return providerError('billing_invoice_tenant_mismatch'); } + +function positiveSafeInteger(value, name) { + if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive safe integer`); + return value; +} + +function localIdentifier(value, name, pattern) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PROVIDER_ID_LENGTH || !pattern.test(value)) { + throw new TypeError(`${name} must be a bounded Stripe identifier`); + } + return value; +} + +function secretKey(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SECRET_KEY_LENGTH) { + throw new TypeError(`secretKey must be a non-empty string no longer than ${MAX_SECRET_KEY_LENGTH} characters`); + } + return value; +} + +function providerIdentifier(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PROVIDER_ID_LENGTH) throw invalidProviderResponse(); + return value; +} + +function nonNegativeSafeInteger(value) { + if (!Number.isSafeInteger(value) || value < 0) throw invalidProviderResponse(); + return value; +} + +function nullableTimestamp(value) { + if (value === null) return null; + return nonNegativeSafeInteger(value); +} + +async function cancelProviderBody(response) { + try { await response.body?.cancel(); } catch { /* best-effort cleanup */ } +} + +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) { + await reader.cancel().catch(() => undefined); + 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 metadataOrganization(metadata, organizationId) { + if (metadata == null) return; + if (typeof metadata !== 'object' || Array.isArray(metadata)) throw invalidProviderResponse(); + if (!Object.hasOwn(metadata, 'orgId')) return; + if (typeof metadata.orgId !== 'string') throw invalidProviderResponse(); + if (metadata.orgId !== String(organizationId)) throw tenantMismatch(); +} + +function subscriptionIdentity(payload, organizationId) { + let currentId = null; + if (payload.parent != null) { + if (typeof payload.parent !== 'object' || Array.isArray(payload.parent) || payload.parent.type !== 'subscription_details') { + throw invalidProviderResponse(); + } + const details = payload.parent.subscription_details; + if (!details || typeof details !== 'object' || Array.isArray(details)) throw invalidProviderResponse(); + currentId = providerIdentifier(details.subscription); + metadataOrganization(details.metadata, organizationId); + } + + let legacyId = null; + if (payload.subscription != null) legacyId = providerIdentifier(payload.subscription); + if (payload.subscription_details != null) { + if (typeof payload.subscription_details !== 'object' || Array.isArray(payload.subscription_details)) throw invalidProviderResponse(); + metadataOrganization(payload.subscription_details.metadata, organizationId); + } + if (currentId && legacyId && currentId !== legacyId) throw invalidProviderResponse(); + const resolved = currentId ?? legacyId; + if (!resolved) throw invalidProviderResponse(); + return resolved; +} + +function normalizeAuthoritativeInvoice(payload, authority) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw invalidProviderResponse(); + if (payload.id !== authority.invoiceId || payload.object !== 'invoice') throw invalidProviderResponse(); + if (providerIdentifier(payload.customer) !== authority.customerId) throw tenantMismatch(); + + const subscriptionId = subscriptionIdentity(payload, authority.organizationId); + if (subscriptionId !== authority.subscriptionId) throw tenantMismatch(); + if (typeof payload.status !== 'string' || !STRIPE_INVOICE_STATUSES.has(payload.status)) throw invalidProviderResponse(); + if (typeof payload.paid !== 'boolean' || payload.paid !== (payload.status === 'paid')) throw invalidProviderResponse(); + if (typeof payload.currency !== 'string' || !CURRENCY_PATTERN.test(payload.currency)) throw invalidProviderResponse(); + + if (!payload.status_transitions || typeof payload.status_transitions !== 'object' || Array.isArray(payload.status_transitions)) { + throw invalidProviderResponse(); + } + const paidAtSec = nullableTimestamp(payload.status_transitions.paid_at); + if ((payload.status === 'paid') !== (paidAtSec !== null)) throw invalidProviderResponse(); + + return Object.freeze({ + invoiceId: authority.invoiceId, + subscriptionId, + customerId: authority.customerId, + organizationId: authority.organizationId, + status: payload.status, + paid: payload.paid, + currency: payload.currency, + amountDue: nonNegativeSafeInteger(payload.amount_due), + amountPaid: nonNegativeSafeInteger(payload.amount_paid), + amountRemaining: nonNegativeSafeInteger(payload.amount_remaining), + createdSec: nonNegativeSafeInteger(payload.created), + paidAtSec, + }); +} + +/** + * Fetch and normalize one authoritative Stripe Invoice for a known tenant-bound + * customer and Subscription. + * + * The request deliberately follows the Stripe account's configured API version. + * Current Basil invoices identify their generating Subscription under + * `parent.subscription_details.subscription`; older accounts can still expose the + * legacy top-level `subscription` field. Both shapes are accepted only when they + * agree, while server-owned customer/Subscription authority remains mandatory. + * This boundary returns payment evidence only and never grants local entitlement. + * + * @param {object} input - Tenant authority and deterministic transport seams. + * @param {number} input.organizationId - Positive ScopeWeave organization ID. + * @param {string} input.invoiceId - Exact Stripe `in_...` Invoice ID. + * @param {string} input.subscriptionId - Exact tenant-bound Stripe `sub_...` ID. + * @param {string} input.customerId - Exact tenant-bound Stripe `cus_...` ID. + * @param {string} [input.secretKey=process.env.STRIPE_SECRET_KEY] - Server-owned provider secret. + * @param {typeof fetch} [input.fetchImpl=globalThis.fetch] - HTTPS transport seam. + * @param {() => AbortSignal} [input.timeoutSignalFactory] - Bounded request signal factory. + * @returns {Promise>} Frozen normalized Invoice evidence for policy evaluation. + * @throws {TypeError} For malformed local authority or dependency inputs. + * @throws {StripeInvoiceProviderError} For sanitized provider, response, or tenant failures. + */ +export async function fetchStripeInvoiceAuthoritative({ + organizationId, + invoiceId, + subscriptionId, + customerId, + secretKey: secret = process.env.STRIPE_SECRET_KEY, + fetchImpl = globalThis.fetch, + timeoutSignalFactory = () => AbortSignal.timeout(STRIPE_REQUEST_TIMEOUT_MS), +}) { + const authority = Object.freeze({ + organizationId: positiveSafeInteger(organizationId, 'organizationId'), + invoiceId: localIdentifier(invoiceId, 'invoiceId', INVOICE_ID_PATTERN), + subscriptionId: localIdentifier(subscriptionId, 'subscriptionId', SUBSCRIPTION_ID_PATTERN), + customerId: localIdentifier(customerId, 'customerId', CUSTOMER_ID_PATTERN), + }); + const key = secretKey(secret); + 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_INVOICE_ENDPOINT}${encodeURIComponent(authority.invoiceId)}`, { + 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(); + } + + return normalizeAuthoritativeInvoice(await readBoundedProviderJson(response), authority); +} diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index b09ecf3e..2faa8b9a 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -79,6 +79,11 @@ assert.match( /--include=server\/stripe_entitlement_policy\.mjs/, 'the monotonic Stripe entitlement policy is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_invoice_provider\.mjs/, + 'the authoritative Stripe invoice reader is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, @@ -194,6 +199,26 @@ assert.match( /tests\/unit\/stripe-entitlement-policy-duplicate-claims\.test\.mjs/, 'normal unit CI executes duplicate subscription-claim corruption regression', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-invoice-provider\.test\.mjs/, + 'the authoritative Stripe invoice reader regression executes under c8', +); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/stripe-invoice-provider-edge\.test\.mjs/, + 'the authoritative Stripe invoice edge regression executes under c8', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-invoice-provider\.test\.mjs/, + 'normal unit CI executes the authoritative Stripe invoice reader regression', +); +assert.match( + scripts['test:unit'], + /tests\/unit\/stripe-invoice-provider-edge\.test\.mjs/, + 'normal unit CI executes the authoritative Stripe invoice edge regression', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, diff --git a/tests/unit/stripe-invoice-provider-edge.test.mjs b/tests/unit/stripe-invoice-provider-edge.test.mjs new file mode 100644 index 00000000..bbe7ac66 --- /dev/null +++ b/tests/unit/stripe-invoice-provider-edge.test.mjs @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + StripeInvoiceProviderError, + fetchStripeInvoiceAuthoritative, +} from '../../server/stripe_invoice_provider.mjs'; + +const EXPECTED = Object.freeze({ + organizationId: 42, + invoiceId: 'in_invoice_42', + subscriptionId: 'sub_scopeweave_42', + customerId: 'cus_scopeweave_42', + secretKey: 'sk_test_scopeweave_invoice_reader', +}); + +function currentInvoice(overrides = {}) { + return { + id: EXPECTED.invoiceId, + object: 'invoice', + customer: EXPECTED.customerId, + status: 'paid', + paid: true, + currency: 'krw', + amount_due: 29000, + amount_paid: 29000, + amount_remaining: 0, + created: 1_786_000_000, + status_transitions: { paid_at: 1_786_000_100 }, + parent: { + type: 'subscription_details', + subscription_details: { + subscription: EXPECTED.subscriptionId, + metadata: { orgId: String(EXPECTED.organizationId) }, + }, + }, + ...overrides, + }; +} + +function jsonResponse(payload) { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function expectProviderCode(code) { + return (error) => { + assert.ok(error instanceof StripeInvoiceProviderError); + assert.equal(error.code, code); + return true; + }; +} + +test('edge contracts cover malformed provider envelopes, stream failures, and compatibility branches', async () => { + const invalid = expectProviderCode('billing_invoice_provider_invalid_response'); + const malformedPayloads = [ + null, + [], + { ...currentInvoice(), id: 'in_other' }, + { ...currentInvoice(), object: 'charge' }, + { ...currentInvoice(), customer: null }, + { ...currentInvoice(), parent: [] }, + { ...currentInvoice(), parent: { type: 'subscription_details', subscription_details: null } }, + { ...currentInvoice(), parent: { type: 'subscription_details', subscription_details: { subscription: null, metadata: {} } } }, + { ...currentInvoice(), parent: undefined, subscription: undefined, subscription_details: undefined }, + { ...currentInvoice(), parent: undefined, subscription: EXPECTED.subscriptionId, subscription_details: [] }, + { ...currentInvoice(), parent: undefined, subscription: EXPECTED.subscriptionId, subscription_details: { metadata: 'bad' } }, + { ...currentInvoice(), parent: undefined, subscription: EXPECTED.subscriptionId, subscription_details: { metadata: { orgId: 42 } } }, + { ...currentInvoice(), status_transitions: null }, + { ...currentInvoice(), status_transitions: [] }, + { ...currentInvoice(), paid: 'yes' }, + ]; + for (const payload of malformedPayloads) { + await assert.rejects(fetchStripeInvoiceAuthoritative({ ...EXPECTED, fetchImpl: async () => jsonResponse(payload) }), invalid); + } + + const dualShape = currentInvoice({ + parent: { type: 'subscription_details', subscription_details: { subscription: EXPECTED.subscriptionId, metadata: null } }, + subscription: EXPECTED.subscriptionId, + }); + const dualSnapshot = await fetchStripeInvoiceAuthoritative({ ...EXPECTED, fetchImpl: async () => jsonResponse(dualShape) }); + assert.equal(dualSnapshot.subscriptionId, EXPECTED.subscriptionId); + + const openInvoice = currentInvoice({ status: 'open', paid: false, status_transitions: { paid_at: null }, amount_paid: 0, amount_remaining: 29000 }); + const openSnapshot = await fetchStripeInvoiceAuthoritative({ ...EXPECTED, fetchImpl: async () => jsonResponse(openInvoice) }); + assert.equal(openSnapshot.status, 'open'); + assert.equal(openSnapshot.paidAtSec, null); + + const encoded = JSON.stringify(currentInvoice()); + const encodedBytes = new TextEncoder().encode(encoded); + const declared = await fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response(encodedBytes, { + headers: { 'content-type': 'application/json; charset=utf-8', 'content-length': String(encodedBytes.byteLength) }, + }), + }); + assert.equal(declared.invoiceId, EXPECTED.invoiceId); + + for (const contentLength of ['nope', '-1']) { + await assert.rejects(fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response('{}', { headers: { 'content-type': 'application/json', 'content-length': contentLength } }), + }), invalid); + } + + await assert.rejects(fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response(null, { headers: { 'content-type': 'application/json' } }), + }), invalid); + await assert.rejects(fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response('{', { headers: { 'content-type': 'application/json' } }), + }), invalid); + + const readFailureStream = new ReadableStream({ pull(controller) { controller.error(new Error('read failure')); } }); + await assert.rejects(fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response(readFailureStream, { headers: { 'content-type': 'application/json' } }), + }), invalid); + + const oversizedStream = new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(256 * 1024 + 1)); }, + cancel() { throw new Error('cancel failure'); }, + }); + await assert.rejects(fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response(oversizedStream, { headers: { 'content-type': 'application/json' } }), + }), invalid); + + let oversizedRead = false; + const rejectingCancelResponse = { + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + body: { + getReader() { + return { + async read() { + if (oversizedRead) return { done: true, value: undefined }; + oversizedRead = true; + return { done: false, value: new Uint8Array(256 * 1024 + 1) }; + }, + async cancel() { throw new Error('reader cancel failure'); }, + }; + }, + }, + }; + await assert.rejects(fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => rejectingCancelResponse, + }), invalid); + + const cancelFailureStream = new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode('not json')); }, + cancel() { throw new Error('cancel failure'); }, + }); + await assert.rejects(fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response(cancelFailureStream, { headers: { 'content-type': 'text/plain' } }), + }), invalid); + + await assert.rejects(fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response(null, { status: 500 }), + }), expectProviderCode('billing_invoice_provider_unavailable')); + await assert.rejects(fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => ({ ok: true, headers: null }), + }), invalid); + await assert.rejects(fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response('{}', { headers: {} }), + }), invalid); + await assert.rejects(fetchStripeInvoiceAuthoritative({ ...EXPECTED, fetchImpl: async () => jsonResponse({ ...currentInvoice(), amount_due: null }) }), invalid); + await assert.rejects(fetchStripeInvoiceAuthoritative({ ...EXPECTED, timeoutSignalFactory: null }), TypeError); +}); diff --git a/tests/unit/stripe-invoice-provider.test.mjs b/tests/unit/stripe-invoice-provider.test.mjs new file mode 100644 index 00000000..5da316c6 --- /dev/null +++ b/tests/unit/stripe-invoice-provider.test.mjs @@ -0,0 +1,256 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + StripeInvoiceProviderError, + fetchStripeInvoiceAuthoritative, +} from '../../server/stripe_invoice_provider.mjs'; + +const EXPECTED = Object.freeze({ + organizationId: 42, + invoiceId: 'in_invoice_42', + subscriptionId: 'sub_scopeweave_42', + customerId: 'cus_scopeweave_42', + secretKey: 'sk_test_scopeweave_invoice_reader', +}); + +function currentInvoice(overrides = {}) { + return { + id: EXPECTED.invoiceId, + object: 'invoice', + customer: EXPECTED.customerId, + status: 'paid', + paid: true, + currency: 'krw', + amount_due: 29000, + amount_paid: 29000, + amount_remaining: 0, + created: 1_786_000_000, + status_transitions: { paid_at: 1_786_000_100 }, + parent: { + type: 'subscription_details', + subscription_details: { + subscription: EXPECTED.subscriptionId, + metadata: { orgId: String(EXPECTED.organizationId) }, + }, + }, + ...overrides, + }; +} + +function jsonResponse(payload, init = {}) { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json', ...(init.headers || {}) }, + ...init, + }); +} + +function expectProviderCode(code) { + return (error) => { + assert.ok(error instanceof StripeInvoiceProviderError); + assert.equal(error.code, code); + assert.equal(error.message, code); + return true; + }; +} + +test('authoritative invoice read uses one exact bounded Stripe GET and returns an immutable current-shape snapshot', async () => { + const calls = []; + const snapshot = await fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + timeoutSignalFactory: () => AbortSignal.timeout(1_000), + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return jsonResponse(currentInvoice()); + }, + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, `https://api.stripe.com/v1/invoices/${EXPECTED.invoiceId}`); + assert.equal(calls[0].init.method, 'GET'); + assert.equal(calls[0].init.redirect, 'error'); + assert.ok(calls[0].init.signal instanceof AbortSignal); + assert.equal(calls[0].init.headers.authorization, `Bearer ${EXPECTED.secretKey}`); + assert.equal(calls[0].init.headers.accept, 'application/json'); + assert.equal(Object.hasOwn(calls[0].init.headers, 'Stripe-Version'), false); + assert.deepEqual(snapshot, { + invoiceId: EXPECTED.invoiceId, + subscriptionId: EXPECTED.subscriptionId, + customerId: EXPECTED.customerId, + organizationId: EXPECTED.organizationId, + status: 'paid', + paid: true, + currency: 'krw', + amountDue: 29000, + amountPaid: 29000, + amountRemaining: 0, + createdSec: 1_786_000_000, + paidAtSec: 1_786_000_100, + }); + assert.ok(Object.isFrozen(snapshot)); +}); + +test('reader accepts the legacy pre-Basil subscription shape without weakening exact identity checks', async () => { + const legacy = currentInvoice({ + parent: undefined, + subscription: EXPECTED.subscriptionId, + subscription_details: { metadata: { orgId: String(EXPECTED.organizationId) } }, + }); + const snapshot = await fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => jsonResponse(legacy), + }); + assert.equal(snapshot.subscriptionId, EXPECTED.subscriptionId); + + await assert.rejects( + fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => jsonResponse(currentInvoice({ + subscription: 'sub_conflicting_legacy', + })), + }), + expectProviderCode('billing_invoice_provider_invalid_response'), + ); +}); + +test('customer, subscription, and available tenant metadata must match server-owned authority exactly', async () => { + for (const payload of [ + currentInvoice({ customer: 'cus_other_tenant' }), + currentInvoice({ parent: { + type: 'subscription_details', + subscription_details: { + subscription: 'sub_other_tenant', + metadata: { orgId: String(EXPECTED.organizationId) }, + }, + } }), + currentInvoice({ parent: { + type: 'subscription_details', + subscription_details: { + subscription: EXPECTED.subscriptionId, + metadata: { orgId: '9001' }, + }, + } }), + ]) { + await assert.rejects( + fetchStripeInvoiceAuthoritative({ ...EXPECTED, fetchImpl: async () => jsonResponse(payload) }), + expectProviderCode('billing_invoice_tenant_mismatch'), + ); + } + + const withoutMetadata = currentInvoice({ parent: { + type: 'subscription_details', + subscription_details: { subscription: EXPECTED.subscriptionId, metadata: {} }, + } }); + const accepted = await fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => jsonResponse(withoutMetadata), + }); + assert.equal(accepted.organizationId, EXPECTED.organizationId); +}); + +test('invoice lifecycle and arithmetic contradictions fail closed before policy evidence is returned', async () => { + const invalidPayloads = [ + currentInvoice({ status: 'paid', paid: false }), + currentInvoice({ status: 'open', paid: true, status_transitions: { paid_at: null } }), + currentInvoice({ status: 'paid', status_transitions: { paid_at: null } }), + currentInvoice({ status: 'open', paid: false, status_transitions: { paid_at: 1_786_000_100 } }), + currentInvoice({ status: 'mystery' }), + currentInvoice({ currency: 'KRW' }), + currentInvoice({ amount_due: -1 }), + currentInvoice({ amount_paid: Number.MAX_SAFE_INTEGER + 1 }), + currentInvoice({ amount_remaining: -1 }), + currentInvoice({ created: -1 }), + currentInvoice({ parent: { type: 'quote_details', quote_details: { quote: 'qt_1' } } }), + ]; + + for (const payload of invalidPayloads) { + await assert.rejects( + fetchStripeInvoiceAuthoritative({ ...EXPECTED, fetchImpl: async () => jsonResponse(payload) }), + expectProviderCode('billing_invoice_provider_invalid_response'), + ); + } +}); + +test('provider failures, media type, and response bytes are bounded and sanitized', async () => { + await assert.rejects( + fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => { throw new Error('socket includes secret'); }, + }), + expectProviderCode('billing_invoice_provider_unavailable'), + ); + + await assert.rejects( + fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response('missing', { status: 404 }), + }), + expectProviderCode('billing_invoice_provider_not_found'), + ); + + await assert.rejects( + fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response('provider secret body', { status: 503 }), + }), + expectProviderCode('billing_invoice_provider_unavailable'), + ); + + await assert.rejects( + fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response('{}', { status: 200, headers: { 'content-type': 'text/plain' } }), + }), + expectProviderCode('billing_invoice_provider_invalid_response'), + ); + + await assert.rejects( + fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl: async () => new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json', 'content-length': String(256 * 1024 + 1) }, + }), + }), + expectProviderCode('billing_invoice_provider_invalid_response'), + ); +}); + +test('malformed local invoice authority and dependency seams fail before provider transport', async () => { + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return jsonResponse(currentInvoice()); + }; + const invalidInputs = [ + { organizationId: 0 }, + { organizationId: '42' }, + { invoiceId: 'pi_wrong_kind' }, + { subscriptionId: 'in_wrong_kind' }, + { customerId: '' }, + { secretKey: '' }, + ]; + + for (const patch of invalidInputs) { + await assert.rejects(fetchStripeInvoiceAuthoritative({ ...EXPECTED, fetchImpl, ...patch }), TypeError); + } + assert.equal(calls, 0); + + await assert.rejects( + fetchStripeInvoiceAuthoritative({ ...EXPECTED, fetchImpl: null }), + TypeError, + ); + await assert.rejects( + fetchStripeInvoiceAuthoritative({ ...EXPECTED, fetchImpl, timeoutSignalFactory: () => ({}) }), + TypeError, + ); + await assert.rejects( + fetchStripeInvoiceAuthoritative({ + ...EXPECTED, + fetchImpl, + timeoutSignalFactory: () => { throw new Error('clock failure'); }, + }), + expectProviderCode('billing_invoice_provider_unavailable'), + ); +});