diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..be8862d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. +- 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 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 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/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. 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`). | diff --git a/docs/doctoring/stripe-checkout-trusted-origin.md b/docs/doctoring/stripe-checkout-trusted-origin.md new file mode 100644 index 00000000..4226d743 --- /dev/null +++ b/docs/doctoring/stripe-checkout-trusted-origin.md @@ -0,0 +1,137 @@ +# 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]`. + +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. +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. + +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. + +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 root 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; +- an injected deterministic Stripe client receives success/cancel URLs built + 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 +`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; +- 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; +- 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 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 + +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/ diff --git a/package.json b/package.json index 8cefdc74..2a685bb2 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 && node tests/api/orchestrator-attribution.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/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/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 && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs", + "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/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 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/server/billing.mjs b/server/billing.mjs index 9781bcee..4cd63af7 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -1,47 +1,184 @@ -// 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(); +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 }, 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); - 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`, - client_reference_id: String(orgId), - metadata: { orgId: String(orgId) }, - }); - return { url: session.url, live: true }; +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' }, + }); +} + +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], + ['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], + ]); +} + +function validateCheckoutSessionUrl(session) { + if (!session || typeof session.url !== 'string' || !session.url.trim()) { + throw billingProviderUnavailable(); + } + + let checkoutUrl; + try { + checkoutUrl = new URL(session.url); + } catch { + throw billingProviderUnavailable(); + } + + if ( + checkoutUrl.protocol !== 'https:' + || checkoutUrl.hostname !== 'checkout.stripe.com' + || checkoutUrl.port !== '' + || checkoutUrl.username + || checkoutUrl.password + ) { + throw billingProviderUnavailable(); + } + + return session.url; +} + +async function defaultStripeClientFactory(secretKey) { + return { + checkout: { + sessions: { + async create(payload) { + 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(); + } + }, + }, + }, + }; +} + +/** + * 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. + * 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. + * @param {{mode: 'disabled'|'mock'|'live', publicOrigin: string|null}} [options.configuration] + * Validated billing capability; defaults to startup configuration. + * @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 billing is unconfigured or HTTP 502 when + * the live provider cannot produce a valid hosted Checkout Session URL. + */ +export async function createCheckout({ + orgId, + configuration = billingConfiguration, + stripeClientFactory = defaultStripeClientFactory, +}) { + const { mode, publicOrigin } = configuration; + if (mode === 'disabled' || !publicOrigin) { + throw new HTTPException(503, { res: billingUnavailableResponse() }); } - return { url: `${origin}/?billing=mock&org=${orgId}`, live: false, mock: true }; + + 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(secretKey); + session = await stripe.checkout.sessions.create({ + mode: 'subscription', + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${publicOrigin}/?billing=success`, + cancel_url: `${publicOrigin}/?billing=cancel`, + client_reference_id: String(orgId), + metadata: { orgId: String(orgId) }, + }); + } catch { + throw billingProviderUnavailable(); + } + return { url: validateCheckoutSessionUrl(session), live: true }; + } + + return { url: `${publicOrigin}/?billing=mock&org=${encodeURIComponent(String(orgId))}`, live: false, mock: true }; } 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 }; +} 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/); +}); 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 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'); diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs new file mode 100644 index 00000000..1cecd5b5 --- /dev/null +++ b/tests/unit/billing-checkout.test.mjs @@ -0,0 +1,296 @@ +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' }; + +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 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 () => { + await assertProviderFailure(() => createCheckout({ orgId: 91, configuration: liveConfiguration })); + }); +} + +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( + createCheckout({ orgId: 42, configuration: disabledConfiguration }), + (error) => { + rejectedError = error; + assert.equal(error.status, 503); + assert.equal(typeof error.getResponse, 'function'); + 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 () => { + 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; + } +}); + +test('default live provider transport uses Stripe HTTPS without an undeclared runtime SDK', async () => { + const calls = []; + 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', + }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + }, async () => { + 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'); + }); +}); + +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('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'); + }); +}); + +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' }, + })); +}); + +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', + '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({ + orgId: 92, + configuration: liveConfiguration, + stripeClientFactory: fixedSessionFactory({ 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'); + }, + })); +}); 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', + }); +});