@volter/twin-stripe 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,20 +5,24 @@ application sends a person through, and Stripe.js, over one state.
5
5
 
6
6
  ## Use with an existing app
7
7
 
8
+ Use Node 22.6 or newer.
9
+
8
10
  In an app that already uses this vendor, install this exact release and the product CLI:
9
11
 
10
12
  ```console
11
- npm install --save-dev --save-exact @volter/world@3.0.68 @volter/twin-stripe@3.0.2
12
- npx volter world init --name my-app --twins stripe --source stripe=@volter/twin-stripe
13
+ npm install --save-dev --save-exact @volter/world@3.0.147 @volter/twin-stripe@3.0.4
14
+ ./node_modules/.bin/volter world init --name my-app --twins stripe --source stripe=@volter/twin-stripe
13
15
  ```
14
16
 
17
+ Run the local executable from this app’s folder; if it is missing, complete the installation here before continuing.
18
+
15
19
  Review the detected vendor and generated bindings before booting. Read the credential names and limitations below; the World supplies throwaway credentials. Then run your app's own command through the World:
16
20
 
17
21
  ```console
18
- npx volter world up
19
- npx volter world run -- npm test
20
- npx volter world log
21
- npx volter world down
22
+ ./node_modules/.bin/volter world up
23
+ ./node_modules/.bin/volter world run -- npm test
24
+ ./node_modules/.bin/volter world log
25
+ ./node_modules/.bin/volter world down
22
26
  ```
23
27
 
24
28
  Here `npm test` is your app's existing command; replace it with your app or test command. `down` retains state.
@@ -47,9 +51,10 @@ vendor-backed World's refresh; each served operation is decided in `journeys/dec
47
51
  - **Catalog**: products and prices created and listed, prices retrieved, coupons and promotion codes managed. Product search and administrative product/price updates answer the gap.
48
52
  - **Checkout Sessions** and the customer portal (sessions and configurations): pay, add a card, pay an open invoice,
49
53
  cancel at the period's end or at once.
50
- - **Subscriptions**: created (charged at once, trialing, `default_incomplete` or `error_if_incomplete`), updated (items,
51
- discounts, a trial ended early or added), searched, canceled; their items, schedules (made, updated, released),
52
- renewals on the World clock, and a renewal declined and paid later.
54
+ - **Subscriptions**: created by hosted Checkout (including trials), read, updated (items, discounts, a trial
55
+ ended early or added) and canceled, with renewals on the World clock. Direct `POST /v1/subscriptions` is outside
56
+ the declared application demand and returns the gap; use the release operation table to check other subscription
57
+ and schedule endpoints before choosing this twin.
53
58
  - **Invoices**: invoice items, drafts, finalize, pay (a draft is finalized on the way), void, delete, lines,
54
59
  previews and their payments. Invoice lines are read in the invoice; the standalone lines endpoint answers the gap.
55
60
  - **Intents**: payment and setup intents, confirmed on the server or by Stripe.js with the publishable key and the
@@ -98,30 +103,22 @@ SDK's `constructEvent` verifies) and delivers to the account's enabled webhook e
98
103
  (`STRIPE_CONNECT_WEBHOOK_SECRET`), the same on every boot; an endpoint made on the Webhooks page (a door) is signed
99
104
  with the one of its kind. The twin reads no env.
100
105
 
106
+ The documented card-brand samples keep their brand and credit/debit/prepaid funding type when saved
107
+ through Checkout or the portal. Other raw numbers retain synthetic Visa metadata; the twin does not
108
+ look up a real card issuer.
109
+
101
110
  ## Screens
102
111
 
103
- Stripe's own pages, at their vendor urls (`src/screens/`):
104
-
105
- - **Checkout** (`checkout.stripe.com/c/pay/{session}`): the customer pays; the session completes and
106
- `checkout.session.completed` fires.
107
- - **Customer portal** (`billing.stripe.com/p/session/{session}`): cancel or renew a plan, update the card, pay an
108
- open invoice.
109
- - **Connect onboarding** (`connect.stripe.com/setup/…`): an Account Link's hosted onboarding.
110
- - **Connect OAuth** (`connect.stripe.com/oauth/authorize`, `/oauth/token`, `/oauth/deauthorize`; Standard accounts,
111
- docs.stripe.com/connect/oauth-reference). The platform's client_id is `ca_twin_self` in every World (a test
112
- client_id; the platform account is `acct_twin_self`), shown on the Dashboard's Connect OAuth settings page in
113
- `<code data-testid="connect-client-id">`. OAuth starts off: a runner POSTs that page as a browser does,
114
- `oauth_enabled=on&redirect_uris=<one per line>`. The authorize page offers the test-mode **Skip this form** (a new
115
- Standard account, connected) and **Deny access**; the token endpoint exchanges a code once, within 5 minutes (a
116
- reused code revokes the connection), and refreshes to an equal or lesser scope; after a deauthorization the
117
- account's `Stripe-Account` header is refused 403 `account_invalid`. The deprecated `access_token` and
118
- `stripe_publishable_key` act as the connected account. Not modelled: connecting an existing Stripe account, the full
119
- account application, holding a `read_only` connection to reads.
120
- - **Stripe.js** (`js.stripe.com/v3/`, `/v3/stripe.js`, `/<release train>/stripe.js`): `redirectToCheckout({ sessionId })`
121
- goes to the hosted Checkout page; any other member
122
- throws, naming itself.
123
- - **Dashboard** (`dashboard.stripe.com/settings/public`, `/settings/connect/onboarding-options/oauth`): Public details
124
- (the business name Checkout and the portal show) and the Connect OAuth settings.
112
+ Authored pages and content at the declared vendor URLs (`src/screens/`):
113
+
114
+ - **Checkout** (`checkout.stripe.com/c/pay/{session}`): payment, setup and subscription sessions, including trials.
115
+ - **Customer portal** (`billing.stripe.com/p/session/{session}`): cancel or renew a plan, update the card and pay an open invoice.
116
+ - **Connect onboarding** (`connect.stripe.com/setup/…`): the Account Link's business, identity, payout and agreement form.
117
+ - **Stripe.js** (`js.stripe.com/v3/`, `/v3/stripe.js`, `/<release train>/stripe.js`): `redirectToCheckout({ sessionId })` opens the hosted session. Unsupported members refuse.
118
+ - **Checkout client wire** (`api.stripe.com/v1/payment_pages/{session}`): the authored session operations used by Stripe.js CheckoutProvider.
119
+ - **Dashboard** (`dashboard.stripe.com/customers`, `/customers/{customer}`, `/settings/public`, `/radar/lists`, `/test/issuing/balance`): inspect SDK-created customers and their stored subscriptions, payments, payment methods, invoices and metadata; set the public business name, create Radar lists and fund test Issuing. The workspace opens Public details; choose Customers in its sidebar to inspect a record. Customer pages are read-only. Search matches name, email, id or description; dates are UTC. Dashboard customer creation/editing, exports, guest-customer grouping and global search are unavailable.
120
+
121
+ Page actions use the same stored resources as the API. Public screen references and authored choices are in [spec/SOURCE.md](./spec/SOURCE.md).
125
122
 
