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