Skip to content

feat(server-express): add paywall + paywallConfig options#79

Merged
levalleux-ludo merged 15 commits into
add-x402-paywallfrom
add-x402-server-express-paywall
May 21, 2026
Merged

feat(server-express): add paywall + paywallConfig options#79
levalleux-ludo merged 15 commits into
add-x402-paywallfrom
add-x402-server-express-paywall

Conversation

@levalleux-ludo
Copy link
Copy Markdown
Member

Summary

  • Adds optional paywall (a PaywallProvider) and paywallConfig to ExpressMiddlewareOptions and MountX402bOptions.
  • When supplied and the request's Accept header prefers text/html, the 402 challenge is rendered as an HTML document via paywall.generateHtml(...) (with currentUrl built from ${protocol}://${host}${originalUrl}) instead of the canonical JSON body.
  • All other cases (JSON Accept, missing Accept, paywall.supports() returns false, no paywall configured, settle-phase requests) keep the existing JSON challenge unchanged — no regression.

Implementation notes

  • A local structural PaywallProviderLike interface in src/internal/x402-challenge.ts keeps @bosonprotocol/x402-paywall an optional integration. Consumers wanting HTML 402s install the paywall package separately; this adapter never imports it at runtime.
  • Both expressMiddleware and mountX402b's commit routes funnel through the same shared respondWithChallenge helper, so the content-negotiation rules can't drift between the two entry points.
  • A devDep on @bosonprotocol/x402-paywall powers one CI-level smoke test that exercises the real evmEscrowPaywall through the middleware — catches structural drift between PaywallProviderLike here and the upstream PaywallProvider shape.

Notes

Test plan

  • `pnpm --filter @bosonprotocol/x402-server-express build` — clean
  • `pnpm --filter @bosonprotocol/x402-server-express test` — 32/32 (22 existing + 10 new)
  • Repo-wide `pnpm build` — 16/16 successful
  • Repo-wide `pnpm test` — 32/32 successful
  • Repo-wide `pnpm lint` — 16/16 successful
  • Repo-wide `pnpm format:check` — clean

🤖 Generated with Claude Code

`ExpressMiddlewareOptions` and `MountX402bOptions` now accept
optional `paywall` (a `PaywallProvider` instance) and
`paywallConfig` (opaque config forwarded to `generateHtml`).

When supplied, missing-`X-PAYMENT` requests whose `Accept` header
prefers `text/html` and whose `paywall.supports(requirements)`
returns true get an HTML 402 body produced by the paywall, with
`currentUrl` built from `req.protocol://${host}${req.originalUrl}`.
Everything else — JSON-preferring clients, missing Accept,
unsupported schemes, unconfigured paywall, settle-phase requests —
keeps the canonical JSON challenge body.

A local structural `PaywallProviderLike` keeps
`@bosonprotocol/x402-paywall` an optional integration: consumers
who want HTML 402s install the paywall package separately; this
adapter never imports it at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a61906ad-15ed-4bab-87e6-131cbe99207c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add-x402-server-express-paywall

Comment @coderabbitai help to get the list of available commands and usage tips.

@levalleux-ludo levalleux-ludo marked this pull request as ready for review May 20, 2026 16:41
Copilot AI review requested due to automatic review settings May 20, 2026 16:41
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds optional HTML paywall rendering for 402 challenges in @bosonprotocol/x402-server-express, using content negotiation to switch between the canonical JSON x402 challenge body and a paywall-generated HTML document.

Changes:

  • Add paywall / paywallConfig options to expressMiddleware and mountX402b and funnel both through a shared respondWithChallenge helper.
  • Implement content negotiation in respondWithChallenge to serve text/html via paywall.generateHtml(...) when the request prefers HTML.
  • Add a new test suite (including a smoke test using the real @bosonprotocol/x402-paywall) and wire the paywall package as a dev dependency.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