126
123
  ## Doors
127
124
 
@@ -646,6 +646,51 @@
646
646
  ],
647
647
  "evidence": "Dub: apps/web/app/api/workspaces/[idOrSlug]/billing/payment-methods/route.ts:66"
648
648
  },
649
+ {
650
+ "name": "Femke finds the SDK-created customer in the Dashboard",
651
+ "method": "GET",
652
+ "path": "https://dashboard.stripe.com/customers?q=billing%40ravel.test",
653
+ "status": 200,
654
+ "expect": [
655
+ {
656
+ "path": "",
657
+ "contains": "billing@ravel.test"
658
+ },
659
+ {
660
+ "path": "",
661
+ "contains": "/customers/{ravel}"
662
+ }
663
+ ],
664
+ "evidence": "World first-use customer inspection; https://support.stripe.com/questions/export-customer-data-without-the-payment-details"
665
+ },
666
+ {
667
+ "name": "Femke opens Ravel's stored customer profile",
668
+ "method": "GET",
669
+ "path": "https://dashboard.stripe.com/customers/{ravel}",
670
+ "status": 200,
671
+ "expect": [
672
+ {
673
+ "path": "",
674
+ "contains": "billing@ravel.test"
675
+ },
676
+ {
677
+ "path": "",
678
+ "contains": "Subscriptions"
679
+ },
680
+ {
681
+ "path": "",
682
+ "contains": "Metadata"
683
+ }
684
+ ],
685
+ "evidence": "World first-use customer inspection; https://support.stripe.com/questions/updates-to-the-customer-detail-page"
686
+ },
687
+ {
688
+ "name": "A missing customer profile stays missing",
689
+ "method": "GET",
690
+ "path": "https://dashboard.stripe.com/customers/cus_missing",
691
+ "status": 404,
692
+ "evidence": "The Dashboard reads an existing customer only"
693
+ },
649
694
  {
650
695
  "name": "Twenty\u2019s app health check reads its Stripe account with the user-supplied key",
651
696
  "method": "GET",
@@ -2183,7 +2228,11 @@
2183
2228
  "expect": [
2184
2229
  {
2185
2230
  "path": "",
2186
- "contains": "Radar Lists"
2231
+ "contains": "Payments / Radar"
2232
+ },
2233
+ {
2234
+ "path": "",
2235
+ "contains": "New list"
2187
2236
  }
2188
2237
  ],
2189
2238
  "evidence": "Stripe: https://docs.stripe.com/radar/lists"
@@ -2690,7 +2739,11 @@
2690
2739
  "expect": [
2691
2740
  {
2692
2741
  "path": "",
2693
- "contains": "Test Issuing balance"
2742
+ "contains": "Issuing balance"
2743
+ },
2744
+ {
2745
+ "path": "",
2746
+ "contains": "Add test funds before creating test Issuing transactions."
2694
2747
  }
2695
2748
  ],
2696
2749
  "evidence": "Stripe: https://docs.stripe.com/issuing/testing"
@@ -55,6 +55,9 @@ Elapsed: 120m.
55
55
  - and every customer — GET /v1/customers?limit=100. Volter Harness: container/retake/stack/app/src/app.py:116
56
56
  - The sample shop's seeder looks its first customer up by email before seeding — GET /v1/customers?email=billing%40ravel.test. Twin: apps/platform/src/sample.ts:40
57
57
  - Dub reads Ravel’s billing customer — GET /v1/customers/{ravel}. Dub: apps/web/app/api/workspaces/[idOrSlug]/billing/payment-methods/route.ts:66
58
+ - Femke opens Customers to inspect the SDK-created billing customer — GET https://dashboard.stripe.com/customers?q=billing%40ravel.test. World first-use customer inspection; Stripe: https://support.stripe.com/questions/export-customer-data-without-the-payment-details
59
+ - Femke opens Ravel's profile and sees the stored subscriptions and billing details — GET https://dashboard.stripe.com/customers/{ravel}. World first-use customer inspection; Stripe: https://support.stripe.com/questions/updates-to-the-customer-detail-page
60
+ - A missing customer profile is not an invented customer — GET https://dashboard.stripe.com/customers/cus_missing. The profile reads the existing customer view only.
58
61
  - Twenty’s app health check reads its Stripe account with the user-supplied key — GET /v1/account. Twenty: packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx:850 (GET, Authorization only)
59
62
  - Twenty’s invoice workflow creates the quote invoice for Ravel — POST /v1/invoices. Twenty: packages/twenty-docs/user-guide/workflows/how-tos/connect-to-other-tools/generate-quote-or-invoice-from-twenty.mdx:101; packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action.ts:28
60
63
  - The invoice workflow reads the saved draft — GET /v1/invoices/{workflow_invoice}. Postiz: libraries/nestjs-libraries/src/services/stripe.service.ts; Stripe: pinned API and Stripe.js Checkout contract
@@ -1,5 +1,5 @@
1
1
  {
2
- "about": "Stripe operations served for the application calls at the cited lines in demand.json and for the resource refresh reads declared in src/manifest.ts. All other operations answer the gap. Each decision names its caller or refresh and its implementation.",
2
+ "about": "Stripe operations served for the application calls at the cited lines in demand.json and for the resource refresh reads declared in src/manifest.ts. The Dashboard customer pages read those same resource views, as declared in src/manifest.ts and customer-life.outline.md. All other operations answer the gap. Each decision names its caller or refresh and its implementation.",
3
3
  "decisions": [
4
4
  {
5
5
  "operation": "DeleteAccountsAccount",
@@ -1,5 +1,5 @@
1
1
  {
2
- "measured": "Applications are read at the scanned commits below. Demand includes SDK methods, delegated Stripe.js calls, raw URLs and API-path strings, and shipped guides that use a shipped feature. Credentials authorize a feature; they do not opt into it. Postiz ships Stripe as its web billing provider. Twenty workflow HTTP actions and enterprise website billing are independent of the server billing flag (which defaults false). Cal payment/OAuth needs an app-store install; its metadata installed flag makes the catalog entry available, while the user install requires a credential created by the install callback. All products in generated/DEMAND.md’s Volter column were searched, including their own World scenarios and seeds; none calls a removed operation. The launch does not name Stripe. Separately invoked Rallly billing CLI and Dub maintenance scripts are outside the running products. Dub’s raw GET /v1/mandates, POST /v1/fx_quotes and v2 calls remain outside the pinned v1 document and answer the vendor gap; their demand is retained, not classified optional.",
2
+ "measured": "Applications are read at the scanned commits below. Demand includes SDK methods, delegated Stripe.js calls, raw URLs and API-path strings, and shipped guides that use a shipped feature. Credentials authorize a feature; they do not opt into it. Postiz ships Stripe as its web billing provider. Twenty workflow HTTP actions and enterprise website billing are independent of the server billing flag (which defaults false). Cal payment/OAuth needs an app-store install; its metadata installed flag makes the catalog entry available, while the user install requires a credential created by the install callback. All products in generated/DEMAND.md\u2019s Volter column were searched, including their own World scenarios and seeds; none calls a removed operation. The launch does not name Stripe. Separately invoked Rallly billing CLI and Dub maintenance scripts are outside the running products. Dub\u2019s raw GET /v1/mandates, POST /v1/fx_quotes and v2 calls remain outside the pinned v1 document and answer the vendor gap; their demand is retained, not classified optional.",
3
3
  "applications": [
4
4
  {
5
5
  "name": "Open Autonomy",
@@ -65,7 +65,7 @@
65
65
  "client": "fetch (the platform's sample World seeder)",
66
66
  "backend": [
67
67
  {
68
- "call": "call('stripe', '/v1/customers?email=…')",
68
+ "call": "call('stripe', '/v1/customers?email=\u2026')",
69
69
  "operation": "GetCustomers",
70
70
  "evidence": "apps/platform/src/sample.ts:40"
71
71
  },
@@ -687,6 +687,18 @@
687
687
  }
688
688
  ],
689
689
  "gate": "The shipped website enterprise checkout directly uses Stripe independently of the server IS_BILLING_ENABLED=false switch (packages/twenty-website/src/app/api/enterprise/checkout/route.ts:7-19,51)."
690
+ },
691
+ {
692
+ "name": "World first-use",
693
+ "commit": "7638d09b23a3bb36067a607e0fd19898b0a5b624",
694
+ "client": "Real Stripe SDK customer workflow and normal browser inspection",
695
+ "frontend": [
696
+ {
697
+ "call": "Customers sidebar \u2192 SDK-created customer profile",
698
+ "operation": "GET https://dashboard.stripe.com/customers; GET https://dashboard.stripe.com/customers/{customer}",
699
+ "evidence": "twin-world/docs/contributing/first-use.md:39; twin-world/docs/getting-started.md:158"
700
+ }
701
+ ]
690
702
  }
691
703
  ],
692
704
  "optional": [
@@ -8,6 +8,15 @@
8
8
 
9
9
  The pack serves this document's version (`SERVED_VERSION` in `src/engine/version.ts`).
10
10
 
11
+ Customer Dashboard references, read 2026-10-09: [Export customer data](https://support.stripe.com/questions/export-customer-data-without-the-payment-details)
12
+ establishes the Customers section at `/customers`. [Updates to the Customer details page](https://support.stripe.com/questions/updates-to-the-customer-detail-page)
13
+ establishes dynamic payments, invoices and subscriptions in the primary column, with details and metadata on the right.
14
+ [Manage customer information](https://support.stripe.com/questions/manage-a-customer-s-information-and-payments)
15
+ names the customer profile and payment-method modules; its older left-details layout is superseded by the update article.
16
+ The pages publish no version date. The authored list and read-only detail use stored API views, UTC dates and an explicitly
17
+ limited name/email/id/description search. Dashboard create/edit actions, exports and guest-customer grouping are outside this scope.
18
+ Only public documentation was read; no vendor sign-in, upstream DOM, script or image is shipped.
19
+
11
20
  - **Published examples:** `fixtures3.json.gz`, gzip of Stripe's `openapi/fixtures3.json` (one sample object per resource,
12
21
  the objects Stripe's API reference shows), from commit `0d98342fc985dc7710a44be25e8528dd8e099bc2` (2026-09-23).
13
22
  SHA-256 of the uncompressed file: `e6822f7ee6fd71bdde6228537368aafcfb7191109340fe119d9aca27c6e9396d`.
@@ -16,3 +25,7 @@ The pack serves this document's version (`SERVED_VERSION` in `src/engine/version
16
25
  publish (its testing page's cards, the payment lifecycle's states, each reference page's stated rule), each with its
17
26
  page's URL and the page's words for what it does, read with twin-standard's `read-page`. **Read by:**
18
27
  `bun scripts/vendor-examples.ts stripe`.
28
+
29
+ Screen references, read 2026-10-08: [Stripe's hosted-onboarding guide](https://docs.stripe.com/connect/hosted-onboarding), its hosted_onboarding_form.e59ba8300f563e43489953f06127f52c.png image, supplies the two-column layout, progress and sequential form. The image's example platform branding is configurable; this screen retains the Stripe skin. Back/Continue keep local form answers; only the existing final submit changes the connected account. The existing US requirements and refusal behavior remain the contract. No upstream page, image or script is shipped.
30
+
31
+ Dashboard references, read 2026-10-08: [the Web Dashboard guide](https://docs.stripe.com/dashboard/basics) supplies the primary sidebar categories, Shortcuts and Settings. The public [Stripe Apps testing guide](https://docs.stripe.com/stripe-apps/test-app), image `test-app-uninstall.710b89177767cff3a7f53de749558d88.png`, illustrates the earlier horizontal navigation, settings breadcrumbs, form density and type; its publication date is unspecified, and it is not a current Public details screenshot. The authored shell follows the current documented sidebar, while the controls remain the existing public business name, Radar list creation and test Issuing funding. The declared root opens Public details; other Dashboard products are unavailable, and there are no invented analytics. [Radar Lists](https://docs.stripe.com/radar/lists) supplies New, name, alias, type and Add. No upstream image, DOM, script or account sign-in is shipped.
@@ -302,5 +302,13 @@
302
302
  "quotes": {
303
303
  "Unrecognized request URL (GET: /v1/test).": true
304
304
  }
305
+ },
306
+ "https://support.stripe.com/questions/export-customer-data-without-the-payment-details": {
307
+ "status": 200,
308
+ "checked": "2026-10-09"
309
+ },
310
+ "https://support.stripe.com/questions/updates-to-the-customer-detail-page": {
311
+ "status": 200,
312
+ "checked": "2026-10-09"
305
313
  }
306
314
  }
@@ -672,6 +672,27 @@ export function disputeEvidence() {
672
672
  };
673
673
  }
674
674
  const subParams = (params, k) => (params[k] && typeof params[k] === 'object' ? params[k] : {});
675
+ // source: https://docs.stripe.com/testing
676
+ // The documented brand cards keep their brand and funding type when Checkout or the portal saves them.
677
+ // Where the documentation stops and the twin decides: other numbers retain the existing synthetic Visa default.
678
+ const TEST_CARD_PROFILES = {
679
+ '4000056655665556': { brand: 'visa', funding: 'debit' },
680
+ '5555555555554444': { brand: 'mastercard', funding: 'credit' },
681
+ '2223003122003222': { brand: 'mastercard', funding: 'credit' },
682
+ '5200828282828210': { brand: 'mastercard', funding: 'debit' },
683
+ '5105105105105100': { brand: 'mastercard', funding: 'prepaid' },
684
+ '378282246310005': { brand: 'amex', funding: 'credit' },
685
+ '371449635398431': { brand: 'amex', funding: 'credit' },
686
+ '6011111111111117': { brand: 'discover', funding: 'credit' },
687
+ '6011000990139424': { brand: 'discover', funding: 'credit' },
688
+ '6011981111111113': { brand: 'discover', funding: 'debit' },
689
+ '3056930009020004': { brand: 'diners', funding: 'credit' },
690
+ '36227206271667': { brand: 'diners', funding: 'credit' },
691
+ '3566002020360505': { brand: 'jcb', funding: 'credit' },
692
+ '6200000000000005': { brand: 'unionpay', funding: 'credit' },
693
+ '6200000000000047': { brand: 'unionpay', funding: 'debit' },
694
+ '6205500000000000004': { brand: 'unionpay', funding: 'credit' },
695
+ };
675
696
  /** A card's own sub-object, from the number and expiry given. */
676
697
  function cardSubObject(params) {
677
698
  const c = subParams(params, 'card');
@@ -680,15 +701,16 @@ function cardSubObject(params) {
680
701
  // produces its real last4 instead of silently falling back to the '4242' default.
681
702
  const number = typeof c.number === 'string' ? c.number.replace(/\D/g, '')
682
703
  : typeof c.number === 'number' ? String(c.number).replace(/\D/g, '') : '';
704
+ const profile = TEST_CARD_PROFILES[number] ?? { brand: 'visa', funding: 'credit' };
683
705
  return { card: {
684
- brand: 'visa', last4: number ? number.slice(-4) : '4242',
706
+ brand: profile.brand, last4: number ? number.slice(-4) : '4242',
685
707
  exp_month: Number(c.exp_month) || 12, exp_year: Number(c.exp_year) || 2034,
686
- funding: 'credit', country: 'US',
708
+ funding: profile.funding, country: 'US',
687
709
  // source: spec:/components/schemas/payment_method_card/properties/fingerprint/description "Uniquely identifies this particular card number."
688
710
  // The synthetic fingerprint is stable for a number; Stripe's opaque fingerprint algorithm is not published.
689
711
  fingerprint: digest('sha256', number || '4242424242424242').slice(0, 16),
690
712
  checks: { address_line1_check: null, address_postal_code_check: null, cvc_check: 'pass' },
691
- networks: { available: ['visa'], preferred: null },
713
+ networks: { available: [profile.brand], preferred: null },
692
714
  three_d_secure_usage: { supported: true }, wallet: null,
693
715
  } };
694
716
  }
@@ -359,7 +359,7 @@ export const manifest = {
359
359
  hosts: [
360
360
  { host: 'api.stripe.com' }, { host: 'checkout.stripe.com', pathPattern: '^/c/pay/' }, { host: 'billing.stripe.com', pathPattern: '^/p/session/' },
361
361
  { host: 'connect.stripe.com', pathPattern: '^/setup/' },
362
- { host: 'dashboard.stripe.com', pathPattern: '^/radar/lists(?:/[^/]+)?/?$|^/test/issuing/balance/?$|^/settings/public/?$' },
362
+ { host: 'dashboard.stripe.com', pathPattern: '^/customers(?:/[^/]+)?/?$|^/radar/lists(?:/[^/]+)?/?$|^/test/issuing/balance/?$|^/settings/public/?$' },
363
363
  { host: 'verify.stripe.com', pathPattern: '^/start/' },
364
364
  { host: 'js.stripe.com', pathPattern: '^/v3/?$|^/(v3|[a-z]+)/stripe\\.js$' },
365
365
  ],
@@ -405,8 +405,8 @@ export const manifest = {
405
405
  },
406
406
  {
407
407
  id: 'dashboard', kind: 'workspace', host: 'dashboard.stripe.com', path: '/', status: 'done',
408
- demand: 'Operator setup for the cited application calls: Public details; Radar lists for Dub add-to-stripe-fraud-value-lists.ts:3-7; sandbox funding for Open Autonomy world/model/scenario.ts:60,62',
409
- controls: ['Business name', 'Save', 'New list', 'Name', 'Alias', 'List type', 'Add', 'Add test funds', 'Amount'], source: 'https://docs.stripe.com/dashboard/basics',
408
+ demand: 'World first-use customer inspection (twin-world/docs/contributing/first-use.md); operator setup: Public details; Radar lists for Dub add-to-stripe-fraud-value-lists.ts:3-7; sandbox funding for Open Autonomy world/model/scenario.ts:60,62',
409
+ controls: ['Customers', 'Search customers', 'Search', 'Business name', 'Save', 'New list', 'Name', 'Alias', 'List type', 'Add', 'Add test funds', 'Amount'], source: 'https://docs.stripe.com/dashboard/basics',
410
410
  },
411
411
  ],
412
412
  resources: {
@@ -56,8 +56,8 @@ function page(ctx, id, session, problem) {
56
56
  badge: s.status === 'trialing' ? 'Trial' : s.status === 'past_due' ? 'Past due' : ending ? 'Cancels' : undefined,
57
57
  note: ending ? `Your plan will be canceled on ${day(s.current_period_end)}.` : s.status === 'trialing' ? `Trial ends ${day(s.trial_end)}.` : `Renews on ${day(s.current_period_end)}.`,
58
58
  actions: ending
59
- ? [{ label: 'Renew plan', action: `/p/session/${id}/renew`, fields: { subscription: String(s.id) }, tone: 'primary' }]
60
- : features.cancel.enabled ? [{ label: 'Cancel plan', action: `/p/session/${id}/cancel`, fields: { subscription: String(s.id) }, tone: 'danger' }] : [],
59
+ ? [{ label: 'Renew plan', action: `${ctx.publicBase}/p/session/${id}/renew`, fields: { subscription: String(s.id) }, tone: 'primary' }]
60
+ : features.cancel.enabled ? [{ label: 'Cancel plan', action: `${ctx.publicBase}/p/session/${id}/cancel`, fields: { subscription: String(s.id) }, tone: 'danger' }] : [],
61
61
  };
62
62
  });
63
63
  const defaultPm = customer.invoice_settings?.default_payment_method ?? undefined;
@@ -72,7 +72,7 @@ function page(ctx, id, session, problem) {
72
72
  ? ctx.rows('invoice').filter((i) => i.customer === session.customer && i.status !== 'draft').sort((a, b) => Number(b.created) - Number(a.created))
73
73
  .map((i) => ({
74
74
  title: day(i.created), detail: money(i.amount_due ?? i.total, i.currency), badge: String(i.status ?? '').replace(/^./, (c) => c.toUpperCase()),
75
- ...(i.status === 'open' && i.collection_method !== 'send_invoice' ? { actions: [{ label: 'Pay', action: `/p/session/${id}/pay`, fields: { invoice: String(i.id) }, tone: 'primary' }] } : {}),
75
+ ...(i.status === 'open' && i.collection_method !== 'send_invoice' ? { actions: [{ label: 'Pay', action: `${ctx.publicBase}/p/session/${id}/pay`, fields: { invoice: String(i.id) }, tone: 'primary' }] } : {}),
76
76
  }))
77
77
  : [];
78
78
  const back = typeof session.return_url === 'string' ? session.return_url : undefined;
@@ -95,7 +95,7 @@ function page(ctx, id, session, problem) {
95
95
  title: 'Billing',
96
96
  css: [PORTAL_CSS, SKIN],
97
97
  ...(problem ? { status: 402 } : {}),
98
- body: (_jsx(Portal, { merchant: merchant, ...(problem ? { notice: problem.error } : {}), ...(features.paymentMethod ? { forms: [{ heading: pm ? 'Update payment method' : 'Add payment method', action: `/p/session/${id}/payment_method`, fields: cardFields, submit: { label: pm ? 'Update' : 'Add' } }] } : {}), ...(back ? { back: { href: back, label: `← Return to ${merchant}` } } : {}), sections: [
98
+ body: (_jsx(Portal, { merchant: merchant, ...(problem ? { notice: problem.error } : {}), ...(features.paymentMethod ? { forms: [{ heading: pm ? 'Update payment method' : 'Add payment method', action: `${ctx.publicBase}/p/session/${id}/payment_method`, fields: cardFields, submit: { label: pm ? 'Update' : 'Add' } }] } : {}), ...(back ? { back: { href: back, label: `← Return to ${merchant}` } } : {}), sections: [
99
99
  { heading: 'Current plan', items: plans, empty: 'You have no active plans.' },
100
100
  { heading: 'Payment method', items: methods, empty: 'No payment method on file.' },
101
101
  { heading: 'Billing information', items: details },
@@ -108,7 +108,7 @@ async function move(ctx, id, session, subscription, kind) {
108
108
  const sub = resourceView(ctx, SUB, subscription);
109
109
  if (!sub || sub.customer !== session.customer || sub.status === 'canceled')
110
110
  return gone();
111
- const back = new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
111
+ const back = new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
112
112
  if (kind === 'renew') {
113
113
  await ctx.write(SUB, subscription, { cancel_at_period_end: false, cancel_at: null, canceled_at: null, cancellation_details: { comment: null, feedback: null, reason: null } }, 'subscription.update');
114
114
  return back;
@@ -149,7 +149,7 @@ async function updatePaymentMethod(ctx, id, session, v) {
149
149
  for (const sub of ctx.rows(SUB).map((s) => ctx.expand(SUB, s)).filter((s) => s.customer === customer && s.status !== 'canceled' && s.status !== 'incomplete_expired')) {
150
150
  await ctx.write(SUB, String(sub.id), { default_payment_method: pm.id }, 'subscription.update');
151
151
  }
152
- return new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
152
+ return new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
153
153
  }
154
154
  /** The customer pays one of their open invoices with the card on file. */
155
155
  async function payInvoice(ctx, id, session, invoiceId) {
@@ -164,7 +164,7 @@ async function payInvoice(ctx, id, session, invoiceId) {
164
164
  const declined = await payOpenInvoice(ctx, invoiceId, pm);
165
165
  if (declined)
166
166
  return page(ctx, id, session, { error: declined.message, values: {} });
167
- return new Response(null, { status: 303, headers: { location: `/p/session/${id}` } });
167
+ return new Response(null, { status: 303, headers: { location: `${ctx.publicBase}/p/session/${id}` } });
168
168
  }
169
169
  /** billing.stripe.com's portal pages, or undefined for any other request. */
170
170
  export async function screen(ctx) {
@@ -66,7 +66,7 @@ function customerEmailOf(session) {
66
66
  const email = session._customer?.email;
67
67
  return typeof email === 'string' && EMAIL.test(email) ? email : undefined;
68
68
  }
69
- function page(session, id, now, values = {}, error) {
69
+ function page(publicBase, session, id, now, values = {}, error) {
70
70
  const lines = ((session.line_items?.data) ?? []).map((item) => ({
71
71
  name: String(item.description ?? 'Item'),
72
72
  amount: money(item.amount_total, item.currency ?? session.currency),
@@ -95,7 +95,7 @@ function page(session, id, now, values = {}, error) {
95
95
  title: 'Checkout',
96
96
  css: [PAYMENT_CSS, SKIN],
97
97
  status: error ? 402 : 200,
98
- body: (_jsx(Payment, { merchant: merchant, lines: lines, ...(trialText ? { total: trialText.lead, note: trialText.note } : session.amount_total !== null && session.amount_total !== undefined ? { total: { label: mode === 'subscription' ? 'Subscribe' : `Pay ${merchant}`, amount: money(session.amount_total, session.currency) } } : {}), action: `/c/pay/${id}`, sections: [{ heading: 'Contact information', fields: contact }, { heading: 'Payment method', fields: card }], submit: { label: submit, testId: 'hosted-payment-submit-button' }, ...(typeof session.cancel_url === 'string' ? { back: { href: session.cancel_url, label: '← Back' } } : {}), ...(error ? { error } : {}) })),
98
+ body: (_jsx(Payment, { merchant: merchant, lines: lines, ...(trialText ? { total: trialText.lead, note: trialText.note } : session.amount_total !== null && session.amount_total !== undefined ? { total: { label: mode === 'subscription' ? 'Subscribe' : `Pay ${merchant}`, amount: money(session.amount_total, session.currency) } } : {}), action: `${publicBase}/c/pay/${id}`, sections: [{ heading: 'Contact information', fields: contact }, { heading: 'Payment method', fields: card }], submit: { label: submit, testId: 'hosted-payment-submit-button' }, ...(typeof session.cancel_url === 'string' ? { back: { href: session.cancel_url, label: '← Back' } } : {}), ...(error ? { error } : {}) })),
99
99
  });
100
100
  }
101
101
  const gone = () => flowPage({ title: 'Checkout', status: 404, css: [PAYMENT_CSS, SKIN], body: _jsx("main", { className: "pay-form", children: _jsx("h1", { children: "This Checkout Session is no longer active." }) }) });
@@ -104,14 +104,14 @@ async function pay(ctx, id, session, v) {
104
104
  const email = onFile ?? (typeof session.customer_email === 'string' ? session.customer_email : (v.email ?? '').trim());
105
105
  if (session.mode !== 'setup' || !session.customer) {
106
106
  if (!EMAIL.test(email))
107
- return page(session, id, nowUnix(ctx.occurredAt), v, 'Your email address is incomplete.');
107
+ return page(ctx.publicBase, session, id, nowUnix(ctx.occurredAt), v, 'Your email address is incomplete.');
108
108
  }
109
109
  // a trial charges nothing now unless a one-time price is on the first invoice
110
110
  const oneTimeDue = ((session.line_items?.data) ?? []).some((it, i) => isOneTime(it, inlineOf(session, i)));
111
111
  const chargesNow = session.mode === 'payment' || (session.mode === 'subscription' && (!trialOf(session, nowUnix(ctx.occurredAt)) || oneTimeDue));
112
112
  const refused = cardAnswer(v, nowUnix(ctx.occurredAt), chargesNow);
113
113
  if (refused)
114
- return page(session, id, nowUnix(ctx.occurredAt), v, refused);
114
+ return page(ctx.publicBase, session, id, nowUnix(ctx.occurredAt), v, refused);
115
115
  let existing = session;
116
116
  // Checkout always makes a customer for a subscription; a payment makes one only when the session asks
117
117
  if (!session.customer && (session.mode === 'subscription' || session.customer_creation === 'always')) {
@@ -162,5 +162,5 @@ export async function screen(ctx) {
162
162
  const stored = ctx.row(CS, id) ?? {};
163
163
  const coupon = stored._discount?.coupon;
164
164
  const full = { ...session, _subscription_data: stored._subscription_data, _price_data: stored._price_data, _merchant: merchantOf(ctx, stored), _coupon: typeof coupon === 'string' ? ctx.get('coupon', coupon) : undefined, _customer: typeof session.customer === 'string' ? ctx.get('customer', session.customer) : undefined };
165
- return request.method === 'GET' ? page(full, id, nowUnix(ctx.occurredAt)) : pay(ctx, id, full, values);
165
+ return request.method === 'GET' ? page(ctx.publicBase, full, id, nowUnix(ctx.occurredAt)) : pay(ctx, id, full, values);
166
166
  }
@@ -1,3 +1,3 @@
1
- import type { HandlerContext } from '@volter/world-core';
1
+ import { type HandlerContext } from '@volter/world-core';
2
2
  /** connect.stripe.com's onboarding pages, or undefined for any other request. */
3
3
  export declare function screen(ctx: HandlerContext): Promise<Response>;
@@ -1,4 +1,31 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // STRIPE CONNECT HOSTED ONBOARDING — a hosted flow (docs/contributing/architecture.md, "Screens"): a platform
3
+ // creates an Account Link for a connected account and sends the account's owner to its `url`. The link is a
4
+ // single-use entry point: visiting it takes the owner to their onboarding session, and visiting it again, or after it
5
+ // expires, sends them to refresh_url, where the platform makes a new one (docs.stripe.com/connect/hosted-onboarding).
6
+ // At the session's own address they give Stripe what it requires, and Stripe sends them to return_url. Submitting is
7
+ // the owner's move, not an API call. Authored from @volter/world-ui's payment piece and Stripe's published visual
8
+ // example; the form moves through one section at a time, retaining answers until the final submit.
9
+ //
10
+ // The page asks for exactly what Stripe requires before a US Express account with card_payments and transfers can
11
+ // take card payments (docs.stripe.com/connect/required-verification-information; the set is the one Stripe's
12
+ // requirements endpoint answers, docs.stripe.com/_endpoint/get-requirements-for-setups with apiVersion=v1,
13
+ // platformCountry=US, accountCountry=US, dashboardType=express, tosType=full, capabilities card_payments and transfers):
14
+ // thirty requirements for a company (the business profile, the statement descriptor, the company with its tax id,
15
+ // phone and address, its owners, its representative with their title and address, the agreement and the payout bank
16
+ // account) and eighteen for an individual. A submit that leaves any out is refused with what is missing. The account's
17
+ // requirements.currently_due lists what is still missing, and charges and payouts are enabled, and its requested
18
+ // capabilities become active (Stripe's move on the capability machine), only once none is. The agreement is the
19
+ // page's own checkbox, recorded as tos_acceptance with the request's address and the time.
20
+ //
21
+ // Where the documentation stops and the twin decides: only these two US sets are modelled; an account elsewhere, or
22
+ // of another business type, is told its requirements are not modelled. Test mode verifies at once whatever is
23
+ // submitted (docs.stripe.com/connect/testing lists the values that pass: date of birth 1901-01-01, SSN last four
24
+ // 0000, tax id 000000000, routing 110000000, account 000123456789); the industries offered are Stripe's supported
25
+ // merchant category codes as its list gives them (./industries.ts), and an industry the page does not offer leaves
26
+ // business_profile.mcc due; the states are the fifty and the District of Columbia; a request with no forwarded address
27
+ // records 127.0.0.1. An update link (account_update) shows the same page.
28
+ import { twinVendorUrl } from '@volter/world-core';
2
29
  import { CONSENT_CSS, flowPage, Payment, PAYMENT_CSS } from '@volter/world-ui';
3
30
  import { nowUnix } from "../engine/stripe.js";
4
31
  import { formOf, notFound, seeOther, STRIPE_CONSENT_SKIN } from "./shared.js";
@@ -58,9 +85,9 @@ function page(ctx, sessionUrl, link, kind, values = {}, error) {
58
85
  : [{ heading: 'Business details', fields: business }, { heading: 'About you', fields: person }, { heading: 'Payout account', fields: payout }, { heading: 'Agreement', fields: agreement }];
59
86
  return flowPage({
60
87
  title: 'Stripe · Account onboarding',
61
- css: [PAYMENT_CSS],
88
+ css: [ONBOARDING_CSS],
62
89
  ...(error ? { status: 400 } : {}),
63
- body: (_jsx(Payment, { merchant: `Verify your ${kind === 'company' ? 'company' : 'business'} with Stripe · ${String(link.account)} · test mode`, lines: [], action: sessionUrl, sections: sections, submit: { label: 'Submit' }, back: { href: String(link.return_url), label: '← Return to platform' }, ...(error ? { error } : {}) })),
90
+ body: (_jsx(ConnectOnboarding, { merchant: "Stripe", account: String(link.account), checked: ['owners_provided', 'tos'].filter(name => v[name] === (name === 'tos' ? 'accepted' : 'provided')), lines: sections.map(section => ({ name: section.heading, amount: '' })), action: sessionUrl, sections: sections, submit: { label: 'Submit' }, back: { href: String(link.return_url), label: '← Return to platform' }, ...(error ? { error } : {}) })),
64
91
  });
65
92
  }
66
93
  /** An account whose requirements the twin does not model: outside the US, or of another business type. */
@@ -124,7 +151,7 @@ async function visit(ctx, id) {
124
151
  return seeOther(String(link.refresh_url));
125
152
  const session = `onbs_${id.replace(/^acctlink_/, '')}`;
126
153
  await ctx.write(LINK, id, { used: true, _session: session }, 'account_link.used');
127
- return seeOther(`${SESSIONS}${session}`);
154
+ return seeOther(twinVendorUrl(ctx.call.request, `${SESSIONS}${session}`));
128
155
  }
129
156
  /** connect.stripe.com's onboarding pages, or undefined for any other request. */
130
157
  export async function screen(ctx) {
@@ -143,7 +170,7 @@ export async function screen(ctx) {
143
170
  const link = stored ? ctx.get(LINK, String(stored.id)) : undefined;
144
171
  if (!stored || !link)
145
172
  return gone();
146
- const sessionUrl = `${SESSIONS}${sid}`;
173
+ const sessionUrl = twinVendorUrl(request, `${SESSIONS}${sid}`);
147
174
  const account = ctx.get('account', String(link.account)) ?? {};
148
175
  const kind = kindOf(account);
149
176
  if (!kind)
@@ -157,3 +184,50 @@ export async function screen(ctx) {
157
184
  await submit(ctx, link, kind, form, request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1');
158
185
  return seeOther(String(link.return_url));
159
186
  }
187
+ // Visual reference: Stripe's own hosted onboarding example, read 2026-10-08:
188
+ // https://docs.stripe.com/connect/hosted-onboarding
189
+ // hosted_onboarding_form.e59ba8300f563e43489953f06127f52c.png. Platform branding
190
+ // in that example is configurable; this twin uses its existing Stripe skin.
191
+ const ONBOARDING_CSS = PAYMENT_CSS + `
192
+ *{box-sizing:border-box}body{color:#30313d;background:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
193
+ .pay{grid-template-columns:minmax(280px,40%) minmax(0,60%)}.pay-summary{background:#f7f7f9;padding:48px max(32px,calc((100vw - 1000px)/2));padding-right:40px;position:relative}
194
+ .pay-back{color:#596171;font-size:13px;margin-bottom:40px}.pay-merchant{color:#635bff;font-size:30px;font-weight:700;letter-spacing:-1px;margin-bottom:32px}
195
+ .pay-lines{counter-reset:step;max-width:280px}.pay-line{counter-increment:step;display:block;position:relative;padding:12px 0 12px 38px;color:#687385;font-size:14px}
196
+ .pay-line::before{content:counter(step);position:absolute;left:0;top:11px;width:24px;height:24px;line-height:22px;text-align:center;border:1px solid #d8dee4;border-radius:50%;background:#fff;font-size:12px}
197
+ .pay-line[aria-current=step]{color:#30313d;font-weight:600}.pay-line[aria-current=step]::before{border-color:#635bff;color:#635bff}.pay-line.is-complete::before{background:#635bff;border-color:#635bff;color:#fff}
198
+ .pay-form{padding:56px 48px;max-width:620px;width:100%}.pay-section legend{font-size:26px;letter-spacing:-.6px;font-weight:600;margin-bottom:24px}.pay-field{margin-bottom:22px}
199
+ .pay-field span{font-size:14px;margin-bottom:8px;font-weight:500}.pay-field input,.pay-field select{background:#fff;border-color:#d8dee4;border-radius:6px;padding:10px 12px;box-shadow:0 1px 2px #00000005;color:#30313d}
200
+ .pay-field input:focus,.pay-field select:focus{outline:2px solid #a5a0ff;outline-offset:1px}
201
+ .pay-field:has(input[type=checkbox]){display:flex;align-items:flex-start;gap:10px}.pay-field:has(input[type=checkbox]) span{order:2;font-weight:400}.pay-field input[type=checkbox]{width:16px;height:16px;flex:none;margin-top:3px;accent-color:#635bff}
202
+ .pay-submit,.connect-next{background:#635bff;color:#fff;border:0;border-radius:6px;font-size:14px;font-weight:600;cursor:pointer;padding:12px 16px}
203
+ .connect-actions{display:flex;justify-content:space-between;gap:16px;margin-top:32px}.connect-back{color:#596171;background:transparent;border:0;cursor:pointer;padding:12px 0;font:inherit}.connect-next{min-width:140px;flex:1}
204
+ .pay-error{border:1px solid #f1c2c2;padding:12px;border-radius:6px;color:#b52e2e;background:#fff7f7;font-size:13px;white-space:normal;overflow-wrap:anywhere}
205
+ .connect-progress{height:3px;background:#ececf1;margin:0 0 36px;border-radius:2px;overflow:hidden}.connect-progress div{height:100%;background:#635bff;transition:width .15s}
206
+ .connect-context{position:absolute;bottom:40px;left:32px;color:#687385;font-size:12px}.connect-context strong{display:block;font-weight:500;color:#30313d}
207
+ .connect-ready .pay-section[hidden],.connect-ready .pay-submit[hidden],.connect-actions [hidden]{display:none}
208
+ @media(max-width:800px){.pay{grid-template-columns:1fr}.pay-summary{padding:24px}.pay-back{margin-bottom:20px}.pay-merchant{font-size:24px;margin-bottom:0}.pay-lines{display:none}.connect-context{position:static;padding:0 24px;background:#f7f7f9}.pay-form{padding:32px 24px;max-width:100%}.pay-section legend{font-size:24px}}
209
+ `;
210
+ // Progressive enhancement only: Back/Continue move through the existing form.
211
+ // No account state is written until its existing Submit operation succeeds.
212
+ const STEPS = `(function(){
213
+ var root=document.querySelector('.connect-onboarding'),form=root.querySelector('form'),groups=Array.from(form.querySelectorAll('.pay-section')),items=Array.from(root.querySelectorAll('.pay-line')),submit=form.querySelector('.pay-submit');
214
+ var progress=document.createElement('div');progress.className='connect-progress';progress.setAttribute('role','progressbar');progress.setAttribute('aria-label','Onboarding progress');progress.setAttribute('aria-valuemin','0');progress.setAttribute('aria-valuemax',String(groups.length));
215
+ var bar=document.createElement('div');progress.append(bar);form.before(progress);
216
+ var controls=document.createElement('div');controls.className='connect-actions';
217
+ var back=document.createElement('button');back.type='button';back.className='connect-back';back.textContent='Back';
218
+ var next=document.createElement('button');next.type='button';next.className='connect-next';next.textContent='Continue';
219
+ controls.append(back,next);form.append(controls);
220
+ var checked=JSON.parse(root.getAttribute('data-checked-fields')||'[]');
221
+ groups.forEach(function(group){group.querySelectorAll('input,select').forEach(function(input){input.required=true;if(input.type==='checkbox')input.checked=checked.includes(input.name);if(input.name==='ssn_last_4'){input.pattern='[0-9]{4}';input.inputMode='numeric';input.maxLength=4;}});});
222
+ var index=0;
223
+ function show(i){index=i;groups.forEach(function(group,n){group.hidden=n!==i;});items.forEach(function(item,n){if(n===i)item.setAttribute('aria-current','step');else item.removeAttribute('aria-current');item.classList.toggle('is-complete',n<i);});back.hidden=i===0;next.hidden=i===groups.length-1;submit.hidden=i!==groups.length-1;bar.style.width=((i+1)/groups.length*100)+'%';progress.setAttribute('aria-valuenow',String(i+1));progress.setAttribute('aria-valuetext',groups[i].querySelector('legend').textContent);root.classList.add('connect-ready');}
224
+ function invalid(group){return Array.from(group.querySelectorAll('input,select')).find(function(input){return !input.checkValidity();});}
225
+ back.addEventListener('click',function(){show(index-1);});
226
+ next.addEventListener('click',function(){var field=invalid(groups[index]);if(field){field.reportValidity();return;}show(index+1);groups[index].querySelector('input,select')?.focus();});
227
+ form.noValidate=true;
228
+ form.addEventListener('submit',function(event){for(var i=0;i<groups.length;i++){var field=invalid(groups[i]);if(field){event.preventDefault();show(i);field.reportValidity();return;}}});
229
+ if(form.querySelector('.pay-error')){var first=groups.findIndex(function(group){return invalid(group);});show(first<0?groups.length-1:first);}else show(0);
230
+ })();`;
231
+ function ConnectOnboarding(props) {
232
+ return _jsxs("div", { className: "connect-onboarding", "data-checked-fields": JSON.stringify(props.checked), children: [_jsx(Payment, { ...props }), _jsxs("div", { className: "connect-context", children: [_jsx("strong", { children: "Test mode" }), props.account] }), _jsx("script", { dangerouslySetInnerHTML: { __html: STEPS } })] });
233
+ }
@@ -1,3 +1,3 @@
1
1
  import type { HandlerContext } from '@volter/world-core';
2
- /** Dashboard settings, Radar list creation and test Issuing funding; other pages answer Stripe's 404. */
2
+ /** Dashboard customer inspection, settings, Radar lists and test Issuing funding. */
3
3
  export declare function screen(ctx: HandlerContext): Promise<Response>;