feat(server-express): add paywall + paywallConfig options#79
Conversation
`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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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/paywallConfigoptions toexpressMiddlewareandmountX402band funnel both through a sharedrespondWithChallengehelper. - Implement content negotiation in
respondWithChallengeto servetext/htmlviapaywall.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.
`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>
|
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:
Suggested test additions:
The feature shape looks useful; these are mostly deployment-boundary issues around browser paywalls and agent-readable 402s. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Thanks @TateLyman |
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>
|
Thanks @TateLyman — both remarks were fully valid and we've folded them into this PR in ff34cbd. 1. 2.
Both of your suggested tests are in: cache-header assertions on each branch, a |
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
|
Thanks Ludo. The two patch directions look right from the PR readback: |
…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>
Summary
paywall(aPaywallProvider) andpaywallConfigtoExpressMiddlewareOptionsandMountX402bOptions.Acceptheader preferstext/html, the 402 challenge is rendered as an HTML document viapaywall.generateHtml(...)(withcurrentUrlbuilt from${protocol}://${host}${originalUrl}) instead of the canonical JSON body.paywall.supports()returns false, no paywall configured, settle-phase requests) keep the existing JSON challenge unchanged — no regression.Implementation notes
PaywallProviderLikeinterface insrc/internal/x402-challenge.tskeeps@bosonprotocol/x402-paywallan optional integration. Consumers wanting HTML 402s install the paywall package separately; this adapter never imports it at runtime.expressMiddlewareandmountX402b's commit routes funnel through the same sharedrespondWithChallengehelper, so the content-negotiation rules can't drift between the two entry points.@bosonprotocol/x402-paywallpowers one CI-level smoke test that exercises the realevmEscrowPaywallthrough the middleware — catches structural drift betweenPaywallProviderLikehere and the upstreamPaywallProvidershape.Notes
@bosonprotocol/x402-paywall); base is set accordingly. GitHub will auto-rebase tomainonce feat: add @bosonprotocol/x402-paywall package #78 (and its own base feat: add initial implementation of x402-client-browser with tests and docs #77) merges.Test plan
🤖 Generated with Claude Code