typescript/packages/server-express/test/paywall.test.ts Adds unit + integration tests for HTML-vs-JSON 402 challenge negotiation and paywall wiring.
typescript/packages/server-express/src/mount.ts Extends MountX402bOptions with paywall options and routes commit-route 402s through the shared challenge responder.
typescript/packages/server-express/src/middleware.ts Extends ExpressMiddlewareOptions with paywall options and routes missing-X-PAYMENT 402s through the shared challenge responder.
typescript/packages/server-express/src/internal/x402-challenge.ts Adds paywall/provider “like” types and implements HTML vs JSON challenge negotiation + currentUrl building.
typescript/packages/server-express/package.json Adds @bosonprotocol/x402-paywall as a devDependency for the integration smoke test.
pnpm-lock.yaml Lockfile updates to include the paywall workspace package and its dependency graph changes.
.changeset/server-express-paywall-option.md Changeset documenting the new options and behavior for the server-express package.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread typescript/packages/server-express/src/internal/x402-challenge.ts Outdated
Comment thread typescript/packages/server-express/test/paywall.test.ts Outdated
`createResourceServerApp` now accepts an optional `paywall`
(`PaywallProvider`) and `paywallConfig` and forwards both to the
underlying `expressMiddleware` + `mountX402b` calls. Setting any
`PAYWALL_*` env var populates a `paywall` block on the parsed env so
a fork of the binary can `import { evmEscrowPaywall }` and hand both
to `createResourceServerApp` to enable HTML 402s for browser
User-Agents. Programmatic clients still see JSON.

Also widens `PaywallConfigLike` in `@bosonprotocol/x402-server-express`
from `Record<string, unknown>` to `object` so concrete `PaywallConfig`
shapes (with named optional fields, no index signature) satisfy it
structurally — required for the example to typecheck without casts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@TateLyman
Copy link
Copy Markdown

Ran a focused code-read on the new browser paywall 402 path. No wallet signing, payment headers, live facilitator calls, or paid calls.

Two launch-risk notes before this is used behind a real domain/CDN:

  1. The response now content-negotiates the same URL between JSON and HTML, but the helper does not set Vary: Accept or Cache-Control: no-store on either branch. Because the HTML body contains an injected window.x402b state payload and the JSON branch is the canonical machine challenge, I would make the 402 challenge explicitly non-cacheable and vary on Accept so an intermediary cannot reuse an HTML paywall for an agent/client expecting JSON, or reuse stale requirements for a later request.

  2. buildCurrentUrl() uses req.protocol + Host. Behind a TLS-terminating proxy, Express often sees req.protocol === "http" unless trust proxy is configured; the paywall can then inject http://... as the retry URL even though the browser loaded the page over HTTPS. That can break browser retry/mixed-content behavior and can also misalign the URL the buyer retries with the resource URL the seller intended. I would either let callers pass an explicit canonical currentUrl/origin, or document/test the proxy requirement with X-Forwarded-Proto/trusted host handling.

Suggested test additions:

  • Accept: text/html response asserts Vary includes Accept and Cache-Control: no-store.
  • Proxied request fixture verifies the injected currentUrl stays https://seller.example/... when the app runs behind TLS termination, or confirms the documented override is used.

The feature shape looks useful; these are mostly deployment-boundary issues around browser paywalls and agent-readable 402s.

levalleux-ludo and others added 2 commits May 21, 2026 14:30
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@levalleux-ludo
Copy link
Copy Markdown
Member Author

Ran a focused code-read on the new browser paywall 402 path. No wallet signing, payment headers, live facilitator calls, or paid calls.

