From fb1a7c00ccc410cdb3f27b17660a8b85c931621e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:31:57 +0900 Subject: [PATCH 01/27] test(billing): specify trusted checkout configuration --- tests/unit/billing-configuration.test.mjs | 109 ++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/unit/billing-configuration.test.mjs diff --git a/tests/unit/billing-configuration.test.mjs b/tests/unit/billing-configuration.test.mjs new file mode 100644 index 00000000..021eb33a --- /dev/null +++ b/tests/unit/billing-configuration.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + BillingConfigurationError, + validateBillingStartupConfiguration, +} from '../../server/billing_configuration.mjs'; + +function expectConfigurationError(env, code) { + assert.throws( + () => validateBillingStartupConfiguration(env), + (error) => error instanceof BillingConfigurationError && error.code === code, + ); +} + +test('production without Stripe configuration keeps billing disabled instead of mocking', () => { + const configuration = validateBillingStartupConfiguration({}); + assert.deepEqual(configuration, { + mode: 'disabled', + publicOrigin: null, + }); +}); + +test('explicit development mode permits the mock only with a canonical public origin', () => { + const configuration = validateBillingStartupConfiguration({ + SCOPEWEAVE_DEV: '1', + SCOPEWEAVE_PUBLIC_ORIGIN: 'http://127.0.0.1:8787', + }); + assert.deepEqual(configuration, { + mode: 'mock', + publicOrigin: 'http://127.0.0.1:8787', + }); +}); + +test('partial Stripe configuration fails closed during startup validation', () => { + expectConfigurationError( + { STRIPE_SECRET_KEY: 'sk_test_example' }, + 'billing_configuration_incomplete', + ); + expectConfigurationError( + { + STRIPE_SECRET_KEY: 'sk_test_example', + STRIPE_PRICE_ID: 'price_example', + }, + 'billing_configuration_incomplete', + ); +}); + +test('complete Stripe configuration requires and returns the operator public origin', () => { + expectConfigurationError( + { + STRIPE_SECRET_KEY: 'sk_test_example', + STRIPE_PRICE_ID: 'price_example', + STRIPE_WEBHOOK_SECRET: 'whsec_example', + }, + 'billing_public_origin_required', + ); + + const configuration = validateBillingStartupConfiguration({ + STRIPE_SECRET_KEY: 'sk_test_example', + STRIPE_PRICE_ID: 'price_example', + STRIPE_WEBHOOK_SECRET: 'whsec_example', + SCOPEWEAVE_PUBLIC_ORIGIN: 'https://planner.example.com', + }); + assert.deepEqual(configuration, { + mode: 'live', + publicOrigin: 'https://planner.example.com', + }); +}); + +test('public origin rejects ambiguous URL components and remote plaintext transport', () => { + for (const value of [ + 'https://user:pass@planner.example.com', + 'https://planner.example.com/base', + 'https://planner.example.com/?tenant=1', + 'https://planner.example.com/#fragment', + 'http://planner.example.com', + 'ftp://planner.example.com', + 'not a URL', + ]) { + expectConfigurationError( + { SCOPEWEAVE_DEV: '1', SCOPEWEAVE_PUBLIC_ORIGIN: value }, + 'billing_public_origin_invalid', + ); + } +}); + +test('development HTTP is restricted to loopback while HTTPS is canonicalized', () => { + for (const value of [ + 'http://localhost:8787/', + 'http://127.0.0.1:8787/', + 'http://[::1]:8787/', + ]) { + const configuration = validateBillingStartupConfiguration({ + SCOPEWEAVE_DEV: '1', + SCOPEWEAVE_PUBLIC_ORIGIN: value, + }); + assert.equal(configuration.mode, 'mock'); + assert.equal(configuration.publicOrigin, new URL(value).origin); + } + + const production = validateBillingStartupConfiguration({ + SCOPEWEAVE_PUBLIC_ORIGIN: 'https://planner.example.com/', + }); + assert.deepEqual(production, { + mode: 'disabled', + publicOrigin: 'https://planner.example.com', + }); +}); From f2ac4fafa20c76dfad2f221676efb470ee08dc65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:32:30 +0900 Subject: [PATCH 02/27] feat(billing): validate trusted checkout configuration --- server/billing_configuration.mjs | 97 ++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 server/billing_configuration.mjs diff --git a/server/billing_configuration.mjs b/server/billing_configuration.mjs new file mode 100644 index 00000000..1a526402 --- /dev/null +++ b/server/billing_configuration.mjs @@ -0,0 +1,97 @@ +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); +const STRIPE_CONFIGURATION_KEYS = [ + 'STRIPE_SECRET_KEY', + 'STRIPE_PRICE_ID', + 'STRIPE_WEBHOOK_SECRET', +]; + +/** Stable, machine-classifiable failure for billing startup configuration. */ +export class BillingConfigurationError extends Error { + /** + * Create a safe billing configuration error. + * + * @param {string} code - Stable machine-readable failure code. + */ + constructor(code) { + super(code); + this.name = 'BillingConfigurationError'; + this.code = code; + } +} + +function configuredValue(env, key) { + return String(env[key] || '').trim(); +} + +function parsePublicOrigin(rawValue, developmentMode) { + let url; + try { + url = new URL(rawValue); + } catch { + throw new BillingConfigurationError('billing_public_origin_invalid'); + } + + const hasAmbiguousComponents = Boolean( + url.username + || url.password + || (url.pathname !== '/' && url.pathname !== '') + || url.search + || url.hash, + ); + if (hasAmbiguousComponents) { + throw new BillingConfigurationError('billing_public_origin_invalid'); + } + + const secure = url.protocol === 'https:'; + const loopbackDevelopmentHttp = developmentMode + && url.protocol === 'http:' + && LOOPBACK_HOSTNAMES.has(url.hostname); + if (!secure && !loopbackDevelopmentHttp) { + throw new BillingConfigurationError('billing_public_origin_invalid'); + } + + return url.origin; +} + +/** + * Resolve the billing capability state from process-style environment values. + * + * Production never falls back to a successful mock. A live Stripe capability + * requires the complete provider key/price/webhook tuple plus an operator-owned + * canonical public origin. Explicit development mode may use the mock, but the + * same public-origin contract prevents request Host headers from becoming + * Checkout redirect authority. + * + * @param {Record} [env=process.env] - Environment values. + * @returns {{mode: 'disabled' | 'mock' | 'live', publicOrigin: string | null}} + * Validated billing mode and canonical public origin. + * @throws {BillingConfigurationError} When provider settings are partial or the + * configured public origin is absent/ambiguous/insecure. + */ +export function validateBillingStartupConfiguration(env = process.env) { + const developmentMode = env.SCOPEWEAVE_DEV === '1'; + const stripeValues = STRIPE_CONFIGURATION_KEYS.map((key) => configuredValue(env, key)); + const configuredCount = stripeValues.filter(Boolean).length; + const liveStripeConfigured = configuredCount === STRIPE_CONFIGURATION_KEYS.length; + + if (configuredCount > 0 && !liveStripeConfigured) { + throw new BillingConfigurationError('billing_configuration_incomplete'); + } + + const publicOriginInput = configuredValue(env, 'SCOPEWEAVE_PUBLIC_ORIGIN'); + if (liveStripeConfigured && !publicOriginInput) { + throw new BillingConfigurationError('billing_public_origin_required'); + } + + const publicOrigin = publicOriginInput + ? parsePublicOrigin(publicOriginInput, developmentMode) + : null; + + if (liveStripeConfigured) { + return { mode: 'live', publicOrigin }; + } + if (developmentMode && publicOrigin) { + return { mode: 'mock', publicOrigin }; + } + return { mode: 'disabled', publicOrigin }; +} From fb40d2ab23854279eded19b036525c3058298cec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:34:42 +0900 Subject: [PATCH 03/27] feat(billing): fail closed and use operator redirect origin --- server/billing.mjs | 74 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index 9781bcee..398db1e0 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -1,47 +1,89 @@ -// Billing / plan configuration + checkout. Stripe is OPTIONAL — imported -// dynamically only when STRIPE_SECRET_KEY is set, so it is not a hard dependency -// (npm i stripe + keys required for live payments; without them the mock path -// keeps the whole flow testable). Plan changes only ever happen server-side. +// Billing / plan configuration + checkout. Stripe is optional at install time, +// but production never substitutes a missing provider with a successful mock. +// Plan changes only ever happen server-side. +import { HTTPException } from 'hono/http-exception'; +import { validateBillingStartupConfiguration } from './billing_configuration.mjs'; + +const billingConfiguration = validateBillingStartupConfiguration(); export const PLANS = { free: { name: 'Free', limits: { projects: 2, members: 3 }, priceKrw: 0 }, pro: { name: 'Pro', limits: { projects: null, members: null }, priceKrw: 19000 }, // null = unlimited }; +/** Return the effective plan definition for an organization-like record. */ export function planOf(org) { return PLANS[org?.plan] || PLANS.free; } -// Returns { projects, members } counts for an org. +/** Return current project/member counts for one organization. */ export function orgUsage(db, orgId) { const projects = db.prepare('SELECT COUNT(*) AS n FROM projects WHERE org_id = ?').get(orgId).n; const members = db.prepare('SELECT COUNT(*) AS n FROM memberships WHERE org_id = ?').get(orgId).n; return { projects, members }; } -// true if adding one more of `kind` would exceed the org's plan limit. +/** Return whether adding one resource would exceed the organization's plan limit. */ export function wouldExceed(db, org, kind) { const limit = planOf(org).limits[kind]; if (limit == null) return false; // unlimited return orgUsage(db, org.id)[kind] >= limit; } -// Create a checkout session. Real Stripe when a key is present, else a mock URL -// that the dev-activate endpoint / webhook stub can complete. -export async function createCheckout({ orgId, origin }) { - const key = process.env.STRIPE_SECRET_KEY; - if (key) { - const { default: Stripe } = await import('stripe'); - const stripe = new Stripe(key); +function billingUnavailableResponse() { + return new Response(JSON.stringify({ + error: 'billing_not_configured', + action: 'Configure the complete Stripe billing settings and SCOPEWEAVE_PUBLIC_ORIGIN, then restart ScopeWeave.', + }), { + status: 503, + headers: { 'content-type': 'application/json; charset=UTF-8' }, + }); +} + +async function defaultStripeClientFactory(secretKey) { + const { default: Stripe } = await import('stripe'); + return new Stripe(secretKey); +} + +/** + * Create one hosted checkout session from trusted server-owned configuration. + * + * The request URL/Host header is intentionally not an authority input. Redirect + * URLs always derive from the canonical operator-configured public origin. The + * successful mock exists only in explicit development mode; an unconfigured + * production capability returns HTTP 503 instead of pretending checkout worked. + * + * @param {object} options - Checkout inputs and optional deterministic test seams. + * @param {string|number} options.orgId - Organization that owns the checkout. + * @param {{mode: 'disabled'|'mock'|'live', publicOrigin: string|null}} [options.configuration] + * Validated billing capability; defaults to startup configuration. + * @param {(secretKey: string) => Promise} [options.stripeClientFactory] + * Stripe client factory; injectable for deterministic provider-contract tests. + * @returns {Promise<{url: string, live: boolean, mock?: boolean}>} Checkout target. + * @throws {HTTPException} HTTP 503 when production billing is not configured. + */ +export async function createCheckout({ + orgId, + configuration = billingConfiguration, + stripeClientFactory = defaultStripeClientFactory, +}) { + const { mode, publicOrigin } = configuration; + if (mode === 'disabled' || !publicOrigin) { + throw new HTTPException(503, { res: billingUnavailableResponse() }); + } + + if (mode === 'live') { + const stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); const session = await stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], - success_url: `${origin}/?billing=success`, - cancel_url: `${origin}/?billing=cancel`, + success_url: `${publicOrigin}/?billing=success`, + cancel_url: `${publicOrigin}/?billing=cancel`, client_reference_id: String(orgId), metadata: { orgId: String(orgId) }, }); return { url: session.url, live: true }; } - return { url: `${origin}/?billing=mock&org=${orgId}`, live: false, mock: true }; + + return { url: `${publicOrigin}/?billing=mock&org=${encodeURIComponent(String(orgId))}`, live: false, mock: true }; } From 520f1b77461ee46dcb3451cfba5475d61e68225f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:35:26 +0900 Subject: [PATCH 04/27] test(billing): cover trusted checkout authority --- tests/unit/billing-checkout.test.mjs | 89 ++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/unit/billing-checkout.test.mjs diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs new file mode 100644 index 00000000..7577a1d7 --- /dev/null +++ b/tests/unit/billing-checkout.test.mjs @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createCheckout } from '../../server/billing.mjs'; + +const disabledConfiguration = { mode: 'disabled', publicOrigin: null }; +const mockConfiguration = { mode: 'mock', publicOrigin: 'http://127.0.0.1:8787' }; +const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example.com' }; + +test('unconfigured production checkout fails closed with actionable HTTP 503', async () => { + await assert.rejects( + createCheckout({ orgId: 42, configuration: disabledConfiguration }), + async (error) => { + assert.equal(error.status, 503); + assert.equal(typeof error.getResponse, 'function'); + const response = error.getResponse(); + assert.equal(response.status, 503); + assert.equal(response.headers.get('content-type'), 'application/json; charset=UTF-8'); + const payload = await response.json(); + assert.equal(payload.error, 'billing_not_configured'); + assert.match(payload.action, /Configure the complete Stripe billing settings/); + return true; + }, + ); +}); + +test('development mock uses only the operator-owned public origin', async () => { + const checkout = await createCheckout({ + orgId: 'org /?#42', + origin: 'https://attacker.example', + configuration: mockConfiguration, + }); + + assert.deepEqual(checkout, { + url: 'http://127.0.0.1:8787/?billing=mock&org=org%20%2F%3F%2342', + live: false, + mock: true, + }); + assert.doesNotMatch(checkout.url, /attacker\.example/); +}); + +test('live checkout builds redirects from canonical configuration and preserves server identity', async () => { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_SECRET_KEY = 'sk_test_trusted'; + process.env.STRIPE_PRICE_ID = 'price_trusted'; + + const calls = []; + const fakeStripeClientFactory = async (secretKey) => { + assert.equal(secretKey, 'sk_test_trusted'); + return { + checkout: { + sessions: { + async create(payload) { + calls.push(payload); + return { url: 'https://checkout.stripe.com/c/pay/cs_test_123' }; + }, + }, + }, + }; + }; + + try { + const checkout = await createCheckout({ + orgId: 73, + origin: 'https://attacker.example', + configuration: liveConfiguration, + stripeClientFactory: fakeStripeClientFactory, + }); + + assert.deepEqual(checkout, { + url: 'https://checkout.stripe.com/c/pay/cs_test_123', + live: true, + }); + assert.deepEqual(calls, [{ + mode: 'subscription', + line_items: [{ price: 'price_trusted', quantity: 1 }], + success_url: 'https://planner.example.com/?billing=success', + cancel_url: 'https://planner.example.com/?billing=cancel', + client_reference_id: '73', + metadata: { orgId: '73' }, + }]); + } 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 1b3594c227bdaa742fa54330ea0491c205d073a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:35:51 +0900 Subject: [PATCH 05/27] test(api): prove checkout ignores request host authority --- tests/api/billing-checkout.test.mjs | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/api/billing-checkout.test.mjs diff --git a/tests/api/billing-checkout.test.mjs b/tests/api/billing-checkout.test.mjs new file mode 100644 index 00000000..8aa7580d --- /dev/null +++ b/tests/api/billing-checkout.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'http://127.0.0.1:8787'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.STRIPE_SECRET_KEY; +delete process.env.STRIPE_PRICE_ID; +delete process.env.STRIPE_WEBHOOK_SECRET; + +const { app } = await import('../../server/app.mjs'); + +const jsonHeaders = { 'content-type': 'application/json' }; + +test('checkout redirects use the operator origin even when request authority differs', async () => { + let response = await app.request('https://attacker.example/api/auth/signup', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + email: 'billing-origin@example.test', + password: 'password123', + name: 'Billing Origin', + }), + }); + assert.equal(response.status, 200); + const { token } = await response.json(); + assert.ok(token); + + response = await app.request('https://attacker.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, 200); + const me = await response.json(); + const orgId = me.orgs[0].id; + assert.ok(orgId); + + response = await app.request(`https://attacker.example/api/orgs/${orgId}/checkout`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, 200); + const checkout = await response.json(); + assert.equal(checkout.mock, true); + assert.equal(checkout.live, false); + assert.equal(checkout.url, `http://127.0.0.1:8787/?billing=mock&org=${orgId}`); + assert.doesNotMatch(checkout.url, /attacker\.example/); +}); From 311f084a28d2f207e714fc3311959acd0d02d5e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:36:51 +0900 Subject: [PATCH 06/27] test(billing): register checkout coverage evidence --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 46d07bfb..9f0531fb 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 tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", - "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/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", From 303e4c6f9d7d0c425156b6865c7014c797884d95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:37:28 +0900 Subject: [PATCH 07/27] docs(billing): define trusted checkout configuration --- docs/billing-production.md | 82 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/billing-production.md diff --git a/docs/billing-production.md b/docs/billing-production.md new file mode 100644 index 00000000..d5c8abb6 --- /dev/null +++ b/docs/billing-production.md @@ -0,0 +1,82 @@ +# Billing production configuration + +ScopeWeave treats billing as a separately deployable capability. An absent Stripe +configuration does **not** imply a successful production checkout. The only +successful mock path is explicit development mode. + +## Configuration contract + +A live checkout process requires all of the following values together: + +- `STRIPE_SECRET_KEY` +- `STRIPE_PRICE_ID` +- `STRIPE_WEBHOOK_SECRET` +- `SCOPEWEAVE_PUBLIC_ORIGIN` + +The three Stripe values are an all-or-none startup tuple. A partial tuple stops +application startup with `billing_configuration_incomplete`. A complete Stripe +tuple without `SCOPEWEAVE_PUBLIC_ORIGIN` stops startup with +`billing_public_origin_required`. + +`SCOPEWEAVE_PUBLIC_ORIGIN` is the operator-owned browser origin used to construct +Checkout success and cancellation URLs. ScopeWeave parses it with the platform +`URL` implementation and accepts a root HTTPS origin only. URL credentials, +paths, query strings, fragments, unsupported schemes, and remote plaintext HTTP +are rejected. Explicit `SCOPEWEAVE_DEV=1` may use HTTP only on `localhost`, +`127.0.0.1`, or `::1`. + +Example production shape: + +```text +SCOPEWEAVE_PUBLIC_ORIGIN=https://planner.example.com +STRIPE_SECRET_KEY= +STRIPE_PRICE_ID=price_... +STRIPE_WEBHOOK_SECRET= +``` + +Do not derive `SCOPEWEAVE_PUBLIC_ORIGIN` from `Host`, `Forwarded`, +`X-Forwarded-Host`, or the incoming request URL. Proxy headers describe a request +path through infrastructure; they are not billing redirect authority. + +## Disabled and development behavior + +With no Stripe tuple, production billing is disabled. A checkout attempt fails +closed with HTTP 503 and `billing_not_configured` rather than generating a fake +success URL. The response tells the operator to configure the complete Stripe +settings and public origin, then restart ScopeWeave. + +For local integration tests, `SCOPEWEAVE_DEV=1` plus a valid loopback +`SCOPEWEAVE_PUBLIC_ORIGIN` enables the mock checkout. The mock URL is built from +the configured origin and a percent-encoded organization identifier; a different +request host cannot replace that origin. + +## Current slice boundary + +This document describes only the trusted-configuration and redirect-authority +slice of issue #488. It does **not** declare the Stripe lifecycle production +complete. Before production billing can be release-approved, ScopeWeave still +needs the remaining #488 controls, including durable checkout attempts and stable +idempotency keys, a packaged/pinned provider SDK and bounded provider transport, +validated returned Checkout destinations, raw-body webhook verification and +size limits, durable event deduplication, out-of-order reconciliation, normalized +subscription/payment/entitlement state, rollback/recovery procedures, and +end-to-end operational acceptance evidence. + +## Operator verification + +Before a billing-enabled rollout: + +1. Start a canary with the complete Stripe tuple and the exact public browser + origin intended for customer redirects. +2. Confirm malformed, partial, path-bearing, query-bearing, credential-bearing, + and plaintext remote origins stop startup. +3. Send a checkout request through the same reverse proxy used in production + while varying the request authority; success/cancel URLs must still use only + `SCOPEWEAVE_PUBLIC_ORIGIN`. +4. Keep the rollout blocked until the remaining #488 lifecycle controls are + implemented and their exact-head security, coverage, review, rollback, and + recovery gates pass together. + +Rollback for this slice is configuration-neutral: revert the validation module, +checkout authority change, and tests together. No database migration or +persisted billing state is introduced here. From be5dbd719b7468199cb066b284ecbb236b338d6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:38:06 +0900 Subject: [PATCH 08/27] docs(doctoring): trace trusted checkout origin evidence --- .../stripe-checkout-trusted-origin.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/doctoring/stripe-checkout-trusted-origin.md diff --git a/docs/doctoring/stripe-checkout-trusted-origin.md b/docs/doctoring/stripe-checkout-trusted-origin.md new file mode 100644 index 00000000..3a5cac20 --- /dev/null +++ b/docs/doctoring/stripe-checkout-trusted-origin.md @@ -0,0 +1,106 @@ +# Stripe checkout trusted-origin evidence + +## Decision + +ScopeWeave separates request authority from billing redirect authority. Checkout +success/cancel URLs derive only from the operator-owned +`SCOPEWEAVE_PUBLIC_ORIGIN`; an inbound request URL, `Host`, or forwarded host is +not a trusted redirect source. + +A Stripe-enabled process must also receive `STRIPE_SECRET_KEY`, +`STRIPE_PRICE_ID`, and `STRIPE_WEBHOOK_SECRET` as one complete startup tuple. +Partial provider configuration fails startup. A complete tuple without the +public origin fails startup. Without the tuple, production billing remains +disabled; only explicit `SCOPEWEAVE_DEV=1` plus a valid public loopback origin +may select the mock checkout path. + +The configured public origin is parsed with the WHATWG `URL` API and is accepted +only as a root HTTPS origin. Credentials, a configured path, query, fragment, +unsupported scheme, and remote plaintext HTTP are rejected. Development HTTP is +limited to `localhost`, `127.0.0.1`, and WHATWG-serialized IPv6 loopback `[::1]`. + +## Threat and standards rationale + +Stripe Checkout sessions are created server-side and carry success/cancel URLs. +Using request authority to populate those URLs would let reverse-proxy or +host-header misconfiguration influence a security-sensitive customer redirect. +The operator origin is therefore explicit configuration rather than request +derived data. + +The WHATWG URL Standard defines the parsed URL components and tuple origin used +by the JavaScript `URL` implementation. Parsing first and then applying +component-level policy avoids ambiguous prefix/string matching. + +Stripe documents idempotency keys for safely retrying POST requests and webhook +handling requirements including raw-body signature verification, duplicate +events, and non-guaranteed event ordering. Those requirements are intentionally +recorded here as the next lifecycle boundary; this slice does not claim to have +implemented them. + +## Executable evidence + +`tests/unit/billing-configuration.test.mjs` proves: + +- no provider tuple in production resolves to a disabled capability, not a mock; +- explicit development mode plus loopback origin enables only the mock; +- partial Stripe tuples fail closed; +- a live tuple requires a canonical public origin; +- credentials, path, query, fragment, malformed URLs, unsupported schemes, and + remote HTTP are rejected; and +- development loopback HTTP and canonical HTTPS serialization behave exactly as + documented. + +`tests/unit/billing-checkout.test.mjs` proves: + +- disabled production checkout raises an actionable HTTP 503 response; +- a caller-supplied/request-derived `origin` property is ignored by the checkout + implementation; +- mock organization identifiers are percent encoded; and +- an injected deterministic Stripe client receives success/cancel URLs built + from the configured public origin rather than a request host. + +`tests/api/billing-checkout.test.mjs` drives the real Hono route with requests +addressed to `https://attacker.example` while the operator origin is +`http://127.0.0.1:8787`; the returned mock Checkout URL remains bound to the +operator origin. The package coverage producer includes both billing production +modules and these regressions. + +## Scope limit and remaining acquisition gap + +This is the first bounded vertical slice of issue #488 and **does not close it**. +It introduces no billing database schema and makes no claim that subscription +entitlements are production complete. The following remain blocking work: + +- durable checkout-attempt UUIDs and stable Stripe idempotency keys; +- a packaged/pinned Stripe SDK plus bounded provider connect/total time, + redirects, response bytes, and JSON parsing; +- validation of returned hosted Checkout destinations; +- exact raw-body webhook signature verification with bounded timestamp + tolerance and body size; +- durable event-ID deduplication and non-sensitive audit metadata; +- out-of-order event reconciliation against authoritative provider state or a + monotonic per-object cursor; +- 3NF customer/subscription/payment/organization-entitlement state machines; +- transactional, reversible entitlement transitions; and +- migration, incident, recovery, privacy, test-mode provider smoke, and release + acceptance evidence. + +## Rollback + +Rollback reverts `server/billing_configuration.mjs`, the checkout authority +change in `server/billing.mjs`, the registered unit/API coverage cases, billing +operations documentation, and this evidence record together. No database +migration or persisted billing record is introduced by this slice. + +## References + +Stripe. (n.d.). *Create a Checkout Session*. Stripe API Reference. +https://docs.stripe.com/api/checkout/sessions/create + +Stripe. (n.d.). *Idempotent requests*. Stripe API Reference. +https://docs.stripe.com/api/idempotent_requests + +Stripe. (n.d.). *Receive Stripe events in your webhook endpoint*. Stripe +Documentation. https://docs.stripe.com/webhooks + +WHATWG. (2026). *URL Standard*. https://url.spec.whatwg.org/ From 931d828b5b1a234927949c7acd16995884248d07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:38:45 +0900 Subject: [PATCH 09/27] docs(changelog): record trusted billing origin --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 787ee51b..6cd6a4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Bound Stripe Checkout success/cancel redirects to an operator-configured + canonical public origin instead of request authority, rejected partial or + ambiguous billing configuration at startup, and confined successful mock + checkout to explicit development mode. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden From 956742dbcd6abdba9905acd44f044ee8670d8aa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:52:03 +0900 Subject: [PATCH 10/27] test(billing): inspect rejected response after sync validation --- tests/unit/billing-checkout.test.mjs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index 7577a1d7..bac9b9a4 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -8,20 +8,23 @@ const mockConfiguration = { mode: 'mock', publicOrigin: 'http://127.0.0.1:8787' const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example.com' }; test('unconfigured production checkout fails closed with actionable HTTP 503', async () => { + let rejectedError; await assert.rejects( createCheckout({ orgId: 42, configuration: disabledConfiguration }), - async (error) => { + (error) => { + rejectedError = error; assert.equal(error.status, 503); assert.equal(typeof error.getResponse, 'function'); - const response = error.getResponse(); - assert.equal(response.status, 503); - assert.equal(response.headers.get('content-type'), 'application/json; charset=UTF-8'); - const payload = await response.json(); - assert.equal(payload.error, 'billing_not_configured'); - assert.match(payload.action, /Configure the complete Stripe billing settings/); return true; }, ); + + const response = rejectedError.getResponse(); + assert.equal(response.status, 503); + assert.equal(response.headers.get('content-type'), 'application/json; charset=UTF-8'); + const payload = await response.json(); + assert.equal(payload.error, 'billing_not_configured'); + assert.match(payload.action, /Configure the complete Stripe billing settings/); }); test('development mock uses only the operator-owned public origin', async () => { From 47049dd645b2610b08ad4cf586659a3cb12914c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:04:45 +0900 Subject: [PATCH 11/27] test(api): configure trusted billing origin for smoke --- tests/api/smoke.env | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/api/smoke.env diff --git a/tests/api/smoke.env b/tests/api/smoke.env new file mode 100644 index 00000000..a549f324 --- /dev/null +++ b/tests/api/smoke.env @@ -0,0 +1,2 @@ +# Canonical loopback browser origin for the development-only billing smoke path. +SCOPEWEAVE_PUBLIC_ORIGIN=http://127.0.0.1:8787 From 3ae911e416439ab7699d50af4b6655007ea0022d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:05:29 +0900 Subject: [PATCH 12/27] test(api): load billing origin in smoke harness --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9f0531fb..c44a9576 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "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 tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs", "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && npm run test:api", From ad81eb52a6f0e3448e6a17f0e500e1f140993c92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:19:02 +0900 Subject: [PATCH 13/27] test(billing): prove default live transport needs no undeclared SDK --- tests/unit/billing-checkout.test.mjs | 54 ++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index bac9b9a4..b88d5396 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -90,3 +90,57 @@ test('live checkout builds redirects from canonical configuration and preserves else process.env.STRIPE_PRICE_ID = previousPrice; } }); + +test('default live provider transport uses Stripe HTTPS without an undeclared runtime SDK', async () => { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + const previousFetch = globalThis.fetch; + process.env.STRIPE_SECRET_KEY = 'sk_test_default_transport'; + process.env.STRIPE_PRICE_ID = 'price_default_transport'; + + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + return new Response(JSON.stringify({ + url: 'https://checkout.stripe.com/c/pay/cs_test_default_transport', + }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + }; + + try { + const checkout = await createCheckout({ + orgId: 91, + origin: 'https://attacker.example', + configuration: liveConfiguration, + }); + + assert.deepEqual(checkout, { + url: 'https://checkout.stripe.com/c/pay/cs_test_default_transport', + live: true, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://api.stripe.com/v1/checkout/sessions'); + assert.equal(calls[0].options.method, 'POST'); + assert.equal(calls[0].options.redirect, 'error'); + assert.ok(calls[0].options.signal instanceof AbortSignal); + assert.equal(calls[0].options.headers.authorization, 'Bearer sk_test_default_transport'); + assert.equal(calls[0].options.headers['content-type'], 'application/x-www-form-urlencoded'); + + const form = new URLSearchParams(calls[0].options.body); + assert.equal(form.get('mode'), 'subscription'); + assert.equal(form.get('line_items[0][price]'), 'price_default_transport'); + assert.equal(form.get('line_items[0][quantity]'), '1'); + assert.equal(form.get('success_url'), 'https://planner.example.com/?billing=success'); + assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); + assert.equal(form.get('client_reference_id'), '91'); + assert.equal(form.get('metadata[orgId]'), '91'); + } finally { + globalThis.fetch = previousFetch; + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +}); From 0b5e1d9a25a986546efa79d6b0a62d7b1e8395fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:41:17 +0900 Subject: [PATCH 14/27] fix(billing): use declared provider transport for checkout --- server/billing.mjs | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index 398db1e0..f95aa243 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -5,6 +5,8 @@ import { HTTPException } from 'hono/http-exception'; import { validateBillingStartupConfiguration } from './billing_configuration.mjs'; const billingConfiguration = validateBillingStartupConfiguration(); +const STRIPE_CHECKOUT_ENDPOINT = 'https://api.stripe.com/v1/checkout/sessions'; +const STRIPE_REQUEST_TIMEOUT_MS = 15_000; export const PLANS = { free: { name: 'Free', limits: { projects: 2, members: 3 }, priceKrw: 0 }, @@ -40,9 +42,38 @@ function billingUnavailableResponse() { }); } +function stripeCheckoutForm(payload) { + return new URLSearchParams([ + ['mode', payload.mode], + ['line_items[0][price]', payload.line_items[0].price], + ['line_items[0][quantity]', String(payload.line_items[0].quantity)], + ['success_url', payload.success_url], + ['cancel_url', payload.cancel_url], + ['client_reference_id', payload.client_reference_id], + ['metadata[orgId]', payload.metadata.orgId], + ]); +} + async function defaultStripeClientFactory(secretKey) { - const { default: Stripe } = await import('stripe'); - return new Stripe(secretKey); + return { + checkout: { + sessions: { + async create(payload) { + const response = await fetch(STRIPE_CHECKOUT_ENDPOINT, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(STRIPE_REQUEST_TIMEOUT_MS), + headers: { + authorization: `Bearer ${secretKey}`, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: stripeCheckoutForm(payload).toString(), + }); + return response.json(); + }, + }, + }, + }; } /** @@ -58,7 +89,7 @@ async function defaultStripeClientFactory(secretKey) { * @param {{mode: 'disabled'|'mock'|'live', publicOrigin: string|null}} [options.configuration] * Validated billing capability; defaults to startup configuration. * @param {(secretKey: string) => Promise} [options.stripeClientFactory] - * Stripe client factory; injectable for deterministic provider-contract tests. + * Stripe-compatible provider factory; injectable for deterministic contract tests. * @returns {Promise<{url: string, live: boolean, mock?: boolean}>} Checkout target. * @throws {HTTPException} HTTP 503 when production billing is not configured. */ From 6cb438576af931a4150d6042db1a6f8bcb0e2d5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:11:26 +0900 Subject: [PATCH 15/27] fix(test): remove unrelated attribution registrations --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e07c9365..eeb8436d 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/billing-checkout.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && 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 0b46b904b85b37e41f3c1042096cbf3cf697bede Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:11:32 +0900 Subject: [PATCH 16/27] test(billing): fail closed on Stripe provider errors --- tests/unit/billing-checkout.test.mjs | 87 ++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index b88d5396..de111a01 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -7,6 +7,49 @@ const disabledConfiguration = { mode: 'disabled', publicOrigin: null }; const mockConfiguration = { mode: 'mock', publicOrigin: 'http://127.0.0.1:8787' }; const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example.com' }; +async function withDefaultStripeTransport(responseFactory, assertion) { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + const previousFetch = globalThis.fetch; + process.env.STRIPE_SECRET_KEY = 'sk_test_default_transport'; + process.env.STRIPE_PRICE_ID = 'price_default_transport'; + globalThis.fetch = responseFactory; + + try { + await assertion(); + } 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; + } +} + +async function expectSafeProviderFailure(responseFactory) { + await withDefaultStripeTransport(responseFactory, async () => { + let rejectedError; + await assert.rejects( + createCheckout({ orgId: 91, configuration: liveConfiguration }), + (error) => { + rejectedError = error; + assert.equal(error.status, 502); + assert.equal(typeof error.getResponse, 'function'); + return true; + }, + ); + + const response = rejectedError.getResponse(); + assert.equal(response.status, 502); + assert.equal(response.headers.get('content-type'), 'application/json; charset=UTF-8'); + const payload = await response.json(); + assert.deepEqual(payload, { + error: 'billing_provider_unavailable', + action: 'Checkout could not be started. Retry later; if the problem persists, contact your ScopeWeave operator.', + }); + }); +} + test('unconfigured production checkout fails closed with actionable HTTP 503', async () => { let rejectedError; await assert.rejects( @@ -92,14 +135,8 @@ test('live checkout builds redirects from canonical configuration and preserves }); test('default live provider transport uses Stripe HTTPS without an undeclared runtime SDK', async () => { - const previousSecret = process.env.STRIPE_SECRET_KEY; - const previousPrice = process.env.STRIPE_PRICE_ID; - const previousFetch = globalThis.fetch; - process.env.STRIPE_SECRET_KEY = 'sk_test_default_transport'; - process.env.STRIPE_PRICE_ID = 'price_default_transport'; - const calls = []; - globalThis.fetch = async (url, options) => { + await withDefaultStripeTransport(async (url, options) => { calls.push({ url, options }); return new Response(JSON.stringify({ url: 'https://checkout.stripe.com/c/pay/cs_test_default_transport', @@ -107,9 +144,7 @@ test('default live provider transport uses Stripe HTTPS without an undeclared ru status: 200, headers: { 'content-type': 'application/json; charset=utf-8' }, }); - }; - - try { + }, async () => { const checkout = await createCheckout({ orgId: 91, origin: 'https://attacker.example', @@ -136,11 +171,29 @@ test('default live provider transport uses Stripe HTTPS without an undeclared ru assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); assert.equal(form.get('client_reference_id'), '91'); assert.equal(form.get('metadata[orgId]'), '91'); - } finally { - globalThis.fetch = previousFetch; - if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; - else process.env.STRIPE_SECRET_KEY = previousSecret; - if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; - else process.env.STRIPE_PRICE_ID = previousPrice; - } + }); +}); + +test('default live provider transport rejects non-2xx Stripe responses with a safe retryable error', async () => { + await expectSafeProviderFailure(async () => new Response(JSON.stringify({ + error: { message: 'No such price: price_secret_internal_detail' }, + }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8' }, + })); +}); + +test('default live provider transport rejects malformed successful session payloads', async () => { + await expectSafeProviderFailure(async () => new Response(JSON.stringify({ + id: 'cs_test_missing_url', + object: 'checkout.session', + }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + })); + + await expectSafeProviderFailure(async () => new Response('{not-json', { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + })); }); From 1ff56bcf58235dd51fac540661c23f5d49d5ac91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:12:56 +0900 Subject: [PATCH 17/27] fix(billing): fail closed on provider checkout errors --- server/billing.mjs | 101 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index f95aa243..3aed137d 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -42,6 +42,23 @@ function billingUnavailableResponse() { }); } +function billingProviderUnavailableResponse() { + return new Response(JSON.stringify({ + error: 'billing_provider_unavailable', + action: 'Checkout could not be started. Retry later; if the problem persists, contact your ScopeWeave operator.', + }), { + status: 502, + headers: { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=UTF-8', + }, + }); +} + +function billingProviderUnavailable() { + return new HTTPException(502, { res: billingProviderUnavailableResponse() }); +} + function stripeCheckoutForm(payload) { return new URLSearchParams([ ['mode', payload.mode], @@ -54,22 +71,52 @@ function stripeCheckoutForm(payload) { ]); } +function validateCheckoutSessionUrl(session) { + if (!session || typeof session.url !== 'string' || !session.url.trim()) { + throw billingProviderUnavailable(); + } + + try { + const checkoutUrl = new URL(session.url); + if (checkoutUrl.protocol !== 'https:' || checkoutUrl.username || checkoutUrl.password) { + throw billingProviderUnavailable(); + } + } catch (error) { + if (error instanceof HTTPException) throw error; + throw billingProviderUnavailable(); + } + + return session.url; +} + async function defaultStripeClientFactory(secretKey) { return { checkout: { sessions: { async create(payload) { - const response = await fetch(STRIPE_CHECKOUT_ENDPOINT, { - method: 'POST', - redirect: 'error', - signal: AbortSignal.timeout(STRIPE_REQUEST_TIMEOUT_MS), - headers: { - authorization: `Bearer ${secretKey}`, - 'content-type': 'application/x-www-form-urlencoded', - }, - body: stripeCheckoutForm(payload).toString(), - }); - return response.json(); + let response; + try { + response = await fetch(STRIPE_CHECKOUT_ENDPOINT, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(STRIPE_REQUEST_TIMEOUT_MS), + headers: { + authorization: `Bearer ${secretKey}`, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: stripeCheckoutForm(payload).toString(), + }); + } catch { + throw billingProviderUnavailable(); + } + + if (!response.ok) throw billingProviderUnavailable(); + + try { + return await response.json(); + } catch { + throw billingProviderUnavailable(); + } }, }, }, @@ -83,6 +130,8 @@ async function defaultStripeClientFactory(secretKey) { * URLs always derive from the canonical operator-configured public origin. The * successful mock exists only in explicit development mode; an unconfigured * production capability returns HTTP 503 instead of pretending checkout worked. + * Provider transport/status/payload failures return a stable HTTP 502 without + * leaking Stripe response details to the caller. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. @@ -91,7 +140,8 @@ async function defaultStripeClientFactory(secretKey) { * @param {(secretKey: string) => Promise} [options.stripeClientFactory] * Stripe-compatible provider factory; injectable for deterministic contract tests. * @returns {Promise<{url: string, live: boolean, mock?: boolean}>} Checkout target. - * @throws {HTTPException} HTTP 503 when production billing is not configured. + * @throws {HTTPException} HTTP 503 when billing is unconfigured or HTTP 502 when + * the live provider cannot produce a valid hosted Checkout Session URL. */ export async function createCheckout({ orgId, @@ -104,16 +154,23 @@ export async function createCheckout({ } if (mode === 'live') { - const stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); - const session = await stripe.checkout.sessions.create({ - mode: 'subscription', - line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], - success_url: `${publicOrigin}/?billing=success`, - cancel_url: `${publicOrigin}/?billing=cancel`, - client_reference_id: String(orgId), - metadata: { orgId: String(orgId) }, - }); - return { url: session.url, live: true }; + let stripe; + let session; + try { + stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); + session = await stripe.checkout.sessions.create({ + mode: 'subscription', + line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], + success_url: `${publicOrigin}/?billing=success`, + cancel_url: `${publicOrigin}/?billing=cancel`, + client_reference_id: String(orgId), + metadata: { orgId: String(orgId) }, + }); + } catch (error) { + if (error instanceof HTTPException) throw error; + throw billingProviderUnavailable(); + } + return { url: validateCheckoutSessionUrl(session), live: true }; } return { url: `${publicOrigin}/?billing=mock&org=${encodeURIComponent(String(orgId))}`, live: false, mock: true }; From 8363ad8081a1018bcbed44d347614d3eabdb3fd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:13:47 +0900 Subject: [PATCH 18/27] test(billing): cover provider transport failure envelope --- tests/unit/billing-checkout.test.mjs | 82 +++++++++++++++++++++------- 1 file changed, 63 insertions(+), 19 deletions(-) diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index de111a01..895d86e3 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -26,27 +26,32 @@ async function withDefaultStripeTransport(responseFactory, assertion) { } } +async function assertProviderFailure(runCheckout) { + let rejectedError; + await assert.rejects( + runCheckout(), + (error) => { + rejectedError = error; + assert.equal(error.status, 502); + assert.equal(typeof error.getResponse, 'function'); + return true; + }, + ); + + const response = rejectedError.getResponse(); + assert.equal(response.status, 502); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.get('content-type'), 'application/json; charset=UTF-8'); + const payload = await response.json(); + assert.deepEqual(payload, { + error: 'billing_provider_unavailable', + action: 'Checkout could not be started. Retry later; if the problem persists, contact your ScopeWeave operator.', + }); +} + async function expectSafeProviderFailure(responseFactory) { await withDefaultStripeTransport(responseFactory, async () => { - let rejectedError; - await assert.rejects( - createCheckout({ orgId: 91, configuration: liveConfiguration }), - (error) => { - rejectedError = error; - assert.equal(error.status, 502); - assert.equal(typeof error.getResponse, 'function'); - return true; - }, - ); - - const response = rejectedError.getResponse(); - assert.equal(response.status, 502); - assert.equal(response.headers.get('content-type'), 'application/json; charset=UTF-8'); - const payload = await response.json(); - assert.deepEqual(payload, { - error: 'billing_provider_unavailable', - action: 'Checkout could not be started. Retry later; if the problem persists, contact your ScopeWeave operator.', - }); + await assertProviderFailure(() => createCheckout({ orgId: 91, configuration: liveConfiguration })); }); } @@ -183,6 +188,12 @@ test('default live provider transport rejects non-2xx Stripe responses with a sa })); }); +test('default live provider transport rejects network failures without leaking provider detail', async () => { + await expectSafeProviderFailure(async () => { + throw new Error('getaddrinfo ENOTFOUND api.stripe.com internal-network-detail'); + }); +}); + test('default live provider transport rejects malformed successful session payloads', async () => { await expectSafeProviderFailure(async () => new Response(JSON.stringify({ id: 'cs_test_missing_url', @@ -197,3 +208,36 @@ test('default live provider transport rejects malformed successful session paylo headers: { 'content-type': 'application/json; charset=utf-8' }, })); }); + +test('live checkout rejects unsafe or malformed provider redirect URLs', async () => { + for (const url of [ + 'http://checkout.stripe.com/c/pay/cs_test_plaintext', + 'https://user@checkout.stripe.com/c/pay/cs_test_userinfo', + 'https://:password@checkout.stripe.com/c/pay/cs_test_password', + 'not a URL', + ]) { + await assertProviderFailure(() => createCheckout({ + orgId: 92, + configuration: liveConfiguration, + stripeClientFactory: async () => ({ + checkout: { + sessions: { + async create() { + return { url }; + }, + }, + }, + }), + })); + } +}); + +test('live checkout maps unexpected injected provider failures to the same safe envelope', async () => { + await assertProviderFailure(() => createCheckout({ + orgId: 93, + configuration: liveConfiguration, + stripeClientFactory: async () => { + throw new Error('provider credential detail must not escape'); + }, + })); +}); From 35264a801fdc9d9d70628658f34e8b0d387bbb21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:14:56 +0900 Subject: [PATCH 19/27] docs(billing): record fail-closed Stripe response boundary --- .../stripe-checkout-trusted-origin.md | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/doctoring/stripe-checkout-trusted-origin.md b/docs/doctoring/stripe-checkout-trusted-origin.md index 3a5cac20..4226d743 100644 --- a/docs/doctoring/stripe-checkout-trusted-origin.md +++ b/docs/doctoring/stripe-checkout-trusted-origin.md @@ -19,6 +19,15 @@ only as a root HTTPS origin. Credentials, a configured path, query, fragment, unsupported scheme, and remote plaintext HTTP are rejected. Development HTTP is limited to `localhost`, `127.0.0.1`, and WHATWG-serialized IPv6 loopback `[::1]`. +The default live Checkout transport uses the platform HTTPS `fetch` boundary, +not an undeclared Stripe runtime SDK. A provider response is accepted only when +HTTP reports success, JSON parsing succeeds, and the resulting hosted Checkout +Session contains a non-empty HTTPS URL without URL credentials. Network errors, +timeouts, non-2xx provider responses, malformed JSON, missing URLs, plaintext +URLs, and credential-bearing URLs fail closed as a stable HTTP 502 response. +Provider response bodies and transport details are never copied into that +customer-facing failure payload. + ## Threat and standards rationale Stripe Checkout sessions are created server-side and carry success/cancel URLs. @@ -27,6 +36,15 @@ host-header misconfiguration influence a security-sensitive customer redirect. The operator origin is therefore explicit configuration rather than request derived data. +Stripe's API error contract uses conventional HTTP status classes: successful +requests are represented by 2xx responses, while 4xx and 5xx responses represent +request/provider failures. Treating an error document as a successful Checkout +Session can return an undefined or otherwise unusable redirect to the buyer, so +the direct transport validates HTTP success before parsing the session. Stripe's +Checkout Session API returns a Checkout Session object after successful +creation; ScopeWeave additionally validates the returned hosted URL before +exposing it to the caller. + The WHATWG URL Standard defines the parsed URL components and tuple origin used by the JavaScript `URL` implementation. Parsing first and then applying component-level policy avoids ambiguous prefix/string matching. @@ -34,8 +52,8 @@ component-level policy avoids ambiguous prefix/string matching. Stripe documents idempotency keys for safely retrying POST requests and webhook handling requirements including raw-body signature verification, duplicate events, and non-guaranteed event ordering. Those requirements are intentionally -recorded here as the next lifecycle boundary; this slice does not claim to have -implemented them. +recorded here as the next lifecycle boundary; this root slice does not claim to +have implemented them. ## Executable evidence @@ -55,9 +73,16 @@ implemented them. - disabled production checkout raises an actionable HTTP 503 response; - a caller-supplied/request-derived `origin` property is ignored by the checkout implementation; -- mock organization identifiers are percent encoded; and +- mock organization identifiers are percent encoded; - an injected deterministic Stripe client receives success/cancel URLs built - from the configured public origin rather than a request host. + from the configured public origin rather than a request host; +- the default provider path posts only to Stripe's HTTPS Checkout Sessions API; +- provider non-2xx responses and network failures collapse to a non-leaking HTTP + 502 failure envelope; +- malformed success JSON and missing hosted URLs are rejected; +- plaintext, malformed, or URL-credential-bearing provider redirects are + rejected; and +- unexpected injected-provider failures use the same safe failure envelope. `tests/api/billing-checkout.test.mjs` drives the real Hono route with requests addressed to `https://attacker.example` while the operator origin is @@ -72,9 +97,8 @@ It introduces no billing database schema and makes no claim that subscription entitlements are production complete. The following remain blocking work: - durable checkout-attempt UUIDs and stable Stripe idempotency keys; -- a packaged/pinned Stripe SDK plus bounded provider connect/total time, - redirects, response bytes, and JSON parsing; -- validation of returned hosted Checkout destinations; +- bounded provider response-size enforcement and retry policy that distinguishes + safe transient failure from permanent configuration/request failure; - exact raw-body webhook signature verification with bounded timestamp tolerance and body size; - durable event-ID deduplication and non-sensitive audit metadata; @@ -87,16 +111,23 @@ entitlements are production complete. The following remain blocking work: ## Rollback -Rollback reverts `server/billing_configuration.mjs`, the checkout authority -change in `server/billing.mjs`, the registered unit/API coverage cases, billing -operations documentation, and this evidence record together. No database -migration or persisted billing record is introduced by this slice. +Rollback reverts `server/billing_configuration.mjs`, the checkout authority and +provider-response validation in `server/billing.mjs`, the registered unit/API +coverage cases, billing operations documentation, and this evidence record +together. No database migration or persisted billing record is introduced by +this slice. ## References Stripe. (n.d.). *Create a Checkout Session*. Stripe API Reference. https://docs.stripe.com/api/checkout/sessions/create +Stripe. (n.d.). *Errors*. Stripe API Reference. +https://docs.stripe.com/api/errors + +Stripe. (n.d.). *Error handling*. Stripe Documentation. +https://docs.stripe.com/error-handling + Stripe. (n.d.). *Idempotent requests*. Stripe API Reference. https://docs.stripe.com/api/idempotent_requests From d848bbbe1c1c23f6f7d0b961b75724c1a63b5703 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:15:28 +0900 Subject: [PATCH 20/27] docs(billing): record provider failure handling --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aee4cf07..0fd22f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 canonical public origin instead of request authority, rejected partial or ambiguous billing configuration at startup, and confined successful mock checkout to explicit development mode. +- Made live Stripe Checkout fail closed on network errors, provider non-2xx + responses, malformed JSON, missing hosted URLs, plaintext redirect URLs, and + URL credentials, returning a stable non-leaking HTTP 502 retry/operator action + instead of treating provider error documents as successful sessions. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden From 58b348028e0a9aae4c2458086471c066b7a4ed6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:17:47 +0900 Subject: [PATCH 21/27] fix(billing): normalize all provider exceptions --- server/billing.mjs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index 3aed137d..b03b6467 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -76,13 +76,14 @@ function validateCheckoutSessionUrl(session) { throw billingProviderUnavailable(); } + let checkoutUrl; try { - const checkoutUrl = new URL(session.url); - if (checkoutUrl.protocol !== 'https:' || checkoutUrl.username || checkoutUrl.password) { - throw billingProviderUnavailable(); - } - } catch (error) { - if (error instanceof HTTPException) throw error; + checkoutUrl = new URL(session.url); + } catch { + throw billingProviderUnavailable(); + } + + if (checkoutUrl.protocol !== 'https:' || checkoutUrl.username || checkoutUrl.password) { throw billingProviderUnavailable(); } @@ -154,10 +155,9 @@ export async function createCheckout({ } if (mode === 'live') { - let stripe; let session; try { - stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); + const stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); session = await stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], @@ -166,8 +166,7 @@ export async function createCheckout({ client_reference_id: String(orgId), metadata: { orgId: String(orgId) }, }); - } catch (error) { - if (error instanceof HTTPException) throw error; + } catch { throw billingProviderUnavailable(); } return { url: validateCheckoutSessionUrl(session), live: true }; From 3be747d00022f27bb7ebf9aa81a064ab7712b1df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 00:18:26 +0900 Subject: [PATCH 22/27] test(billing): cover absent provider redirect shapes --- tests/unit/billing-checkout.test.mjs | 32 ++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index 895d86e3..a184be8d 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -55,6 +55,18 @@ async function expectSafeProviderFailure(responseFactory) { }); } +function fixedSessionFactory(session) { + return async () => ({ + checkout: { + sessions: { + async create() { + return session; + }, + }, + }, + }); +} + test('unconfigured production checkout fails closed with actionable HTTP 503', async () => { let rejectedError; await assert.rejects( @@ -209,6 +221,16 @@ test('default live provider transport rejects malformed successful session paylo })); }); +test('live checkout rejects absent and blank provider redirect shapes', async () => { + for (const session of [null, {}, { url: null }, { url: '' }, { url: ' ' }]) { + await assertProviderFailure(() => createCheckout({ + orgId: 92, + configuration: liveConfiguration, + stripeClientFactory: fixedSessionFactory(session), + })); + } +}); + test('live checkout rejects unsafe or malformed provider redirect URLs', async () => { for (const url of [ 'http://checkout.stripe.com/c/pay/cs_test_plaintext', @@ -219,15 +241,7 @@ test('live checkout rejects unsafe or malformed provider redirect URLs', async ( await assertProviderFailure(() => createCheckout({ orgId: 92, configuration: liveConfiguration, - stripeClientFactory: async () => ({ - checkout: { - sessions: { - async create() { - return { url }; - }, - }, - }, - }), + stripeClientFactory: fixedSessionFactory({ url }), })); } }); From 4c0592db7ce362c74dae7910a0bf4a4e30710744 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:00:26 -0700 Subject: [PATCH 23/27] fix(stack): preserve protected develop in billing reconciliation --- CHANGELOG.md | 5 + .../contextual-orchestrator-auto-default.md | 41 ++++++ docs/orchestrator-production.md | 22 ++++ package.json | 6 +- server/app.mjs | 5 +- server/orchestrator.mjs | 71 ++++++++++- tests/api/orchestrator-attribution.test.mjs | 89 +++++++++++++ tests/unit/orchestrator-attribution.test.mjs | 117 ++++++++++++++++++ tests/unit/orchestrator.test.mjs | 1 + 9 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 docs/doctoring/contextual-orchestrator-auto-default.md create mode 100644 tests/api/orchestrator-attribution.test.mjs create mode 100644 tests/unit/orchestrator-attribution.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c59e6941..be8862d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Switched the repository-local OpenCode development configuration from GitHub Models to an NVIDIA NIM-only candidate set while preserving organization-level review-workflow ownership in `ContextualWisdomLab/.github`. +- Production planning-analysis requests now combine tenant-bound, server-derived + contextual-orchestrator cost attribution with explicit `auto` orchestration + mode, delegating provider/model/topology policy to the shared service without + weakening ScopeWeave's authenticated, fail-closed transport or response + boundary controls. - Accepted XML whitespace before exact Microsoft Project element delimiters while preserving the linear, regex-free import scanner and rejecting attributes, longer names, non-XML whitespace, nested unmatched blocks, and diff --git a/docs/doctoring/contextual-orchestrator-auto-default.md b/docs/doctoring/contextual-orchestrator-auto-default.md new file mode 100644 index 00000000..c3d5d2f5 --- /dev/null +++ b/docs/doctoring/contextual-orchestrator-auto-default.md @@ -0,0 +1,41 @@ +# Contextual-orchestrator adaptive planning default + +## Status + +Active pull-request evidence. This record does not describe protected `develop` until the owning pull request is integrated. + +## Decision boundary + +ScopeWeave owns the meaning, authorization, cost attribution, and presentation of a planning-analysis request. The shared `contextual-orchestrator` service owns provider/model selection and the depth/topology of execution. Production ScopeWeave requests therefore send `orchestration_mode: "auto"` explicitly instead of relying on an implicit gateway default or selecting `route`/`conduct` locally. + +The binding dependency evidence verified for this slice is protected `ContextualWisdomLab/contextual-orchestrator` `main` commit `6841b71935e0b7cb98fb52bcb4709cc5100c8d87`. At that revision, `/v1/chat/completions` accepts `orchestration_mode`, permits `auto`, `route`, and `conduct`, accepts bounded attribution metadata, and routes execution through the orchestrator rather than treating the request model label as a provider lock. + +This decision does **not** promise a specific provider, model, worker count, topology, verifier strategy, or cost heuristic. Those remain shared-service policy and may evolve behind its versioned contract. + +## Attribution and tenant authority + +Authenticated project AI briefings attach `service=scopeweave` and the project organization as `account` only after membership-scoped project authorization. Browser request fields cannot select another tenant's accounting identity. The client forwards only supported attribution dimensions, accepts bounded strings or finite numeric identifiers, uses a prototype-free validated map, and omits empty attribution. These labels are accounting metadata and never grant execution-provider or model-selection authority. + +## Security and standalone behavior + +The change preserves the protected ScopeWeave orchestrator boundary: authenticated canonical provider origin, HTTPS outside explicit loopback development, bounded messages, 120-second request timeout, bounded streamed provider responses, sanitized failures, and deterministic text only under explicit `SCOPEWEAVE_DEV=1` development mode. No provider credential or caller-controlled execution policy is added. + +## TDD and overlap-convergence evidence + +The adaptive-mode work originally existed separately in PR #529 while cost attribution occupied the same production request-body boundary in PR #496. Keeping both as independent roots created a concrete future regression risk: whichever branch integrated second could erase the other request field. The older attribution owner was therefore made the canonical combined boundary rather than allowing two competing implementations. + +On the canonical branch, test-only commits `dc71cdff9dc258b8f196c35d9b92c1542e869043` and `5510058ae7437ede44fb7a7fd94351ac7f7d6b14` first require `orchestration_mode: "auto"` both on ordinary hardened requests and while tenant-bound attribution is present or omitted. Source commit `bd8878591bfa74b67ae2a36b122513d2c41e376f` then composes adaptive routing with the existing sanitized attribution request. Exact-current-head hosted evidence remains authoritative; predecessor checks are not reused. + +## Rollback + +Rollback of adaptive mode removes the explicit `orchestration_mode` field and its matching regression/documentation while preserving the tenant-bound attribution and hardened transport. Rollback of attribution separately removes only the attribution call-site, sanitizer, and attribution regressions. Neither rollback may restore stale pre-hardening orchestrator source or a self-modifying workflow. + +## APA 7th references + +Contextual Wisdom Lab. (2026). *contextual-orchestrator* (Commit 6841b71935e0b7cb98fb52bcb4709cc5100c8d87) [Computer software]. GitHub. + +Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). *Learning to orchestrate agents in natural language with the Conductor*. arXiv. https://doi.org/10.48550/arXiv.2512.04388 + +Sakana AI. (2026). *Sakana Fugu: Multi-agent system as a model*. https://sakana.ai/fugu/ + +Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator*. arXiv. https://doi.org/10.48550/arXiv.2512.04695 diff --git a/docs/orchestrator-production.md b/docs/orchestrator-production.md index c2c4c5c7..e090a17b 100644 --- a/docs/orchestrator-production.md +++ b/docs/orchestrator-production.md @@ -38,6 +38,28 @@ endpoint is absent. That variable must never be set in staging or production. ## Orchestration responsibility +ScopeWeave explicitly sends `orchestration_mode: "auto"` together with the +configured model and validated messages on production briefing requests. The +current protected `ContextualWisdomLab/contextual-orchestrator` `main` contract +verified for this change, commit +`6841b71935e0b7cb98fb52bcb4709cc5100c8d87`, accepts `auto`, `route`, and +`conduct` as orchestration modes. ScopeWeave chooses `auto` as its default so +execution policy can be optimized centrally without coupling this product to a +specific provider, worker count, topology, verifier pattern, or cost heuristic. +Those internal choices remain `contextual-orchestrator` authority and are not a +ScopeWeave compatibility promise. + +For authenticated project AI briefings, ScopeWeave also sends bounded business +cost attribution derived from server-side project state. `service=scopeweave` +and the authenticated project organization `account` are attached only after +membership-scoped project access succeeds. Caller payload fields cannot choose +another tenant's attribution. The client forwards only the orchestration +service's supported attribution dimensions, accepts only bounded string or +finite numeric values, holds validated labels in a prototype-free map, and +omits the attribution object entirely when no valid labels remain. Attribution +is accounting metadata only: it cannot select an execution provider, model, or +orchestration topology. + ScopeWeave intentionally sends only a versioned OpenAI-compatible request to the orchestration service. Model selection, single-model versus multi-agent allocation, task decomposition, role-specific reasoning effort, recursion diff --git a/package.json b/package.json index 0db4fe42..b8b5d91e 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/billing-checkout.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/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && 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/app.mjs b/server/app.mjs index 03908830..c432a84f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -995,7 +995,10 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { const analysis = await orchestratorChat([ { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, { role: 'user', content: context }, - ]); + ], { + service: 'scopeweave', + account: String(p.org_id), + }); logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); return c.json({ analysis }); } catch (e) { diff --git a/server/orchestrator.mjs b/server/orchestrator.mjs index b3e8e400..fccf23d0 100644 --- a/server/orchestrator.mjs +++ b/server/orchestrator.mjs @@ -9,6 +9,17 @@ const MAX_CONTENT_LENGTH = 100_000; const MAX_PROVIDER_RESPONSE_BYTES = 1024 * 1024; // WHATWG URL serializes an IPv6 hostname with brackets (`[::1]`). const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); +const MAX_ATTRIBUTION_VALUE_LENGTH = 256; +const ATTRIBUTION_DIMENSIONS = new Set([ + 'account', + 'service', + 'upstream_api', + 'model_name', + 'team', + 'group', + 'company', + 'provider', +]); export const orchestratorMock = process.env.SCOPEWEAVE_DEV === '1' && !OC_URL; @@ -137,6 +148,55 @@ function validatedMessages(messages) { }); } +/** + * Copy optional cost-attribution labels into the exact orchestrator allowlist. + * + * Unknown dimensions and empty values are omitted rather than forwarded to the + * strict contextual-orchestrator request validator. Values must be strings or + * finite numeric identifiers before normalization to bounded strings; complex + * objects and non-finite numbers fail closed instead of becoming misleading + * labels through implicit JavaScript string coercion. Execution model/provider + * identity remains controlled by the top-level request model and the + * orchestrator's own provider routing evidence; this object is business + * cost-allocation metadata only. + * + * @param {unknown} attribution optional business cost-attribution mapping + * @returns {Record|undefined} bounded allowed labels or undefined + */ +function sanitizedAttribution(attribution) { + if (attribution === undefined || attribution === null) return undefined; + if (typeof attribution !== 'object' || Array.isArray(attribution)) { + throw new OrchestratorConfigurationError( + 'orchestrator_attribution_invalid', + 'Orchestrator attribution must be an object when provided.', + ); + } + + const safe = Object.create(null); + for (const [key, value] of Object.entries(attribution)) { + if (!ATTRIBUTION_DIMENSIONS.has(key) || value === undefined || value === null) continue; + if ( + typeof value !== 'string' + && (typeof value !== 'number' || !Number.isFinite(value)) + ) { + throw new OrchestratorConfigurationError( + 'orchestrator_attribution_invalid', + 'Orchestrator attribution values must be strings or finite numbers.', + ); + } + const text = String(value).trim(); + if (!text) continue; + if (text.length > MAX_ATTRIBUTION_VALUE_LENGTH) { + throw new OrchestratorConfigurationError( + 'orchestrator_attribution_invalid', + 'Orchestrator attribution value is outside the accepted boundary.', + ); + } + safe[key] = text; + } + return Object.keys(safe).length ? safe : undefined; +} + /** * Build the stable response-size failure used by declared and streamed limits. * @returns {OrchestratorConfigurationError} Operator-safe size error. @@ -275,11 +335,13 @@ async function rejectProviderResponse(response) { /** * Generate one AI briefing through contextual-orchestrator. * @param {unknown} messages OpenAI-compatible messages + * @param {unknown} [attribution] optional bounded business cost-attribution labels * @returns {Promise} */ -export async function chat(messages) { +export async function chat(messages, attribution) { const configuration = orchestratorConfiguration(); const safeMessages = validatedMessages(messages); + const safeAttribution = sanitizedAttribution(attribution); if (configuration.mock) { const user = safeMessages .filter((message) => message.role === 'user') @@ -303,7 +365,12 @@ export async function chat(messages) { 'content-type': 'application/json', authorization: `Bearer ${configuration.token}`, }, - body: JSON.stringify({ model: OC_MODEL, messages: safeMessages }), + body: JSON.stringify({ + model: OC_MODEL, + orchestration_mode: 'auto', + messages: safeMessages, + ...(safeAttribution ? { attribution: safeAttribution } : {}), + }), signal: AbortSignal.timeout(ORCHESTRATOR_TIMEOUT_MS), }); } catch { diff --git a/tests/api/orchestrator-attribution.test.mjs b/tests/api/orchestrator-attribution.test.mjs new file mode 100644 index 00000000..d07460a3 --- /dev/null +++ b/tests/api/orchestrator-attribution.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.SCOPEWEAVE_DEV; +process.env.ORCHESTRATOR_URL = 'https://orchestrator.example'; +process.env.ORCHESTRATOR_TOKEN = 'secret-token'; +process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; + +const providerCalls = []; +globalThis.fetch = async (url, init) => { + providerCalls.push({ url: String(url), init }); + return new Response(JSON.stringify({ + choices: [{ message: { content: 'Grounded production response' } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +const { app } = await import(`../../server/app.mjs?attribution-api-test=${Date.now()}`); + +const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); +const jsonBody = (value) => JSON.stringify(value); + +async function createAccount(email) { + let response = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email, password: 'password123', name: email }), + }); + assert.equal(response.status, 200, `${email} signup`); + const token = (await response.json()).token; + const auth = { authorization: `Bearer ${token}` }; + response = await jsonRequest('/api/me', { headers: auth }); + assert.equal(response.status, 200, `${email} account lookup`); + const account = await response.json(); + return { auth, orgId: account.orgs[0].id }; +} + +const owner = await createAccount('orchestrator-owner@scopeweave.test'); +const outsider = await createAccount('orchestrator-outsider@scopeweave.test'); + +let response = await jsonRequest('/api/projects', { + method: 'POST', + headers: owner.auth, + body: jsonBody({ name: 'Attribution Project' }), +}); +assert.equal(response.status, 200, 'owner creates attribution project'); +const projectId = (await response.json()).id; + +response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, { + method: 'POST', + headers: owner.auth, + body: jsonBody({ account: String(outsider.orgId), service: 'spoofed-client-service' }), +}); +assert.equal(response.status, 200, 'authorized owner receives AI briefing'); +assert.equal(providerCalls.length, 1, 'authorized briefing performs one provider call'); +assert.equal(providerCalls[0].url, 'https://orchestrator.example/v1/chat/completions'); +const providerBody = JSON.parse(providerCalls[0].init.body); +assert.deepEqual( + providerBody.attribution, + { service: 'scopeweave', account: String(owner.orgId) }, + 'the authenticated server-side project organization owns cost attribution', +); +assert.notEqual( + providerBody.attribution.account, + String(outsider.orgId), + 'browser-supplied account data cannot spoof another tenant attribution', +); + +response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, { + method: 'POST', + headers: outsider.auth, + body: jsonBody({ account: String(owner.orgId) }), +}); +assert.equal(response.status, 404, 'cross-tenant AI briefing hides project existence'); +assert.equal( + providerCalls.length, + 1, + 'cross-tenant requests are rejected before any contextual-orchestrator call', +); + +console.log('✓ AI briefing attribution tenant-boundary tests passed'); diff --git a/tests/unit/orchestrator-attribution.test.mjs b/tests/unit/orchestrator-attribution.test.mjs new file mode 100644 index 00000000..45934f6e --- /dev/null +++ b/tests/unit/orchestrator-attribution.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DEV = ''; +process.env.ORCHESTRATOR_URL = 'https://orchestrator.example'; +process.env.ORCHESTRATOR_TOKEN = 'secret-token'; +process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; + +const calls = []; +globalThis.fetch = async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ + choices: [{ message: { content: 'Grounded production response' } }], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +const { chat } = await import( + `../../server/orchestrator.mjs?attribution-test=${Date.now()}-${Math.random()}` +); + +const messages = [{ role: 'user', content: 'status' }]; + +assert.equal( + await chat(messages, { + service: 'scopeweave', + account: 42, + upstream_api: 'requested-upstream-label', + provider: 'requested-provider-label', + model_name: 'requested-model-label', + team: null, + group: '', + company: ' ', + unsupported_dimension: 'must-not-cross-boundary', + }), + 'Grounded production response', +); + +assert.equal(calls.length, 1); +const attributedBody = JSON.parse(calls[0].init.body); +assert.equal(attributedBody.model, 'nvidia/nemotron-3-super-120b-a12b'); +assert.equal(attributedBody.orchestration_mode, 'auto'); +assert.equal(Object.hasOwn(attributedBody, 'provider'), false); +assert.deepEqual(attributedBody.attribution, { + service: 'scopeweave', + account: '42', + upstream_api: 'requested-upstream-label', + provider: 'requested-provider-label', + model_name: 'requested-model-label', +}); +assert.equal( + Object.hasOwn(attributedBody.attribution, 'unsupported_dimension'), + false, + 'unknown attribution keys never cross the ScopeWeave boundary', +); + +await chat(messages, { unsupported_dimension: 'x', account: ' ' }); +const emptyBody = JSON.parse(calls[1].init.body); +assert.equal(emptyBody.orchestration_mode, 'auto'); +assert.equal( + Object.hasOwn(emptyBody, 'attribution'), + false, + 'an attribution field is omitted when no non-empty allowed dimensions remain', +); + +await chat(messages); +const legacyBody = JSON.parse(calls[2].init.body); +assert.deepEqual( + legacyBody, + { + model: 'nvidia/nemotron-3-super-120b-a12b', + orchestration_mode: 'auto', + messages, + }, + 'omitting attribution preserves the hardened adaptive request shape exactly', +); + +for (const invalidAttribution of [ + [], + 'scopeweave', + { service: 'x'.repeat(257) }, + { service: ['scopeweave'] }, + { account: { organization_id: 42 } }, + { team: Symbol('scopeweave') }, + { group: Number.NaN }, + { company: Number.POSITIVE_INFINITY }, +]) { + await assert.rejects( + chat(messages, invalidAttribution), + (error) => error.code === 'orchestrator_attribution_invalid', + 'malformed, non-scalar, non-finite, or unbounded attribution fails before provider transport', + ); +} +assert.equal(calls.length, 3, 'invalid attribution never reaches the provider'); + +const originalJsonStringify = JSON.stringify; +let serializedAttributionPrototype; +JSON.stringify = (value, ...args) => { + if (value?.attribution) { + serializedAttributionPrototype = Object.getPrototypeOf(value.attribution); + } + return originalJsonStringify(value, ...args); +}; +try { + await chat(messages, { service: 'scopeweave' }); +} finally { + JSON.stringify = originalJsonStringify; +} +assert.equal( + serializedAttributionPrototype, + null, + 'validated attribution is held in a prototype-free map before provider serialization', +); +assert.equal(calls.length, 4, 'prototype-free attribution still reaches the provider once'); + +console.log('✓ orchestrator attribution boundary tests passed'); \ No newline at end of file diff --git a/tests/unit/orchestrator.test.mjs b/tests/unit/orchestrator.test.mjs index 14de7136..87cfb647 100644 --- a/tests/unit/orchestrator.test.mjs +++ b/tests/unit/orchestrator.test.mjs @@ -98,6 +98,7 @@ try { assert.ok(calls[0].init.signal instanceof AbortSignal); assert.deepEqual(JSON.parse(calls[0].init.body), { model: 'nvidia/nemotron-3-super-120b-a12b', + orchestration_mode: 'auto', messages: [{ role: 'user', content: 'status' }], }); From 3329a17c8bbbbb51859c1a2c134a93ae8db23a0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 19:45:17 +0900 Subject: [PATCH 24/27] docs: align billing deployment guidance --- README.md | 4 ++-- docs/deploy.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6340c1f4..9b8c2230 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,9 @@ Docker: set a **persistent** `SCOPEWEAVE_JWT_SECRET` first, then run `docker com | `SCOPEWEAVE_DB` | SQLite path (default `data.db`; `:memory:` for tests) | | `PORT` | API port (default 8787) | | `OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI` | Real SSO IdP (mock when unset) | -| `STRIPE_SECRET_KEY` | Real checkout (mock URL when unset) | +| `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET`, `SCOPEWEAVE_PUBLIC_ORIGIN` | Live Stripe checkout; production billing is disabled unless the complete tuple is configured | | `SCOPEWEAVE_RATE_LIMIT_MAX` (+`_WINDOW_MS`) | Opt-in per-IP rate limiting | -| `SCOPEWEAVE_DEV=1` | Dev-only endpoints (activate-pro) | +| `SCOPEWEAVE_DEV=1` | Dev-only endpoints (activate-pro); with a loopback `SCOPEWEAVE_PUBLIC_ORIGIN`, enables the mock checkout | ## Verification diff --git a/docs/deploy.md b/docs/deploy.md index 0cfdb799..4e936ade 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -35,7 +35,7 @@ persists the database in the `scopeweave-data` volume. | `PORT` | no (default 8787) | Listen port | | `SCOPEWEAVE_DB` | no (default `/data/scopeweave.db`) | SQLite file path (on the volume) | | `SCOPEWEAVE_DEV` | no | Must be `1` to enable the dev `activate-pro` endpoint. **Never set in production.** | -| `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | for live billing | Enables real Stripe Checkout (`npm i stripe` too). Without them, billing uses the mock path. | +| `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET`, `SCOPEWEAVE_PUBLIC_ORIGIN` | for live billing | Enables real Stripe Checkout. Production billing is disabled unless the complete tuple is configured; only explicit `SCOPEWEAVE_DEV=1` with a loopback public origin enables the mock path. | | `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | for real SSO | Points the OIDC login at your IdP. Unset → a built-in mock IdP (dev/test only). | | `ORCHESTRATOR_URL` | for AI 브리핑 | contextual-orchestrator 주소. Unset → deterministic mock. | | `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | From 23a699827fd51314a02fd1ad6ed0638cb23b4fca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 19:51:16 +0900 Subject: [PATCH 25/27] fix: normalize Stripe configuration values --- server/billing.mjs | 6 +++-- tests/unit/billing-checkout.test.mjs | 36 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/server/billing.mjs b/server/billing.mjs index b03b6467..16037b5c 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -155,12 +155,14 @@ export async function createCheckout({ } if (mode === 'live') { + const secretKey = String(process.env.STRIPE_SECRET_KEY || '').trim(); + const priceId = String(process.env.STRIPE_PRICE_ID || '').trim(); let session; try { - const stripe = await stripeClientFactory(process.env.STRIPE_SECRET_KEY); + const stripe = await stripeClientFactory(secretKey); session = await stripe.checkout.sessions.create({ mode: 'subscription', - line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], + line_items: [{ price: priceId, quantity: 1 }], success_url: `${publicOrigin}/?billing=success`, cancel_url: `${publicOrigin}/?billing=cancel`, client_reference_id: String(orgId), diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index a184be8d..a43c2d62 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -200,6 +200,42 @@ test('default live provider transport rejects non-2xx Stripe responses with a sa })); }); +test('live checkout trims configuration values before the provider boundary', async () => { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_SECRET_KEY = ' sk_test_trimmed '; + process.env.STRIPE_PRICE_ID = ' price_trimmed '; + + try { + const checkout = await createCheckout({ + orgId: 94, + configuration: liveConfiguration, + stripeClientFactory: async (secretKey) => { + assert.equal(secretKey, 'sk_test_trimmed'); + return { + checkout: { + sessions: { + async create(payload) { + assert.equal(payload.line_items[0].price, 'price_trimmed'); + return { url: 'https://checkout.stripe.com/c/pay/cs_test_trimmed' }; + }, + }, + }, + }; + }, + }); + assert.deepEqual(checkout, { + url: 'https://checkout.stripe.com/c/pay/cs_test_trimmed', + live: true, + }); + } 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('default live provider transport rejects network failures without leaking provider detail', async () => { await expectSafeProviderFailure(async () => { throw new Error('getaddrinfo ENOTFOUND api.stripe.com internal-network-detail'); From 51418c09e261b7d1b4dd694c42ceee63d22513f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 19:57:01 +0900 Subject: [PATCH 26/27] fix: make billing smoke boundary explicit --- server/app.mjs | 3 +-- tests/api/smoke.mjs | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index c432a84f..5f9551e0 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -596,8 +596,7 @@ app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { const uid = c.get('user').sub; const orgId = c.req.param('id'); if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can upgrade' }, 403); - const origin = new URL(c.req.url).origin; - const session = await createCheckout({ orgId, origin }); + const session = await createCheckout({ orgId }); return c.json(session); }); diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index e536b908..d59ef1d7 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -5,6 +5,7 @@ import assert from 'node:assert'; process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_DEV = '1'; // enables the dev-activate-pro endpoint for this test +process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'http://127.0.0.1:8787'; // required for the explicit dev checkout mock delete process.env.ORCHESTRATOR_URL; // keep the AI briefing on the explicit local dev adapter process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; const { app } = await import('../../server/app.mjs'); @@ -748,4 +749,4 @@ assert.equal((await r.json()).orgs.find((o) => o.id === orgAId)?.role, 'admin', r = await req(`/api/orgs/${orgAId}/leave`, { method: 'POST', headers: auth }); assert.equal(r.status, 200, 'former owner can now leave'); -console.log('✓ API smoke tests passed'); \ No newline at end of file +console.log('✓ API smoke tests passed'); From b8435c719baf4ee9eaa2907e2c93c6d959711d66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 00:12:50 +0900 Subject: [PATCH 27/27] fix(billing): restrict checkout redirect host --- server/billing.mjs | 8 +++++++- tests/unit/billing-checkout.test.mjs | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/billing.mjs b/server/billing.mjs index 16037b5c..4cd63af7 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -83,7 +83,13 @@ function validateCheckoutSessionUrl(session) { throw billingProviderUnavailable(); } - if (checkoutUrl.protocol !== 'https:' || checkoutUrl.username || checkoutUrl.password) { + if ( + checkoutUrl.protocol !== 'https:' + || checkoutUrl.hostname !== 'checkout.stripe.com' + || checkoutUrl.port !== '' + || checkoutUrl.username + || checkoutUrl.password + ) { throw billingProviderUnavailable(); } diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index a43c2d62..1cecd5b5 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -272,6 +272,9 @@ test('live checkout rejects unsafe or malformed provider redirect URLs', async ( 'http://checkout.stripe.com/c/pay/cs_test_plaintext', 'https://user@checkout.stripe.com/c/pay/cs_test_userinfo', 'https://:password@checkout.stripe.com/c/pay/cs_test_password', + 'https://checkout.stripe.com.evil.example/c/pay/cs_test_suffix', + 'https://checkout.stripe.com:444/c/pay/cs_test_port', + 'https://attacker.example/c/pay/cs_test_foreign_host', 'not a URL', ]) { await assertProviderFailure(() => createCheckout({