diff --git a/docs/doctoring/stripe-reconciliation-evidence-export.md b/docs/doctoring/stripe-reconciliation-evidence-export.md new file mode 100644 index 00000000..000b5e27 --- /dev/null +++ b/docs/doctoring/stripe-reconciliation-evidence-export.md @@ -0,0 +1,49 @@ +# Stripe reconciliation evidence export + +Status: **active PR only**. This document describes the bounded evidence-export slice on PR #582. It is not protected-`develop` shipped truth until the prerequisite #488 stack is integrated and the then-current required gates pass on the exact integrated head. + +## Buyer and operator decision + +Use `GET /api/orgs/:organizationId/billing/reconciliation/evidence` when a workspace owner or administrator needs a portable reconciliation record for incident review, diligence, or customer/auditor evidence. The response is an attachment-oriented JSON document and is deliberately read-only. If an Event is still `pending` or `processing`, inspect the corresponding reconciliation queue before taking a recovery action. If it is `dead_letter`, use the separately authorized recovery endpoint rather than editing evidence state. + +## Authority and privacy boundary + +The export derives tenant authority only through the persisted `billing_stripe_subscriptions -> billing_stripe_customers -> organization_id` relationship. The URL tenant identifier never substitutes provider or persisted tenant authority. Authentication plus owner/admin membership is required before export. + +The exported document contains bounded reconciliation facts needed to understand what ScopeWeave observed and attempted: Stripe Event and Subscription identifiers, Event type and provider creation time, the stored payload SHA-256, local receipt/queue/completion timing, processing state, attempt history, recovery history, stable error codes, and entitlement claim-decision linkage when present. + +The export does **not** include raw webhook payloads, Stripe credentials, active worker lease-token hashes, or plaintext operator recovery references. A recovery reference is exported only as a SHA-256 correlation value so an authorized operator who already possesses the reference can compare it without copying the free-form reference into a portable artifact. The response uses `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, a JSON content type, and an attachment disposition. + +Every successful disclosure is durably access-logged before response bytes are released. ScopeWeave serializes the report once, records the document's SHA-256 as `evidenceDocumentSha256` together with its schema version and Event count, and then returns those exact serialized bytes. This binds the audit event to the specific portable artifact without copying its private contents into the audit stream. If the audit write fails, disclosure fails closed instead of returning an unaudited evidence document. + +These choices implement purpose-bound access and data minimization rather than indiscriminate masking. NIST Privacy Framework Control-P calls for managing data at sufficient granularity to manage privacy risk, and CT.DM-P8 specifically connects audit/log records with data minimization. The export therefore keeps decision-relevant provenance while excluding unrelated secret or free-form payload material. + +## Boundedness and failure behavior + +- `limit` defaults to 50 Events and cannot exceed 100. +- Event selection is tenant-scoped and bounded before nested history is loaded. +- The combined selected attempt/recovery history is capped at 1,000 rows before materialization; larger exports fail closed with HTTP-compatible `413` and `stripe_reconciliation_evidence_export_too_large`. +- Persisted identifiers, hashes, times, states, outcomes, and stable error codes are revalidated during export. Corrupt or contradictory evidence fails closed instead of being serialized as authoritative audit evidence. +- The selected Event rows and their nested attempt/recovery histories are read under one SQLite snapshot so concurrent worker progress cannot create a mixed-time artifact. +- An unknown tenant identifier produces no cross-tenant evidence. The HTTP route separately hides workspace membership with the existing not-found boundary. +- A successful response is emitted only after its audit event has been persisted; an unavailable audit sink returns `stripe_reconciliation_evidence_export_audit_failed` and no evidence document. + +## Why immutable Event evidence is retained + +Stripe documents that webhook deliveries can be duplicated, retried, and delivered out of order. It recommends tracking processed Event IDs to prevent duplicate processing, and its undelivered-Event guidance notes that automatic retries can continue while operators manually process events. ScopeWeave therefore exports durable Event identity, ordering/provenance timestamps, processing state, and attempt/recovery evidence rather than treating receipt order as entitlement authority. Authoritative reconciliation remains responsible for re-reading current provider state. + +## Acceptance evidence + +The implementation was developed test-first on the existing #488 stack. RED evidence first proved the repository module was absent and then proved the authenticated HTTP route was absent. Later realistic regressions cover recovery/attempt linkage, append-only history completeness, contradictory job/attempt lifecycle state, lease consistency, one-snapshot concurrent reads, nested-evidence bounds, audit-before-disclosure, and exact exported-document digest binding. + +The repository's normal unit/API suite, browser cloud E2E, dependency review, and OSV scan have produced useful behavioral evidence on PR-associated revisions, but synthetic pull-request merge checkouts are **not** exact-head merge authorization. Exact-head Server Tests control remains owned by the repository-wide CI repair path (#523), and centrally reusable SAST/Security exact-head attestation remains on its separate organization-owned repair path. Current-head evidence must be regenerated after every contributor-head change; queued, cancelled, predecessor, synthetic-only, absent, or model-only results are non-passing. + +The production export module and route are explicitly included by the canonical coverage contract, and the unit/API regressions are registered in the canonical test scripts. No release or certification claim is made by this slice. + +## References + +National Institute of Standards and Technology. (2020). *NIST privacy framework: A tool for improving privacy through enterprise risk management, version 1.0*. https://www.nist.gov/privacy-framework + +Stripe. (n.d.). *Process undelivered webhook events*. https://docs.stripe.com/webhooks/process-undelivered-events + +Stripe. (n.d.). *Receive Stripe events in your webhook endpoint*. https://docs.stripe.com/webhooks diff --git a/package.json b/package.json index 25593cae..202ebf5a 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/stripe-reconciliation-dead-letter-recovery.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.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/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap-package-contract.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.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/billing_status_response.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 --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --include=server/stripe_billing_reconciliation.mjs --include=server/stripe_webhook_reconciliation_queue.mjs --include=server/stripe_checkout_identity_bootstrap.mjs --include=server/stripe_reconciliation_worker.mjs --include=server/stripe_reconciliation_recovery.mjs --include=server/stripe_reconciliation_recovery_routes.mjs --include=server/stripe_reconciliation_scheduler.mjs --include=server/server_runtime.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.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/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && npm run test:api", + "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/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/api/stripe-reconciliation-evidence-export.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.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/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap-package-contract.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.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/billing_status_response.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 --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --include=server/stripe_billing_reconciliation.mjs --include=server/stripe_webhook_reconciliation_queue.mjs --include=server/stripe_checkout_identity_bootstrap.mjs --include=server/stripe_reconciliation_worker.mjs --include=server/stripe_reconciliation_recovery.mjs --include=server/stripe_reconciliation_evidence_export.mjs --include=server/stripe_reconciliation_recovery_routes.mjs --include=server/stripe_reconciliation_scheduler.mjs --include=server/server_runtime.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-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.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/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.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_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs new file mode 100644 index 00000000..bca912ce --- /dev/null +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -0,0 +1,419 @@ +import { createHash } from 'node:crypto'; + +const DEFAULT_EVENT_LIMIT = 50; +const MAX_EVENT_LIMIT = 100; +const MAX_NESTED_EVIDENCE_ROWS = 1_000; +const MAX_PROVIDER_ID_LENGTH = 255; +const MAX_EVENT_TYPE_LENGTH = 255; +const MAX_ERROR_CODE_LENGTH = 96; +const MAX_EVIDENCE_REFERENCE_LENGTH = 256; +const PROVIDER_IDENTIFIER_PATTERN = /^[A-Za-z0-9_:-]+$/u; +const ERROR_CODE_PATTERN = /^[a-z0-9_:-]+$/u; +const PAYLOAD_SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const PROCESSING_STATES = new Set(['pending', 'processing', 'succeeded', 'dead_letter']); +const ATTEMPT_OUTCOMES = new Set(['succeeded', 'retry', 'dead_letter']); +const RECOVERY_OUTCOMES = new Set(['succeeded', 'dead_letter']); +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/u; +const SCHEMA_VERSION = 'scopeweave.stripe-reconciliation-evidence/v1'; +const READ_SNAPSHOT_SAVEPOINT = 'scopeweave_stripe_reconciliation_evidence_export'; + +/** Stable fail-closed error for tenant reconciliation evidence exports. */ +export class StripeReconciliationEvidenceExportError extends Error { + /** + * Create one sanitized evidence-export failure. + * @param {string} code stable machine-readable failure code + * @param {number} [status=400] HTTP-compatible status for an API adapter + */ + constructor(code, status = 400) { + super(code); + this.name = 'StripeReconciliationEvidenceExportError'; + this.code = code; + this.status = status; + } +} + +function exportError(code = 'stripe_reconciliation_evidence_export_invalid', status = 400) { + return new StripeReconciliationEvidenceExportError(code, status); +} + +function positiveInteger(value) { + if (!Number.isSafeInteger(value) || value <= 0) throw exportError(); + return value; +} + +function nonNegativeInteger(value) { + const normalized = Number(value); + if (!Number.isSafeInteger(normalized) || normalized < 0) throw exportError(undefined, 500); + return normalized; +} + +function nullableNonNegativeInteger(value) { + return value == null ? null : nonNegativeInteger(value); +} + +function nullablePositiveInteger(value) { + if (value == null) return null; + const normalized = Number(value); + if (!Number.isSafeInteger(normalized) || normalized <= 0) throw exportError(undefined, 500); + return normalized; +} + +function boundedIdentifier(value) { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_PROVIDER_ID_LENGTH + || !PROVIDER_IDENTIFIER_PATTERN.test(value) + ) { + throw exportError(undefined, 500); + } + return value; +} + +function eventTypeValue(value) { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_EVENT_TYPE_LENGTH + || CONTROL_CHARACTER_PATTERN.test(value) + ) { + throw exportError(undefined, 500); + } + return value; +} + +function nullableErrorCode(value) { + if (value == null) return null; + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_ERROR_CODE_LENGTH + || !ERROR_CODE_PATTERN.test(value) + ) { + throw exportError(undefined, 500); + } + return value; +} + +function payloadSha256Value(value) { + if (typeof value !== 'string' || !PAYLOAD_SHA256_PATTERN.test(value)) { + throw exportError(undefined, 500); + } + return value; +} + +function leaseTokenSha256Value(value) { + if (typeof value !== 'string' || !PAYLOAD_SHA256_PATTERN.test(value)) { + throw exportError(undefined, 500); + } + return value; +} + +function processingStateValue(value) { + if (!PROCESSING_STATES.has(value)) throw exportError(undefined, 500); + return value; +} + +function nullableOutcome(value, allowed) { + if (value == null) return null; + if (!allowed.has(value)) throw exportError(undefined, 500); + return value; +} + +function eventLimitValue(value) { + if (value === undefined) return DEFAULT_EVENT_LIMIT; + if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_EVENT_LIMIT) { + throw exportError(); + } + return value; +} + +function evidenceReferenceDigest(value) { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_EVIDENCE_REFERENCE_LENGTH + || CONTROL_CHARACTER_PATTERN.test(value) + ) { + throw exportError(undefined, 500); + } + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +function frozenAttempt(row) { + const outcome = nullableOutcome(row.outcome, ATTEMPT_OUTCOMES); + const leaseStartedAtMs = nonNegativeInteger(row.lease_started_at_ms); + const leaseExpiresAtMs = nonNegativeInteger(row.lease_expires_at_ms); + const finishedAtMs = nullableNonNegativeInteger(row.finished_at_ms); + const errorCode = nullableErrorCode(row.error_code); + if (leaseExpiresAtMs < leaseStartedAtMs || (finishedAtMs != null && finishedAtMs < leaseStartedAtMs)) { + throw exportError(undefined, 500); + } + if (outcome == null && (finishedAtMs != null || errorCode != null)) throw exportError(undefined, 500); + if (outcome === 'succeeded' && errorCode != null) throw exportError(undefined, 500); + if ((outcome === 'retry' || outcome === 'dead_letter') && errorCode == null) { + throw exportError(undefined, 500); + } + + return Object.freeze({ + attemptNumber: positiveInteger(Number(row.attempt_number)), + leaseStartedAtMs, + leaseExpiresAtMs, + finishedAtMs, + outcome, + errorCode, + }); +} + +function frozenRecovery(row) { + const outcome = nullableOutcome(row.outcome, RECOVERY_OUTCOMES); + const requestedAtMs = nonNegativeInteger(row.requested_at_ms); + const completedAtMs = nullableNonNegativeInteger(row.completed_at_ms); + const errorCode = nullableErrorCode(row.error_code); + const claimDecisionId = nullablePositiveInteger(row.claim_decision_id); + if (completedAtMs != null && completedAtMs < requestedAtMs) throw exportError(undefined, 500); + if (outcome == null && (completedAtMs != null || errorCode != null || claimDecisionId != null)) { + throw exportError(undefined, 500); + } + if (outcome === 'succeeded' && (completedAtMs == null || errorCode != null || claimDecisionId == null)) { + throw exportError(undefined, 500); + } + if (outcome === 'dead_letter' && (completedAtMs == null || errorCode == null || claimDecisionId != null)) { + throw exportError(undefined, 500); + } + + return Object.freeze({ + recoveryId: positiveInteger(Number(row.recovery_id)), + attemptNumber: positiveInteger(Number(row.attempt_number)), + actorUserId: positiveInteger(Number(row.actor_user_id)), + evidenceReferenceSha256: evidenceReferenceDigest(row.evidence_reference), + requestedAtMs, + completedAtMs, + outcome, + errorCode, + claimDecisionId, + }); +} + +function frozenEvent(row, attempts, recoveries) { + const processingState = processingStateValue(row.processing_state); + const completedAtMs = nullableNonNegativeInteger(row.completed_at_ms); + const lastErrorCode = nullableErrorCode(row.last_error_code); + const claimDecisionId = nullablePositiveInteger(row.claim_decision_id); + const attemptCount = nonNegativeInteger(row.attempt_count); + const leaseTokenSha256 = row.lease_token_sha256 == null + ? null + : leaseTokenSha256Value(row.lease_token_sha256); + const leaseExpiresAtMs = nullableNonNegativeInteger(row.lease_expires_at_ms); + if ( + attempts.length !== attemptCount + || attempts.some((attempt, index) => attempt.attemptNumber !== index + 1) + ) { + throw exportError(undefined, 500); + } + const attemptsByNumber = new Map(attempts.map((attempt) => [attempt.attemptNumber, attempt])); + for (const recovery of recoveries) { + const attempt = attemptsByNumber.get(recovery.attemptNumber); + if ( + attempt == null + || recovery.outcome !== attempt.outcome + || recovery.errorCode !== attempt.errorCode + ) { + throw exportError(undefined, 500); + } + } + + const lifecycleInvalid = + (processingState === 'pending' && (completedAtMs != null || claimDecisionId != null)) + || (processingState === 'processing' + && (completedAtMs != null || lastErrorCode != null || claimDecisionId != null)) + || (processingState === 'succeeded' + && (completedAtMs == null || lastErrorCode != null || claimDecisionId == null)) + || (processingState === 'dead_letter' + && (completedAtMs == null || lastErrorCode == null || claimDecisionId != null)); + if (lifecycleInvalid) throw exportError(undefined, 500); + + const latestAttempt = attempts.at(-1) ?? null; + const latestAttemptLifecycleInvalid = + (processingState === 'pending' && latestAttempt != null && latestAttempt.outcome !== 'retry') + || (processingState === 'processing' + && (latestAttempt == null || latestAttempt.outcome !== null)) + || (processingState === 'succeeded' + && (latestAttempt == null || latestAttempt.outcome !== 'succeeded')) + || (processingState === 'dead_letter' + && (latestAttempt == null || latestAttempt.outcome !== 'dead_letter')); + if (latestAttemptLifecycleInvalid) throw exportError(undefined, 500); + + const leaseLifecycleInvalid = processingState === 'processing' + ? ( + leaseTokenSha256 == null + || leaseExpiresAtMs == null + || latestAttempt == null + || leaseExpiresAtMs !== latestAttempt.leaseExpiresAtMs + ) + : leaseTokenSha256 != null || leaseExpiresAtMs != null; + if (leaseLifecycleInvalid) throw exportError(undefined, 500); + + return Object.freeze({ + eventId: boundedIdentifier(row.event_id), + subscriptionId: boundedIdentifier(row.subscription_id), + eventType: eventTypeValue(row.event_type), + providerCreatedAtSec: nonNegativeInteger(row.provider_created_at_sec), + payloadSha256: payloadSha256Value(row.payload_sha256), + firstReceivedAtMs: nonNegativeInteger(row.first_received_at_ms), + queuedAtMs: nonNegativeInteger(row.queued_at_ms), + processingState, + attemptCount, + nextAttemptAtMs: nonNegativeInteger(row.next_attempt_at_ms), + completedAtMs, + lastErrorCode, + claimDecisionId, + attempts: Object.freeze(attempts), + recoveries: Object.freeze(recoveries), + }); +} + +/** + * Run a synchronous evidence read inside one composable SQLite read snapshot. + * + * A SAVEPOINT is used instead of a bare BEGIN so this repository can be called + * inside an outer transaction. SQLite fixes the read view on the first SELECT; + * every count, history read, and validation therefore describes one database + * moment even when a WAL-mode reconciliation writer commits concurrently. + * + * @param {import('node:sqlite').DatabaseSync} database SQLite database + * @param {()=>any} operation synchronous evidence read operation + * @returns {any} operation result after the snapshot is released + */ +function withReadSnapshot(database, operation) { + let snapshotStarted = false; + try { + database.exec(`SAVEPOINT ${READ_SNAPSHOT_SAVEPOINT}`); + snapshotStarted = true; + const result = operation(); + database.exec(`RELEASE SAVEPOINT ${READ_SNAPSHOT_SAVEPOINT}`); + return result; + } catch (error) { + if (!snapshotStarted) { + throw exportError('stripe_reconciliation_evidence_export_snapshot_failed', 500); + } + try { + database.exec(`ROLLBACK TO SAVEPOINT ${READ_SNAPSHOT_SAVEPOINT}`); + database.exec(`RELEASE SAVEPOINT ${READ_SNAPSHOT_SAVEPOINT}`); + } catch { + throw exportError('stripe_reconciliation_evidence_export_snapshot_failed', 500); + } + throw error; + } +} + +/** + * Create the read-only tenant evidence export repository. + * + * Tenant authority is derived exclusively through the normalized persisted + * Subscription -> Customer -> organization chain. The export never returns raw + * webhook payloads, provider credentials, active lease-token hashes, or free-form + * recovery evidence text. Operator evidence remains correlatable through SHA-256. + * Event selection is capped at 100 and the combined nested attempt/recovery history + * is capped at 1,000 rows before those histories are materialized. Durable triggers + * remain visible immediately even before the polling worker lazily seeds a job row. + * Every exported document is read from one SQLite snapshot so the row cap and + * lifecycle evidence cannot be invalidated by a concurrent reconciliation commit. + * + * @param {import('node:sqlite').DatabaseSync} database bootstrapped SQLite database + * @returns {{exportTenantEvidence(input:{organizationId:number,limit?:number}):Readonly}} + * bounded read-only evidence port + */ +export function createSqliteStripeReconciliationEvidenceExportRepository(database) { + if (!database || typeof database.prepare !== 'function' || typeof database.exec !== 'function') { + throw new TypeError('database must provide SQLite prepare and exec operations'); + } + + const selectEvents = database.prepare(` + SELECT + t.event_id, + t.subscription_id, + e.event_type, + e.provider_created_at_sec, + e.payload_sha256, + e.first_received_at_ms, + t.queued_at_ms, + COALESCE(j.processing_state, 'pending') AS processing_state, + COALESCE(j.attempt_count, 0) AS attempt_count, + COALESCE(j.next_attempt_at_ms, t.queued_at_ms) AS next_attempt_at_ms, + j.lease_token_sha256, + j.lease_expires_at_ms, + j.completed_at_ms, + j.last_error_code, + j.claim_decision_id + FROM billing_stripe_reconciliation_triggers AS t + JOIN billing_stripe_webhook_events AS e ON e.event_id = t.event_id + JOIN billing_stripe_subscriptions AS s ON s.subscription_id = t.subscription_id + JOIN billing_stripe_customers AS c ON c.customer_id = s.customer_id + LEFT JOIN billing_stripe_reconciliation_jobs AS j ON j.event_id = t.event_id + WHERE c.organization_id = ? + ORDER BY t.queued_at_ms DESC, t.event_id DESC + LIMIT ? + `); + const countNestedEvidence = database.prepare(` + SELECT + (SELECT COUNT(*) FROM billing_stripe_reconciliation_attempts WHERE event_id = ?) + + (SELECT COUNT(*) FROM billing_stripe_reconciliation_recoveries WHERE event_id = ?) + AS evidence_row_count + `); + const selectAttempts = database.prepare(` + SELECT attempt_number, lease_started_at_ms, lease_expires_at_ms, + finished_at_ms, outcome, error_code + FROM billing_stripe_reconciliation_attempts + WHERE event_id = ? + ORDER BY attempt_number ASC + `); + const selectRecoveries = database.prepare(` + SELECT recovery_id, attempt_number, actor_user_id, evidence_reference, + requested_at_ms, completed_at_ms, outcome, error_code, claim_decision_id + FROM billing_stripe_reconciliation_recoveries + WHERE event_id = ? + ORDER BY recovery_id ASC + `); + + return Object.freeze({ + /** + * Export one tenant's bounded reconciliation evidence without mutation. + * @param {{organizationId:number,limit?:number}} input tenant and event ceiling + * @returns {Readonly<{schemaVersion:string,organizationId:number,events:ReadonlyArray}>} + * immutable evidence document + */ + exportTenantEvidence({ organizationId, limit } = {}) { + const tenantId = positiveInteger(organizationId); + const eventLimit = eventLimitValue(limit); + + return withReadSnapshot(database, () => { + const eventRows = selectEvents.all(tenantId, eventLimit); + + let nestedRows = 0; + for (const row of eventRows) { + const eventId = boundedIdentifier(row.event_id); + const count = countNestedEvidence.get(eventId, eventId)?.evidence_row_count; + const normalizedCount = nonNegativeInteger(count); + nestedRows += normalizedCount; + if (!Number.isSafeInteger(nestedRows) || nestedRows > MAX_NESTED_EVIDENCE_ROWS) { + throw exportError('stripe_reconciliation_evidence_export_too_large', 413); + } + } + + const events = eventRows.map((row) => { + const eventId = boundedIdentifier(row.event_id); + const attempts = selectAttempts.all(eventId).map(frozenAttempt); + const recoveries = selectRecoveries.all(eventId).map(frozenRecovery); + return frozenEvent(row, attempts, recoveries); + }); + + return Object.freeze({ + schemaVersion: SCHEMA_VERSION, + organizationId: tenantId, + events: Object.freeze(events), + }); + }); + }, + }); +} \ No newline at end of file diff --git a/server/stripe_reconciliation_recovery_routes.mjs b/server/stripe_reconciliation_recovery_routes.mjs index d5dd38cc..58dfc294 100644 --- a/server/stripe_reconciliation_recovery_routes.mjs +++ b/server/stripe_reconciliation_recovery_routes.mjs @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + import { Hono } from 'hono'; import { bodyLimit } from 'hono/body-limit'; @@ -7,9 +9,24 @@ import { recoverStripeBillingDeadLetter, stripeReconciliationRecoveries, } from './db.mjs'; +import { + StripeReconciliationEvidenceExportError, + createSqliteStripeReconciliationEvidenceExportRepository, +} from './stripe_reconciliation_evidence_export.mjs'; import { StripeReconciliationRecoveryError } from './stripe_reconciliation_recovery.mjs'; const MAX_RECOVERY_REQUEST_BYTES = 4 * 1024; +const EVIDENCE_EXPORT_HEADERS = Object.freeze({ + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', +}); +const EVIDENCE_EXPORT_DOWNLOAD_HEADERS = Object.freeze({ + ...EVIDENCE_EXPORT_HEADERS, + 'Content-Disposition': 'attachment; filename="scopeweave-stripe-reconciliation-evidence.json"', + 'Content-Type': 'application/json; charset=UTF-8', +}); +const stripeReconciliationEvidenceExports = + createSqliteStripeReconciliationEvidenceExportRepository(db); function organizationRole(userId, organizationId) { return db.prepare('SELECT role FROM memberships WHERE user_id = ? AND org_id = ?') @@ -56,6 +73,45 @@ function recoveryFailure(c, error) { ); } +function evidenceExportFailure(c, error) { + if (error instanceof StripeReconciliationEvidenceExportError) { + return c.json({ error: error.code }, error.status, EVIDENCE_EXPORT_HEADERS); + } + return c.json( + { error: 'stripe_reconciliation_evidence_export_unavailable' }, + 500, + EVIDENCE_EXPORT_HEADERS, + ); +} + +function auditEvidenceExport(organizationId, actorUserId, report, evidenceDocument) { + try { + const evidenceDocumentSha256 = createHash('sha256') + .update(evidenceDocument, 'utf8') + .digest('hex'); + db.prepare(` + INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) + VALUES(?,?,?,?,?,?) + `).run( + organizationId, + actorUserId, + 'billing.reconciliation.evidence_export', + 'organization', + String(organizationId), + JSON.stringify({ + schemaVersion: report.schemaVersion, + eventCount: report.events.length, + evidenceDocumentSha256, + }), + ); + } catch { + throw new StripeReconciliationEvidenceExportError( + 'stripe_reconciliation_evidence_export_audit_failed', + 500, + ); + } +} + function auditRecovery(organizationId, actorUserId, result) { try { db.prepare(` @@ -82,17 +138,48 @@ function auditRecovery(organizationId, actorUserId, result) { } /** - * Tenant-scoped operator API for Stripe reconciliation dead-letter recovery. + * Tenant-scoped operator API for Stripe reconciliation evidence and dead-letter recovery. * * The route graph deliberately exposes no lease token, provider secret, raw webhook - * payload, or caller-selected Subscription identity. Owners/admins can inspect their - * bounded backlog and retry one exact verified Event using a durable evidence reference. - * Recovery JSON is capped at 4 KiB by Hono's body-limit middleware, which checks both - * declared Content-Length and streamed bytes before the JSON parser can buffer an - * unbounded privileged request. + * payload, or caller-selected Subscription identity. Owners/admins can export their + * bounded reconciliation evidence, inspect their bounded backlog, and retry one exact + * verified Event using a durable evidence reference. Successful evidence disclosure + * is fail-closed on its durable access-audit write, which binds the audit record to the + * exact exported JSON bytes by SHA-256 without copying private recovery text. Recovery + * JSON is capped at 4 KiB by Hono's body-limit middleware, which checks declared + * Content-Length and streamed bytes before the JSON parser can buffer an unbounded + * privileged request. */ export const stripeReconciliationRecoveryRoutes = new Hono(); +stripeReconciliationRecoveryRoutes.get( + '/api/orgs/:id/billing/reconciliation/evidence', + requireRecoveryAuth, + (c) => { + const actorUserId = c.get('recoveryUserId'); + const organizationId = Number(c.req.param('id')); + const role = Number.isSafeInteger(organizationId) && organizationId > 0 + ? organizationRole(actorUserId, organizationId) + : null; + if (!role) return c.json({ error: 'not found' }, 404, EVIDENCE_EXPORT_HEADERS); + if (!canManage(role)) return c.json({ error: 'forbidden' }, 403, EVIDENCE_EXPORT_HEADERS); + + const rawLimit = c.req.query('limit'); + const limit = rawLimit === undefined ? undefined : Number(rawLimit); + try { + const report = stripeReconciliationEvidenceExports.exportTenantEvidence({ + organizationId, + limit, + }); + const evidenceDocument = JSON.stringify(report); + auditEvidenceExport(organizationId, actorUserId, report, evidenceDocument); + return c.body(evidenceDocument, 200, EVIDENCE_EXPORT_DOWNLOAD_HEADERS); + } catch (error) { + return evidenceExportFailure(c, error); + } + }, +); + stripeReconciliationRecoveryRoutes.get( '/api/orgs/:id/billing/reconciliation/dead-letters', requireRecoveryAuth, diff --git a/tests/api/stripe-reconciliation-evidence-export.test.mjs b/tests/api/stripe-reconciliation-evidence-export.test.mjs new file mode 100644 index 00000000..9d072178 --- /dev/null +++ b/tests/api/stripe-reconciliation-evidence-export.test.mjs @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.test'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_evidence_export'; +process.env.STRIPE_PRICE_ID = 'price_evidence_export'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_evidence_export'; +delete process.env.ORCHESTRATOR_URL; + +const { app } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); + +const request = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); +const jsonBody = (value) => JSON.stringify(value); + +async function signup(email, name) { + const response = await request('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email, password: 'password123', name }), + }); + assert.equal(response.status, 200, `signup succeeds for ${email}`); + const payload = await response.json(); + const me = await request('/api/me', { + headers: { authorization: `Bearer ${payload.token}` }, + }); + const identity = await me.json(); + return { + token: payload.token, + userId: identity.user.id, + organizationId: identity.orgs[0].id, + }; +} + +function seedEvidence({ + organizationId, + actorUserId, + eventId, + subscriptionId, + customerId, + payloadSha256, + queuedAtMs, + evidenceReference, +}) { + db.prepare(` + INSERT INTO billing_stripe_customers(customer_id, organization_id, first_observed_at_ms) + VALUES(?,?,?) + `).run(customerId, organizationId, queuedAtMs - 100); + db.prepare(` + INSERT INTO billing_stripe_subscriptions(subscription_id, customer_id, first_observed_at_ms) + VALUES(?,?,?) + `).run(subscriptionId, customerId, queuedAtMs - 100); + db.prepare(` + INSERT INTO billing_stripe_webhook_events( + event_id, provider_created_at_sec, event_type, object_id, object_type, + api_version, request_id, payload_sha256, first_received_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?) + `).run( + eventId, + 1_787_000_000, + 'customer.subscription.updated', + subscriptionId, + 'subscription', + '2025-03-31.basil', + null, + payloadSha256, + queuedAtMs - 10, + ); + db.prepare(` + INSERT INTO billing_stripe_reconciliation_triggers( + event_id, subscription_id, queued_at_ms, processing_state + ) VALUES(?,?,?,'pending') + `).run(eventId, subscriptionId, queuedAtMs); + db.prepare(` + INSERT INTO billing_stripe_reconciliation_jobs( + event_id, processing_state, attempt_count, next_attempt_at_ms, + lease_token_sha256, lease_expires_at_ms, completed_at_ms, + last_error_code, claim_decision_id + ) VALUES(?,'dead_letter',1,?,NULL,NULL,?,'stripe_reconciliation_failed',NULL) + `).run(eventId, queuedAtMs + 10, queuedAtMs + 20); + db.prepare(` + INSERT INTO billing_stripe_reconciliation_attempts( + event_id, attempt_number, lease_started_at_ms, lease_expires_at_ms, + finished_at_ms, outcome, error_code + ) VALUES(?,1,?,?,?,'dead_letter','stripe_reconciliation_failed') + `).run(eventId, queuedAtMs + 1, queuedAtMs + 11, queuedAtMs + 20); + db.prepare(` + INSERT INTO billing_stripe_reconciliation_recoveries( + event_id, attempt_number, actor_user_id, evidence_reference, + requested_at_ms, completed_at_ms, outcome, error_code, claim_decision_id + ) VALUES(?,1,?,?,?,?,'dead_letter','stripe_reconciliation_failed',NULL) + `).run(eventId, actorUserId, evidenceReference, queuedAtMs + 30, queuedAtMs + 40); +} + +const owner = await signup('export-owner@scopeweave.test', 'Export Owner'); +const member = await signup('export-member@scopeweave.test', 'Export Member'); +db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)') + .run(owner.organizationId, member.userId, 'member'); + +seedEvidence({ + organizationId: owner.organizationId, + actorUserId: owner.userId, + eventId: 'evt_export_owner', + subscriptionId: 'sub_export_owner', + customerId: 'cus_export_owner', + payloadSha256: 'a'.repeat(64), + queuedAtMs: 2_000, + evidenceReference: 'INC-PRIVATE-EXPORT-OWNER', +}); +seedEvidence({ + organizationId: member.organizationId, + actorUserId: member.userId, + eventId: 'evt_export_foreign', + subscriptionId: 'sub_export_foreign', + customerId: 'cus_export_foreign', + payloadSha256: 'b'.repeat(64), + queuedAtMs: 3_000, + evidenceReference: 'INC-PRIVATE-EXPORT-FOREIGN', +}); + +const ownerPath = `/api/orgs/${owner.organizationId}/billing/reconciliation/evidence`; +let response = await request(ownerPath); +assert.equal(response.status, 401, 'evidence export requires authentication'); + +response = await request(ownerPath, { + headers: { authorization: `Bearer ${member.token}` }, +}); +assert.equal(response.status, 403, 'ordinary members cannot export workspace billing evidence'); + +response = await request(`${ownerPath}?limit=0`, { + headers: { authorization: `Bearer ${owner.token}` }, +}); +assert.equal(response.status, 400, 'invalid export bounds fail closed'); +assert.deepEqual(await response.json(), { error: 'stripe_reconciliation_evidence_export_invalid' }); + +assert.equal( + db.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'billing.reconciliation.evidence_export'") + .get().count, + 0, + 'denied and invalid requests do not create successful-export audit records', +); + +response = await request(ownerPath, { + headers: { authorization: `Bearer ${owner.token}` }, +}); +assert.equal(response.status, 200, 'workspace owner can export its reconciliation evidence'); +assert.equal(response.headers.get('cache-control'), 'no-store'); +assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); +assert.equal( + response.headers.get('content-disposition'), + 'attachment; filename="scopeweave-stripe-reconciliation-evidence.json"', +); +const ownerReportBody = await response.text(); +const ownerReport = JSON.parse(ownerReportBody); +const ownerReportSha256 = createHash('sha256') + .update(ownerReportBody, 'utf8') + .digest('hex'); +assert.equal(ownerReport.schemaVersion, 'scopeweave.stripe-reconciliation-evidence/v1'); +assert.equal(ownerReport.organizationId, owner.organizationId); +assert.deepEqual(ownerReport.events.map((event) => event.eventId), ['evt_export_owner']); +assert.equal(JSON.stringify(ownerReport).includes('evt_export_foreign'), false); +assert.equal(JSON.stringify(ownerReport).includes('INC-PRIVATE-EXPORT-OWNER'), false); +assert.equal( + ownerReport.events[0].recoveries[0].evidenceReferenceSha256, + createHash('sha256').update('INC-PRIVATE-EXPORT-OWNER', 'utf8').digest('hex'), +); + +const ownerAudit = db.prepare(` + SELECT org_id, user_id, target_type, target_id, meta + FROM audit_log + WHERE action = 'billing.reconciliation.evidence_export' + AND org_id = ? AND user_id = ? +`).get(owner.organizationId, owner.userId); +assert.ok(ownerAudit, 'each successful evidence export is durably access-logged'); +assert.equal(ownerAudit.target_type, 'organization'); +assert.equal(ownerAudit.target_id, String(owner.organizationId)); +assert.deepEqual(JSON.parse(ownerAudit.meta), { + schemaVersion: 'scopeweave.stripe-reconciliation-evidence/v1', + eventCount: 1, + evidenceDocumentSha256: ownerReportSha256, +}); +assert.equal( + JSON.stringify(ownerAudit).includes('INC-PRIVATE-EXPORT-OWNER'), + false, + 'export audit metadata never copies private recovery evidence text', +); + +response = await request( + `/api/orgs/${member.organizationId}/billing/reconciliation/evidence`, + { headers: { authorization: `Bearer ${member.token}` } }, +); +assert.equal(response.status, 200, 'a second tenant owner can export only its own evidence'); +const memberReport = await response.json(); +assert.deepEqual(memberReport.events.map((event) => event.eventId), ['evt_export_foreign']); +assert.equal(JSON.stringify(memberReport).includes('evt_export_owner'), false); +assert.equal( + db.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'billing.reconciliation.evidence_export'") + .get().count, + 2, + 'successful exports are independently logged for each tenant actor', +); + +db.exec('DROP TABLE audit_log'); +response = await request(ownerPath, { + headers: { authorization: `Bearer ${owner.token}` }, +}); +assert.equal(response.status, 500, 'evidence disclosure fails closed when its audit sink is unavailable'); +assert.deepEqual(await response.json(), { + error: 'stripe_reconciliation_evidence_export_audit_failed', +}); + +db.close(); diff --git a/tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs b/tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs new file mode 100644 index 00000000..69ccc2ca --- /dev/null +++ b/tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { + StripeReconciliationEvidenceExportError, + createSqliteStripeReconciliationEvidenceExportRepository, +} from '../../server/stripe_reconciliation_evidence_export.mjs'; + +const db = new DatabaseSync(':memory:'); +db.exec(` + CREATE TABLE billing_stripe_customers ( + customer_id TEXT PRIMARY KEY, + organization_id INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_subscriptions ( + subscription_id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL + ); + CREATE TABLE billing_stripe_webhook_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + provider_created_at_sec INTEGER NOT NULL, + payload_sha256 TEXT NOT NULL, + first_received_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_triggers ( + event_id TEXT PRIMARY KEY, + subscription_id TEXT NOT NULL, + queued_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_jobs ( + event_id TEXT PRIMARY KEY, + processing_state TEXT NOT NULL, + attempt_count INTEGER NOT NULL, + next_attempt_at_ms INTEGER NOT NULL, + lease_token_sha256 TEXT, + lease_expires_at_ms INTEGER, + completed_at_ms INTEGER, + last_error_code TEXT, + claim_decision_id INTEGER + ); + CREATE TABLE billing_stripe_reconciliation_attempts ( + event_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + lease_started_at_ms INTEGER NOT NULL, + lease_expires_at_ms INTEGER NOT NULL, + finished_at_ms INTEGER, + outcome TEXT, + error_code TEXT + ); + CREATE TABLE billing_stripe_reconciliation_recoveries ( + recovery_id INTEGER PRIMARY KEY, + event_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + actor_user_id INTEGER NOT NULL, + evidence_reference TEXT NOT NULL, + requested_at_ms INTEGER NOT NULL, + completed_at_ms INTEGER, + outcome TEXT, + error_code TEXT, + claim_decision_id INTEGER + ); + + INSERT INTO billing_stripe_customers(customer_id,organization_id) + VALUES('cus_one',1); + INSERT INTO billing_stripe_subscriptions(subscription_id,customer_id) + VALUES('sub_one','cus_one'); + INSERT INTO billing_stripe_webhook_events( + event_id,event_type,provider_created_at_sec,payload_sha256,first_received_at_ms + ) VALUES( + 'evt_one','invoice.paid',1787000100, + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',1000 + ); + INSERT INTO billing_stripe_reconciliation_triggers(event_id,subscription_id,queued_at_ms) + VALUES('evt_one','sub_one',1100); + INSERT INTO billing_stripe_reconciliation_jobs( + event_id,processing_state,attempt_count,next_attempt_at_ms,lease_token_sha256, + lease_expires_at_ms,completed_at_ms,last_error_code,claim_decision_id + ) VALUES( + 'evt_one','processing',1,1200, + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 5000,NULL,NULL,NULL + ); + INSERT INTO billing_stripe_reconciliation_attempts( + event_id,attempt_number,lease_started_at_ms,lease_expires_at_ms,finished_at_ms,outcome,error_code + ) VALUES('evt_one',1,4000,5000,NULL,NULL,NULL); +`); + +const repository = createSqliteStripeReconciliationEvidenceExportRepository(db); +const valid = repository.exportTenantEvidence({ organizationId: 1, limit: 1 }); +assert.equal(valid.events.length, 1); +assert.equal( + JSON.stringify(valid).includes('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'), + false, + 'active lease-token hashes remain validation-only and never enter exported evidence', +); + +function assertFailsClosed(message) { + assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 1 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + message, + ); +} + +// A processing worker row is authoritative only while it retains the opaque lease +// digest required by the worker schema. A damaged restore must not become plausible +// audit evidence merely because the attempt row is otherwise coherent. +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET lease_token_sha256 = NULL + WHERE event_id = 'evt_one'; +`); +assertFailsClosed('processing evidence without its active lease digest fails closed'); + +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET lease_token_sha256 = 'not-a-sha256' + WHERE event_id = 'evt_one'; +`); +assertFailsClosed('processing evidence with a malformed lease digest fails closed'); + +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET lease_token_sha256 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + lease_expires_at_ms = NULL + WHERE event_id = 'evt_one'; +`); +assertFailsClosed('processing evidence without durable lease expiry fails closed'); + +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET lease_expires_at_ms = 5001 + WHERE event_id = 'evt_one'; +`); +assertFailsClosed('job lease expiry must agree with the exact unfinished attempt'); + +// Terminal worker state clears active lease material atomically. If damaged state +// retains a lease after success, the evidence exporter must reject that contradiction. +db.exec(` + UPDATE billing_stripe_reconciliation_attempts + SET finished_at_ms = 6000, outcome = 'succeeded' + WHERE event_id = 'evt_one' AND attempt_number = 1; + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'succeeded', lease_expires_at_ms = 5000, + completed_at_ms = 6000, claim_decision_id = 77 + WHERE event_id = 'evt_one'; +`); +assertFailsClosed('terminal evidence retaining active lease material fails closed'); + +db.close(); diff --git a/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs b/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs new file mode 100644 index 00000000..2459406e --- /dev/null +++ b/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { + createSqliteStripeReconciliationEvidenceExportRepository, +} from '../../server/stripe_reconciliation_evidence_export.mjs'; + +const tempDirectory = mkdtempSync(join(tmpdir(), 'scopeweave-reconciliation-evidence-')); +const databasePath = join(tempDirectory, 'snapshot.sqlite'); +let bootstrap; +let reader; +let writer; + +try { + bootstrap = new DatabaseSync(databasePath); + bootstrap.exec(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE billing_stripe_customers ( + customer_id TEXT PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES orgs(id), + first_observed_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_subscriptions ( + subscription_id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL REFERENCES billing_stripe_customers(customer_id), + first_observed_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_webhook_events ( + event_id TEXT PRIMARY KEY, + provider_created_at_sec INTEGER NOT NULL, + event_type TEXT NOT NULL, + object_id TEXT NOT NULL, + object_type TEXT NOT NULL, + api_version TEXT, + request_id TEXT, + payload_sha256 TEXT NOT NULL, + first_received_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_triggers ( + event_id TEXT PRIMARY KEY REFERENCES billing_stripe_webhook_events(event_id), + subscription_id TEXT NOT NULL, + queued_at_ms INTEGER NOT NULL, + processing_state TEXT NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_jobs ( + event_id TEXT PRIMARY KEY REFERENCES billing_stripe_reconciliation_triggers(event_id), + processing_state TEXT NOT NULL, + attempt_count INTEGER NOT NULL, + next_attempt_at_ms INTEGER NOT NULL, + lease_token_sha256 TEXT, + lease_expires_at_ms INTEGER, + completed_at_ms INTEGER, + last_error_code TEXT, + claim_decision_id INTEGER + ); + CREATE TABLE billing_stripe_reconciliation_attempts ( + attempt_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES billing_stripe_reconciliation_jobs(event_id), + attempt_number INTEGER NOT NULL, + lease_started_at_ms INTEGER NOT NULL, + lease_expires_at_ms INTEGER NOT NULL, + finished_at_ms INTEGER, + outcome TEXT, + error_code TEXT, + UNIQUE(event_id, attempt_number) + ); + CREATE TABLE billing_stripe_reconciliation_recoveries ( + recovery_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + actor_user_id INTEGER NOT NULL, + evidence_reference TEXT NOT NULL, + requested_at_ms INTEGER NOT NULL, + completed_at_ms INTEGER, + outcome TEXT, + error_code TEXT, + claim_decision_id INTEGER, + UNIQUE(event_id, evidence_reference) + ); + + INSERT INTO orgs(id, name) VALUES(1, 'Tenant Snapshot'); + INSERT INTO billing_stripe_customers(customer_id, organization_id, first_observed_at_ms) + VALUES('cus_snapshot', 1, 100); + INSERT INTO billing_stripe_subscriptions(subscription_id, customer_id, first_observed_at_ms) + VALUES('sub_snapshot', 'cus_snapshot', 100); + INSERT INTO billing_stripe_webhook_events( + event_id, provider_created_at_sec, event_type, object_id, object_type, + api_version, request_id, payload_sha256, first_received_at_ms + ) VALUES( + 'evt_snapshot', 1787000000, 'invoice.paid', 'in_snapshot', 'invoice', + '2025-03-31.basil', 'req_snapshot', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 1000 + ); + INSERT INTO billing_stripe_reconciliation_triggers( + event_id, subscription_id, queued_at_ms, processing_state + ) VALUES('evt_snapshot', 'sub_snapshot', 1100, 'pending'); + INSERT INTO billing_stripe_reconciliation_jobs( + event_id, processing_state, attempt_count, next_attempt_at_ms, + lease_token_sha256, lease_expires_at_ms, completed_at_ms, + last_error_code, claim_decision_id + ) VALUES('evt_snapshot', 'pending', 0, 1200, NULL, NULL, NULL, NULL, NULL); + `); + bootstrap.close(); + bootstrap = null; + + reader = new DatabaseSync(databasePath); + writer = new DatabaseSync(databasePath); + reader.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 1000;'); + writer.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 1000;'); + + let writerCommitted = false; + const databaseWithConcurrentWriter = { + exec(sql) { + return reader.exec(sql); + }, + prepare(sql) { + const statement = reader.prepare(sql); + const isAttemptMaterialization = sql.includes('FROM billing_stripe_reconciliation_attempts') + && sql.includes('ORDER BY attempt_number ASC'); + if (!isAttemptMaterialization) return statement; + return { + all(...parameters) { + if (!writerCommitted) { + writer.exec(` + BEGIN IMMEDIATE; + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'processing', + attempt_count = 1, + lease_token_sha256 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + lease_expires_at_ms = 5000 + WHERE event_id = 'evt_snapshot'; + INSERT INTO billing_stripe_reconciliation_attempts( + event_id, attempt_number, lease_started_at_ms, lease_expires_at_ms, + finished_at_ms, outcome, error_code + ) VALUES('evt_snapshot', 1, 4000, 5000, NULL, NULL, NULL); + COMMIT; + `); + writerCommitted = true; + } + return statement.all(...parameters); + }, + }; + }, + }; + + const repository = createSqliteStripeReconciliationEvidenceExportRepository( + databaseWithConcurrentWriter, + ); + const firstReport = repository.exportTenantEvidence({ organizationId: 1, limit: 10 }); + + assert.equal(writerCommitted, true, 'the concurrent reconciliation write committed during export'); + assert.deepEqual( + firstReport.events.map((event) => ({ + eventId: event.eventId, + processingState: event.processingState, + attemptCount: event.attemptCount, + attempts: event.attempts.length, + })), + [{ eventId: 'evt_snapshot', processingState: 'pending', attemptCount: 0, attempts: 0 }], + 'one evidence document must come from one SQLite read snapshot even while a writer commits', + ); + + const secondReport = repository.exportTenantEvidence({ organizationId: 1, limit: 10 }); + assert.deepEqual( + secondReport.events.map((event) => ({ + eventId: event.eventId, + processingState: event.processingState, + attemptCount: event.attemptCount, + attempts: event.attempts.length, + })), + [{ eventId: 'evt_snapshot', processingState: 'processing', attemptCount: 1, attempts: 1 }], + 'a later export observes the committed reconciliation state after the snapshot is released', + ); + + writer.exec(` + INSERT INTO billing_stripe_webhook_events( + event_id, provider_created_at_sec, event_type, object_id, object_type, + api_version, request_id, payload_sha256, first_received_at_ms + ) VALUES( + 'evt_queued_unseeded', 1787000100, 'customer.subscription.updated', + 'sub_snapshot', 'subscription', '2025-03-31.basil', 'req_queued_unseeded', + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', 6000 + ); + INSERT INTO billing_stripe_reconciliation_triggers( + event_id, subscription_id, queued_at_ms, processing_state + ) VALUES('evt_queued_unseeded', 'sub_snapshot', 6100, 'pending'); + `); + + const queuedBeforeWorkerSeed = repository.exportTenantEvidence({ organizationId: 1, limit: 10 }); + assert.deepEqual( + queuedBeforeWorkerSeed.events.map((event) => ({ + eventId: event.eventId, + processingState: event.processingState, + attemptCount: event.attemptCount, + nextAttemptAtMs: event.nextAttemptAtMs, + attempts: event.attempts.length, + })), + [ + { + eventId: 'evt_queued_unseeded', + processingState: 'pending', + attemptCount: 0, + nextAttemptAtMs: 6100, + attempts: 0, + }, + { + eventId: 'evt_snapshot', + processingState: 'processing', + attemptCount: 1, + nextAttemptAtMs: 1200, + attempts: 1, + }, + ], + 'durable queued evidence must remain visible before the polling worker seeds its job row', + ); +} finally { + try { writer?.close(); } catch {} + try { reader?.close(); } catch {} + try { bootstrap?.close(); } catch {} + rmSync(tempDirectory, { recursive: true, force: true }); +} \ No newline at end of file diff --git a/tests/unit/stripe-reconciliation-evidence-export.test.mjs b/tests/unit/stripe-reconciliation-evidence-export.test.mjs new file mode 100644 index 00000000..9b40161e --- /dev/null +++ b/tests/unit/stripe-reconciliation-evidence-export.test.mjs @@ -0,0 +1,405 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; + +import { + StripeReconciliationEvidenceExportError, + createSqliteStripeReconciliationEvidenceExportRepository, +} from '../../server/stripe_reconciliation_evidence_export.mjs'; + +const db = new DatabaseSync(':memory:'); +db.exec(` + PRAGMA foreign_keys = ON; + + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE billing_stripe_customers ( + customer_id TEXT PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES orgs(id), + first_observed_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_subscriptions ( + subscription_id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL REFERENCES billing_stripe_customers(customer_id), + first_observed_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_webhook_events ( + event_id TEXT PRIMARY KEY, + provider_created_at_sec INTEGER NOT NULL, + event_type TEXT NOT NULL, + object_id TEXT NOT NULL, + object_type TEXT NOT NULL, + api_version TEXT, + request_id TEXT, + payload_sha256 TEXT NOT NULL, + first_received_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_triggers ( + event_id TEXT PRIMARY KEY REFERENCES billing_stripe_webhook_events(event_id), + subscription_id TEXT NOT NULL, + queued_at_ms INTEGER NOT NULL, + processing_state TEXT NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_jobs ( + event_id TEXT PRIMARY KEY REFERENCES billing_stripe_reconciliation_triggers(event_id), + processing_state TEXT NOT NULL, + attempt_count INTEGER NOT NULL, + next_attempt_at_ms INTEGER NOT NULL, + lease_token_sha256 TEXT, + lease_expires_at_ms INTEGER, + completed_at_ms INTEGER, + last_error_code TEXT, + claim_decision_id INTEGER + ); + CREATE TABLE billing_stripe_reconciliation_attempts ( + attempt_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES billing_stripe_reconciliation_jobs(event_id), + attempt_number INTEGER NOT NULL, + lease_started_at_ms INTEGER NOT NULL, + lease_expires_at_ms INTEGER NOT NULL, + finished_at_ms INTEGER, + outcome TEXT, + error_code TEXT, + UNIQUE(event_id, attempt_number) + ); + CREATE TABLE billing_stripe_reconciliation_recoveries ( + recovery_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + actor_user_id INTEGER NOT NULL, + evidence_reference TEXT NOT NULL, + requested_at_ms INTEGER NOT NULL, + completed_at_ms INTEGER, + outcome TEXT, + error_code TEXT, + claim_decision_id INTEGER, + UNIQUE(event_id, evidence_reference) + ); +`); + +db.exec(` + INSERT INTO orgs(id,name) VALUES(1,'Tenant One'),(2,'Tenant Two'); + INSERT INTO billing_stripe_customers(customer_id,organization_id,first_observed_at_ms) + VALUES('cus_one',1,100),('cus_two',2,100); + INSERT INTO billing_stripe_subscriptions(subscription_id,customer_id,first_observed_at_ms) + VALUES('sub_one','cus_one',100),('sub_two','cus_two',100); + + INSERT INTO billing_stripe_webhook_events( + event_id,provider_created_at_sec,event_type,object_id,object_type, + api_version,request_id,payload_sha256,first_received_at_ms + ) VALUES + ('evt_one_old',1787000000,'customer.subscription.updated','sub_one','subscription', + '2025-03-31.basil','req_one_old','aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',1000), + ('evt_one_new',1787000100,'invoice.paid','in_one','invoice', + '2025-03-31.basil',NULL,'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',2000), + ('evt_two',1787000200,'customer.subscription.updated','sub_two','subscription', + '2025-03-31.basil','req_two','cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',3000); + + INSERT INTO billing_stripe_reconciliation_triggers(event_id,subscription_id,queued_at_ms,processing_state) + VALUES('evt_one_old','sub_one',1100,'pending'), + ('evt_one_new','sub_one',2100,'pending'), + ('evt_two','sub_two',3100,'pending'); + + INSERT INTO billing_stripe_reconciliation_jobs( + event_id,processing_state,attempt_count,next_attempt_at_ms,lease_token_sha256, + lease_expires_at_ms,completed_at_ms,last_error_code,claim_decision_id + ) VALUES + ('evt_one_old','dead_letter',2,1300,NULL,NULL,1400,'stripe_reconciliation_failed',NULL), + ('evt_one_new','processing',1,2200,'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + 999999999999,NULL,NULL,NULL), + ('evt_two','succeeded',1,3200,NULL,NULL,3300,NULL,77); + + INSERT INTO billing_stripe_reconciliation_attempts( + event_id,attempt_number,lease_started_at_ms,lease_expires_at_ms,finished_at_ms,outcome,error_code + ) VALUES + ('evt_one_old',1,1110,1210,1200,'retry','stripe_provider_timeout'), + ('evt_one_old',2,1210,1310,1400,'dead_letter','stripe_reconciliation_failed'), + ('evt_one_new',1,2110,999999999999,NULL,NULL,NULL), + ('evt_two',1,3110,3210,3300,'succeeded',NULL); + + INSERT INTO billing_stripe_reconciliation_recoveries( + event_id,attempt_number,actor_user_id,evidence_reference,requested_at_ms, + completed_at_ms,outcome,error_code,claim_decision_id + ) VALUES( + 'evt_one_old',2,10,'INC-PRIVATE-CUSTOMER-TICKET',1450,1460, + 'dead_letter','stripe_reconciliation_failed',NULL + ); +`); + +const repository = createSqliteStripeReconciliationEvidenceExportRepository(db); +const report = repository.exportTenantEvidence({ organizationId: 1, limit: 10 }); + +assert.equal(report.schemaVersion, 'scopeweave.stripe-reconciliation-evidence/v1'); +assert.equal(report.organizationId, 1); +assert.equal(report.events.length, 2, 'only the requested tenant evidence is exported'); +assert.deepEqual(report.events.map((event) => event.eventId), ['evt_one_new', 'evt_one_old']); +assert.equal(JSON.stringify(report).includes('evt_two'), false, 'foreign tenant identities never enter the export'); +assert.equal(JSON.stringify(report).includes('cus_two'), false, 'foreign customer identities never enter the export'); +assert.equal( + JSON.stringify(report).includes('dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'), + false, + 'active lease hashes never enter customer-facing evidence exports', +); +assert.equal( + JSON.stringify(report).includes('INC-PRIVATE-CUSTOMER-TICKET'), + false, + 'free-form operator evidence text is not copied into the export', +); + +const newest = report.events[0]; +assert.deepEqual(newest, { + eventId: 'evt_one_new', + subscriptionId: 'sub_one', + eventType: 'invoice.paid', + providerCreatedAtSec: 1787000100, + payloadSha256: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + firstReceivedAtMs: 2000, + queuedAtMs: 2100, + processingState: 'processing', + attemptCount: 1, + nextAttemptAtMs: 2200, + completedAtMs: null, + lastErrorCode: null, + claimDecisionId: null, + attempts: [{ + attemptNumber: 1, + leaseStartedAtMs: 2110, + leaseExpiresAtMs: 999999999999, + finishedAtMs: null, + outcome: null, + errorCode: null, + }], + recoveries: [], +}); + +const older = report.events[1]; +assert.equal(older.recoveries.length, 1); +assert.deepEqual(older.recoveries[0], { + recoveryId: 1, + attemptNumber: 2, + actorUserId: 10, + evidenceReferenceSha256: createHash('sha256') + .update('INC-PRIVATE-CUSTOMER-TICKET', 'utf8') + .digest('hex'), + requestedAtMs: 1450, + completedAtMs: 1460, + outcome: 'dead_letter', + errorCode: 'stripe_reconciliation_failed', + claimDecisionId: null, +}); + +assert.deepEqual( + repository.exportTenantEvidence({ organizationId: 1, limit: 1 }).events.map((event) => event.eventId), + ['evt_one_new'], + 'event count is bounded before nested history is materialized', +); +assert.deepEqual( + repository.exportTenantEvidence({ organizationId: 999, limit: 10 }), + { + schemaVersion: 'scopeweave.stripe-reconciliation-evidence/v1', + organizationId: 999, + events: [], + }, + 'an unknown tenant does not disclose whether another tenant has billing evidence', +); + +for (const input of [ + { organizationId: 0, limit: 10 }, + { organizationId: 1, limit: 0 }, + { organizationId: 1, limit: 101 }, + { organizationId: 1, limit: 1.5 }, +]) { + assert.throws( + () => repository.exportTenantEvidence(input), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.code === 'stripe_reconciliation_evidence_export_invalid', + ); +} + +// Export is an audit boundary over persisted state, so it must not serialize a +// contradictory terminal job as authoritative evidence even if a damaged restore or +// manually altered database bypassed the worker table's normal CHECK constraints. +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'succeeded', completed_at_ms = NULL, + last_error_code = NULL, claim_decision_id = 42 + WHERE event_id = 'evt_one_new'; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 1 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.code === 'stripe_reconciliation_evidence_export_invalid' + && error.status === 500, + 'contradictory persisted job state fails closed instead of becoming audit evidence', +); +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'processing', completed_at_ms = NULL, + last_error_code = NULL, claim_decision_id = NULL + WHERE event_id = 'evt_one_new'; +`); + +// The production worker constrains lease expiry and completion to occur no earlier +// than lease start. A damaged restore that bypasses those CHECKs must not become a +// plausible-looking audit timeline. +db.exec(` + UPDATE billing_stripe_reconciliation_attempts + SET lease_expires_at_ms = 2100 + WHERE event_id = 'evt_one_new' AND attempt_number = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 1 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'attempt lease expiry before lease start fails closed', +); +db.exec(` + UPDATE billing_stripe_reconciliation_attempts + SET lease_expires_at_ms = 999999999999 + WHERE event_id = 'evt_one_new' AND attempt_number = 1; + UPDATE billing_stripe_reconciliation_attempts + SET finished_at_ms = 1100 + WHERE event_id = 'evt_one_old' AND attempt_number = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 10 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'attempt completion before lease start fails closed', +); +db.exec(` + UPDATE billing_stripe_reconciliation_attempts + SET finished_at_ms = 1200 + WHERE event_id = 'evt_one_old' AND attempt_number = 1; +`); + +// Recovery completion chronology is persisted evidence too; a damaged restore that +// predates completion before the operator request must fail closed. +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET completed_at_ms = 1400 + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 10 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'recovery completion before request fails closed', +); +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET completed_at_ms = 1460 + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); + +// Production recovery rows are composite-FK-bound to the exact worker attempt. A +// damaged restore must not be able to make one recovery appear to authorize an +// attempt that does not exist in the exported event history. +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET attempt_number = 99 + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 10 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'recovery referencing a missing attempt fails closed', +); +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET attempt_number = 2 + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); + +// Recovery outcome is written atomically with the exact worker attempt. A damaged +// restore must not serialize success when its linked worker attempt is durable +// dead-letter evidence. +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET outcome = 'succeeded', error_code = NULL, claim_decision_id = 42 + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 10 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'recovery outcome must agree with its linked worker attempt', +); +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET outcome = 'dead_letter', error_code = 'stripe_reconciliation_failed', + claim_decision_id = NULL + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); + +// attempt_count is the worker's durable count of append-only attempts. If a damaged +// restore loses an attempt row, the export must not present a plausible but incomplete +// audit history merely because the surviving attempt numbers are individually valid. +db.exec(` + DELETE FROM billing_stripe_reconciliation_attempts + WHERE event_id = 'evt_one_old' AND attempt_number = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 10 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'missing append-only attempt history fails closed instead of producing incomplete audit evidence', +); +db.exec(` + INSERT INTO billing_stripe_reconciliation_attempts( + event_id,attempt_number,lease_started_at_ms,lease_expires_at_ms,finished_at_ms,outcome,error_code + ) VALUES('evt_one_old',1,1110,1210,1200,'retry','stripe_provider_timeout'); +`); + +// Job and attempt rows are written in one worker savepoint and describe one lifecycle. +// A damaged restore with a terminal job but an unfinished latest attempt must not be +// presented as internally coherent audit evidence merely because each row is valid alone. +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'succeeded', completed_at_ms = 2300, + last_error_code = NULL, claim_decision_id = 42, + lease_token_sha256 = NULL, lease_expires_at_ms = NULL + WHERE event_id = 'evt_one_new'; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 1 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'terminal job with unfinished latest attempt fails closed as contradictory audit evidence', +); +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'processing', completed_at_ms = NULL, + last_error_code = NULL, claim_decision_id = NULL, + lease_token_sha256 = 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + lease_expires_at_ms = 999999999999 + WHERE event_id = 'evt_one_new'; +`); + +// A single selected event with more nested rows than the hard response budget must +// fail closed instead of allocating an arbitrarily large JSON evidence document. +db.exec(` + DELETE FROM billing_stripe_reconciliation_attempts WHERE event_id = 'evt_one_new'; + WITH RECURSIVE sequence(value) AS ( + VALUES(1) + UNION ALL + SELECT value + 1 FROM sequence WHERE value < 1001 + ) + INSERT INTO billing_stripe_reconciliation_attempts( + event_id,attempt_number,lease_started_at_ms,lease_expires_at_ms,finished_at_ms,outcome,error_code + ) + SELECT 'evt_one_new', value, 5000 + value, 6000 + value, 6000 + value, 'retry', 'stripe_provider_timeout' + FROM sequence; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 1 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.code === 'stripe_reconciliation_evidence_export_too_large' + && error.status === 413, + 'oversized nested evidence fails closed with a stable bounded error', +); + +db.close();