Two launch-risk notes before this is used behind a real domain/CDN:

  1. The response now content-negotiates the same URL between JSON and HTML, but the helper does not set Vary: Accept or Cache-Control: no-store on either branch. Because the HTML body contains an injected window.x402b state payload and the JSON branch is the canonical machine challenge, I would make the 402 challenge explicitly non-cacheable and vary on Accept so an intermediary cannot reuse an HTML paywall for an agent/client expecting JSON, or reuse stale requirements for a later request.
  2. buildCurrentUrl() uses req.protocol + Host. Behind a TLS-terminating proxy, Express often sees req.protocol === "http" unless trust proxy is configured; the paywall can then inject http://... as the retry URL even though the browser loaded the page over HTTPS. That can break browser retry/mixed-content behavior and can also misalign the URL the buyer retries with the resource URL the seller intended. I would either let callers pass an explicit canonical currentUrl/origin, or document/test the proxy requirement with X-Forwarded-Proto/trusted host handling.

Suggested test additions:

  • Accept: text/html response asserts Vary includes Accept and Cache-Control: no-store.
  • Proxied request fixture verifies the injected currentUrl stays https://seller.example/... when the app runs behind TLS termination, or confirms the documented override is used.

The feature shape looks useful; these are mostly deployment-boundary issues around browser paywalls and agent-readable 402s.

Thanks @TateLyman
I really appreciate your contribution

levalleux-ludo and others added 3 commits May 21, 2026 14:59
The `wantsHtml` JSDoc body contained the literal `*/*` inside backticks
to discuss wildcard Accept headers. Backticks aren't a comment-escape
mechanism, so the embedded `*/` terminated the block comment, leaving
the surrounding prose as invalid TypeScript. `tsup`/esbuild and `tsc`
both rejected the file, taking `@bosonprotocol/x402-server-express`
out of the build entirely.

Rewrote that block as a sequence of `//` line comments so the documentation
prose is preserved without any block-comment delimiter to collide with.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default `fetch()` calls (and most generic API clients like curl) send
`Accept: */*` — not an empty/absent Accept header. The `wantsHtml`
content-negotiation gate explicitly requires `text/html` or
`application/xhtml+xml` in the raw header, so `*/*` must fall through
to the canonical JSON 402 body. Add an explicit test for that case and
re-title the adjacent empty-Accept test so it stops claiming to cover
real-world default-fetch behaviour.

Addresses PR #79 review comment r3275647569.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 402 challenge response carries per-request signature state
(offer.sellerSig, single-use token-auth nonces) and, on the HTML
branch, injects the full requirements + retry URL into window.x402b.
Without explicit cache headers an intermediary could store the body
or cross-serve HTML to a JSON client; without proxy-aware URL
building the embedded retry URL could come back as http:// from an
HTTPS-loaded page (mixed-content blocking).

- Set Cache-Control: no-store and Vary: Accept on both branches of
  respondWithChallenge, once at the top so the rule can't drift.
- Add currentUrl?: string | ((req) => string) to both
  ExpressMiddlewareOptions and MountX402bOptions, threaded through
  ChallengeOptions, so deployments behind a TLS-terminating proxy
  can pin the canonical retry URL when app.set('trust proxy', ...)
  alone isn't sufficient.
- JSDoc on the paywall option recommends trust proxy for the common
  case.
- Tests: cache-header assertions on each branch, trust-proxy +
  X-Forwarded-Proto fixture, and string + resolver currentUrl
  override fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@levalleux-ludo
Copy link
Copy Markdown
Member Author

Thanks @TateLyman — both remarks were fully valid and we've folded them into this PR in ff34cbd.

1. Vary: Accept + Cache-Control: no-store. Confirmed: the 402 body carries per-request signature state (offer.sellerSig, single-use token-auth nonces) and the HTML branch injects the full requirements into window.x402b — definitely not cache-safe. The spec itself flags this in docs/boson-impl-01-escrow-scheme.md (caching is explicitly deferred until EscrowPaymentRequirements gains an absolute expiresAt). respondWithChallenge now stamps both headers on both branches before writing the body, set once at the top so the rule can't drift between HTML and JSON. New assertions on each branch lock the contract in.

