Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
fb1a7c0
test(billing): specify trusted checkout configuration
seonghobae Aug 15, 2026
f2ac4fa
feat(billing): validate trusted checkout configuration
seonghobae Aug 15, 2026
fb40d2a
feat(billing): fail closed and use operator redirect origin
seonghobae Aug 15, 2026
520f1b7
test(billing): cover trusted checkout authority
seonghobae Aug 15, 2026
1b3594c
test(api): prove checkout ignores request host authority
seonghobae Aug 15, 2026
311f084
test(billing): register checkout coverage evidence
seonghobae Aug 15, 2026
303e4c6
docs(billing): define trusted checkout configuration
seonghobae Aug 15, 2026
be5dbd7
docs(doctoring): trace trusted checkout origin evidence
seonghobae Aug 15, 2026
931d828
docs(changelog): record trusted billing origin
seonghobae Aug 15, 2026
956742d
test(billing): inspect rejected response after sync validation
seonghobae Aug 15, 2026
47049dd
test(api): configure trusted billing origin for smoke
seonghobae Aug 15, 2026
3ae911e
test(api): load billing origin in smoke harness
seonghobae Aug 15, 2026
ad81eb5
test(billing): prove default live transport needs no undeclared SDK
seonghobae Aug 15, 2026
0b5e1d9
fix(billing): use declared provider transport for checkout
seonghobae Aug 15, 2026
53b040a
merge(billing): reconcile trusted checkout with protected develop
seonghobae Aug 16, 2026
6cb4385
fix(test): remove unrelated attribution registrations
seonghobae Aug 16, 2026
b55347b
merge(billing): reconcile trusted checkout with current develop
seonghobae Aug 16, 2026
f9ba6b6
merge(billing): reconcile trusted checkout with current develop
seonghobae Aug 16, 2026
0b46b90
test(billing): fail closed on Stripe provider errors
seonghobae Aug 17, 2026
1ff56bc
fix(billing): fail closed on provider checkout errors
seonghobae Aug 17, 2026
8363ad8
test(billing): cover provider transport failure envelope
seonghobae Aug 17, 2026
35264a8
docs(billing): record fail-closed Stripe response boundary
seonghobae Aug 17, 2026
d848bbb
docs(billing): record provider failure handling
seonghobae Aug 17, 2026
58b3480
fix(billing): normalize all provider exceptions
seonghobae Aug 17, 2026
3be747d
test(billing): cover absent provider redirect shapes
seonghobae Aug 17, 2026
5477a34
merge(billing): reconcile trusted checkout root with current develop
seonghobae Aug 17, 2026
000731d
merge(billing): reconcile trusted checkout root with protected develop
seonghobae Aug 19, 2026
4c0592d
fix(stack): preserve protected develop in billing reconciliation
seonghobae Aug 19, 2026
da14bb4
fix(stack): reconcile billing root with current develop
seonghobae Aug 20, 2026
b80d502
fix(stack): reconcile billing root with Playwright develop update
seonghobae Aug 20, 2026
3329a17
docs: align billing deployment guidance
seonghobae Aug 28, 2026
23a6998
fix: normalize Stripe configuration values
seonghobae Aug 28, 2026
51418c0
fix: make billing smoke boundary explicit
seonghobae Aug 28, 2026
b8435c7
fix(billing): restrict checkout redirect host
seonghobae Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
82 changes: 82 additions & 0 deletions docs/billing-production.md
Original file line number Diff line number Diff line change
@@ -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=<secret-manager reference>
STRIPE_PRICE_ID=price_...
STRIPE_WEBHOOK_SECRET=<secret-manager reference>
```

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.
2 changes: 1 addition & 1 deletion docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`). |
Expand Down
137 changes: 137 additions & 0 deletions docs/doctoring/stripe-checkout-trusted-origin.md
Original file line number Diff line number Diff line change
@@ -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/
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions server/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
seonghobae marked this conversation as resolved.
});

Expand Down
Loading
Loading