2. req.protocol behind a TLS-terminating proxy. Confirmed — Express only honors X-Forwarded-Proto when app.set('trust proxy', ...) is configured, and we documented neither path. Two-pronged fix:

  • JSDoc on the paywall option in both ExpressMiddlewareOptions and MountX402bOptions now recommends app.set('trust proxy', ...) and explains why (the window.x402b.currentUrl retry URL would otherwise come back as http:// from an HTTPS-loaded page and hit mixed-content blocking).
  • New currentUrl?: string | ((req) => string) option on both adapter entry points lets deployments pin the canonical retry URL explicitly when trust proxy alone isn't sufficient (e.g. when the public origin differs from what reaches Express, or when you want the retry URL pinned to a fixed checkout endpoint).

Both of your suggested tests are in: cache-header assertions on each branch, a trust proxy + X-Forwarded-Proto: https fixture, and string + resolver fixtures for the currentUrl override.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

Comment thread typescript/packages/server-express/src/internal/x402-challenge.ts Outdated
Comment thread typescript/packages/server-express/src/internal/x402-challenge.ts Outdated
levalleux-ludo and others added 5 commits May 21, 2026 17:20
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
feat(resource-server): wire optional paywall into the example host
@levalleux-ludo levalleux-ludo requested a review from Copilot May 21, 2026 16:31
@TateLyman
Copy link
Copy Markdown

Thanks Ludo. The two patch directions look right from the PR readback: Vary: Accept plus Cache-Control: no-store is now centralized on the 402 response path, and the currentUrl override / trust-proxy docs cover the TLS-termination case. That closes the two deployment-boundary notes I raised. Scope here was code-read only; I did not sign requests, send payment headers, hit live facilitators, or attempt paid calls.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 4 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

Comment thread typescript/packages/server-express/src/internal/x402-challenge.ts
Comment thread examples/resource-server/src/app.ts
Comment thread typescript/packages/server-express/test/paywall.test.ts
Comment thread typescript/packages/server-express/test/paywall.test.ts
levalleux-ludo and others added 2 commits May 21, 2026 19:21
…lback

The default `currentUrl` builder in `respondWithChallenge` used the raw
`Host` header verbatim, which (a) produced an invalid `http:///path`
when `Host` was missing and (b) ignored `X-Forwarded-Host` even under
`app.set('trust proxy', ...)` (Express does not consult the forwarded
header for `req.get('host')`). Reverse-proxy deployments therefore
embedded an internal-hop URL into the paywall retry form.

Prefer the first entry of `X-Forwarded-Host`, fall back to `Host`, and
fall back to a relative URL (`req.originalUrl`) when neither is
present so the form still posts back to the buyer's current origin
instead of an invalid absolute URL. JSDoc on `ChallengeOptions.currentUrl`
updated to match.

Addresses PR #79 review comment r3282819986.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the example forwarded `paywallConfig` to the `expressMiddleware`
and `mountX402b` adapters independently of `paywall`, so a caller could
set `paywallConfig` without a provider and the value would be silently
dropped downstream. Introduce a single `paywallOptions` bundle that only
populates `paywall`/`paywallConfig` when a provider is present, so the
example no longer models an ambiguous configuration and the two adapter
call sites stay aligned.

Addresses PR #79 review comment r3282820034.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a content-negotiation test that drives the middleware with
`Accept: application/xhtml+xml` and asserts the paywall HTML branch
runs — the explicit Accept-header parser in `wantsHtml` already lists
XHTML alongside `text/html`, but only `text/html` was previously covered
by a test.

The added test exposed (and this commit fixes) a follow-on bug in the
same function: after the explicit list-membership check, `wantsHtml`
also asserts preference via `req.accepts(["html", "json"]) === "html"`,
but Express's short `html` alias only resolves to `text/html`. An
XHTML-only client therefore fell through to JSON despite passing the
explicit check. Match against the full media types
(`text/html`, `application/xhtml+xml`, `application/json`) so the
preference check honors the explicit branch's intent.

Addresses PR #79 review comment r3282820070.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@levalleux-ludo levalleux-ludo merged commit e932859 into add-x402-paywall May 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants