@volter/twin-stripe 2.0.0 → 2.0.2

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.
Files changed (67) hide show
  1. package/README.md +33 -1
  2. package/dist/src/index.js +6 -4
  3. package/dist/src/manifest.js +8 -3
  4. package/dist/src/screens/checkout.js +20 -6
  5. package/dist/src/screens/connect-oauth.d.ts +27 -0
  6. package/dist/src/screens/connect-oauth.js +414 -0
  7. package/dist/src/screens/connect-settings.d.ts +22 -0
  8. package/dist/src/screens/connect-settings.js +103 -0
  9. package/dist/src/screens/portal.js +2 -0
  10. package/dist/src/semantics/after-payment.d.ts +1 -1
  11. package/dist/src/semantics/after-payment.js +6 -0
  12. package/dist/src/semantics/charges.js +10 -2
  13. package/dist/src/semantics/checkout.js +19 -6
  14. package/dist/src/semantics/connect.js +20 -3
  15. package/dist/src/semantics/invoices.js +4 -0
  16. package/dist/src/semantics/issuing.js +7 -2
  17. package/dist/src/semantics/ledger.d.ts +11 -6
  18. package/dist/src/semantics/ledger.js +40 -21
  19. package/dist/src/semantics/payment-methods.js +2 -0
  20. package/dist/src/semantics/shared.d.ts +5 -1
  21. package/dist/src/semantics/shared.js +14 -3
  22. package/dist/src/semantics/test-cards.d.ts +4 -0
  23. package/dist/src/semantics/test-cards.js +7 -0
  24. package/dist/src/semantics/transfers.js +1 -1
  25. package/dist/src/stripe-capabilities.js +829 -186
  26. package/dist/src/stripe-conformance.d.ts +2 -0
  27. package/dist/src/stripe-conformance.js +11 -2
  28. package/dist/src/stripe-emit.js +2 -2
  29. package/dist/src/stripe-events.js +14 -10
  30. package/dist/src/stripe-mirror-ui.js +3 -3
  31. package/dist/src/stripe-server.js +97 -24
  32. package/dist/src/stripe-shared.d.ts +3 -0
  33. package/dist/src/stripe-shared.js +3 -0
  34. package/dist/src/stripe-twin.js +7 -1
  35. package/dist/src/stripe-version.d.ts +2 -0
  36. package/dist/src/stripe-version.js +2 -0
  37. package/dist/test-fixtures/stripe-known-deviations.json +6 -1
  38. package/dist/test-fixtures/stripe-schemas.json +85 -12
  39. package/package.json +4 -4
  40. package/src/index.ts +6 -4
  41. package/src/manifest.ts +8 -3
  42. package/src/screens/checkout.tsx +21 -6
  43. package/src/screens/connect-oauth.tsx +400 -0
  44. package/src/screens/connect-settings.tsx +121 -0
  45. package/src/screens/portal.tsx +2 -0
  46. package/src/semantics/after-payment.ts +6 -1
  47. package/src/semantics/charges.ts +11 -2
  48. package/src/semantics/checkout.ts +19 -6
  49. package/src/semantics/connect.ts +19 -3
  50. package/src/semantics/invoices.ts +4 -0
  51. package/src/semantics/issuing.ts +7 -2
  52. package/src/semantics/ledger.ts +60 -23
  53. package/src/semantics/payment-methods.ts +2 -0
  54. package/src/semantics/shared.ts +14 -3
  55. package/src/semantics/test-cards.ts +7 -0
  56. package/src/semantics/transfers.ts +1 -1
  57. package/src/stripe-capabilities.ts +826 -182
  58. package/src/stripe-conformance.ts +13 -2
  59. package/src/stripe-emit.ts +2 -2
  60. package/src/stripe-events.ts +14 -10
  61. package/src/stripe-mirror-ui.ts +3 -3
  62. package/src/stripe-server.ts +85 -24
  63. package/src/stripe-shared.ts +3 -0
  64. package/src/stripe-twin.ts +6 -1
  65. package/src/stripe-version.ts +3 -0
  66. package/test-fixtures/stripe-known-deviations.json +6 -1
  67. package/test-fixtures/stripe-schemas.json +85 -12
package/README.md CHANGED
@@ -54,6 +54,32 @@ hand-made in-process mock).
54
54
  per World (`evt_twin_<n>`) and is the delivered webhook's id and the stored event's alike (`stripe.events.ids`).
55
55
  `world-stripe emit` re-fires a connected account's object with its `account`; it does not yet route it to Connect
56
56
  endpoints only (the kernel's emit seam has no endpoint scope).
57
+ - **Connect OAuth (Standard accounts)** (`src/screens/connect-oauth.tsx`, `src/screens/connect-settings.tsx`;
58
+ docs.stripe.com/connect/oauth-reference). The platform's **client_id is `ca_twin_self`** in every World (a test
59
+ client_id; the platform account is `acct_twin_self`), shown on the Dashboard's Connect OAuth settings page,
60
+ `dashboard.stripe.com/settings/connect/onboarding-options/oauth` (also `/settings/connect` and the `/test/…` link),
61
+ in `<code data-testid="connect-client-id">`. OAuth starts **off**: a runner POSTs that page as a browser does,
62
+ `oauth_enabled=on&redirect_uris=<one per line>` (303 to `?saved=1`), and the saved URIs are listed in
63
+ `data-testid="connect-redirect-uri"` items. `GET connect.stripe.com/oauth/authorize` checks client_id (unknown:
64
+ `invalid_client`), OAuth on, `redirect_uri` (must exactly match a registered one; absent: the first; http and
65
+ localhost allowed, as Stripe's test client_id allows), `response_type=code` and `scope` (`read_write` | `read_only`,
66
+ default `read_only`), each refusal a 400 JSON `{error, error_description, state}` as the reference says (no
67
+ redirect). The page offers the test-mode **Skip this form**, which creates a new Standard account (controller type
68
+ `account`, prefilled from valid `stripe_user[...]` values, charges and payouts enabled) and redirects with
69
+ `scope`, `code` and `state`, and **Deny access**, which redirects with
70
+ `error=access_denied&error_description=The%20user%20denied%20your%20request&state=…`. `POST
71
+ connect.stripe.com/oauth/token` (the secret key as Bearer, Basic or `client_secret`) exchanges a code once, within 5
72
+ minutes, into `{access_token, livemode, refresh_token, scope, stripe_publishable_key, stripe_user_id, token_type}`;
73
+ a reused code is `invalid_grant` and revokes the connection; `refresh_token` grants an equal or lesser scope.
74
+ `POST connect.stripe.com/oauth/deauthorize` answers `{stripe_user_id}`, after which the Stripe-Account header (and
75
+ `/v1/accounts/{id}`) for that account is refused 403 `account_invalid`. `account.application.authorized` /
76
+ `.deauthorized` reach Connect endpoints with the account; once revoked, `GET /v1/accounts` no longer lists it and its own events (payouts, balance) stop reaching the platform. The token answer's
77
+ deprecated `access_token` and `stripe_publishable_key` act as the connected account (as the Stripe-Account header
78
+ does, as Bearer or Basic; `GET /v1/account` then answers it), a token replaced by a refresh or revoked is a 401 invalid API key, and it is never the platform's key at `/oauth/token` or `/oauth/deauthorize`. The
79
+ application's own setting of its client_id (Cal.com's `client_id` app key, `STRIPE_CLIENT_ID`) is the runner's to
80
+ set to `ca_twin_self`; the World does not provision it. Where the docs stop, the twin decides (the file header lists
81
+ each): connecting an existing Stripe account, the full account application form, and holding a `read_only`
82
+ connection to reads are not modelled (todos in `stripe-capabilities.ts`).
57
83
  - **Signing secrets in a World**: Stripe mints an endpoint's `secret`; in a World the app's env is the World's, so an
58
84
  endpoint is given the World's value, which the app already verifies with: `STRIPE_WEBHOOK_SECRET` (or
59
85
  `STRIPE_WEBHOOK_SIGNING_SECRET`, `STRIPE_ENDPOINT_SECRET`, `STRIPE_WEBHOOK_SECRET_KEY`,
@@ -87,6 +113,12 @@ Point the real `stripe` SDK at it with `{ host, port, protocol: 'http' }`.
87
113
  each payout that arrived, a connected account's with its `account` to its Connect endpoints
88
114
  (`stripe.events.time_drain`). A read sees time's moves without it; a runner's drainer calls it so the events
89
115
  arrive, as the qstash and vercel twins' drain doors do.
116
+ Test mode keeps live timing except where Stripe documents otherwise: a card charge's funds are pending two days,
117
+ except the cards that bypass the pending balance (4000000000000077, 4000003720000278, `pm_card_bypassPending*`,
118
+ `tok_bypassPending*`, and a PaymentMethod saved from one) and US bank account debits ("Test transactions settle
119
+ instantly"), whose funds, and a `source_transaction` transfer's from them, are available at once; every credit
120
+ available at once is sent as `balance.available` at the next drain; a test payout is paid at its `arrival_date`
121
+ (`stripe.balance.test_mode_bypass_pending`; sources in `src/semantics/ledger.ts`).
90
122
 
91
123
  (See Getting Started → "Twin interaction surfaces".)
92
124
 
@@ -111,7 +143,7 @@ destination; a new account starts un-onboarded with charges/payouts disabled + a
111
143
  hash; a transfer's `source_transaction` waives the balance check only while its charge is unsettled,
112
144
  takes the charge's `transfer_group` or writes `group_<payment intent>` onto both, and counts reversals
113
145
  back as room; connected-account balances, payouts (manual and automatic) and payout reversal, top-ups,
114
- account sessions, persons, external accounts, application fees and hosted onboarding), plus **ephemeral_keys**, **identity verification_sessions**, **file_links**
146
+ account sessions, persons, external accounts, application fees, hosted onboarding and Connect OAuth for Standard accounts), plus **ephemeral_keys**, **identity verification_sessions**, **file_links**
115
147
  (synthesized) and **coupons**/**promotion_codes** (retrieve/list, seed-only). Cursor pagination
116
148
  (`limit`/`starting_after`/`ending_before` + `has_more`); per-resource list filters; `expand[]`
117
149
  on the modeled paths; vendor-faithful **test-card declines** (`4242…` succeeds; documented
package/dist/src/index.js CHANGED
@@ -50,7 +50,7 @@ export const pack = {
50
50
  // one live API host — declared HERE, not in the central maps (descriptor-first exemplar (adding-a-twin.md §3);
51
51
  // the pack-facts artifact carries them to covers/inspect-project and inject.cjs).
52
52
  // NEXT_PRIVATE_STRIPE_* joined the stem list in the adoption-facts sweep 2026-08-31.
53
- // `stripeconnect` is deliberately NOT claimed: the Connect area is unmodeled, so a repo
53
+ // `stripeconnect` is deliberately NOT claimed: no Connect-only credential stem is modeled, so a repo
54
54
  // holding only that credential is build-surface, not coverage this twin can honor.
55
55
  adoption: {
56
56
  // Stripe's official Python bindings.
@@ -58,15 +58,17 @@ export const pack = {
58
58
  sdks: ['stripe'], envStems: ['STRIPE', 'NEXTPRIVATESTRIPE'],
59
59
  },
60
60
  // The API and the hosted flows (src/screens): checkout.stripe.com's payment page, billing.stripe.com's customer
61
- // portal, connect.stripe.com's onboarding, verify.stripe.com's identity check and the bank-linking flow Stripe.js
61
+ // portal, connect.stripe.com's onboarding and its OAuth endpoints (/oauth/authorize, /oauth/token, /oauth/deauthorize:
62
+ // src/screens/connect-oauth.tsx), verify.stripe.com's identity check and the bank-linking flow Stripe.js
62
63
  // opens: an application that sends a person to one of their urls lands in the World. dashboard.stripe.com serves the
63
- // Dashboard's Public details page (src/screens/public-details.tsx), where the operator sets the name customers see;
64
+ // Dashboard's Public details page (src/screens/public-details.tsx), where the operator sets the name customers see,
65
+ // and its Connect OAuth settings (src/screens/connect-settings.tsx: the client_id, OAuth on, the redirect URIs);
64
66
  // claiming the host makes every other dashboard.stripe.com path an unclaimed path on a twinned host, refused in a
65
67
  // World rather than sent to the real Dashboard. js.stripe.com also serves
66
68
  // Stripe.js itself, the client library an application's page loads (stripe-js.ts): /v3/, /v3/stripe.js and /<release train>/stripe.js.
67
69
  hosts: [
68
70
  { host: 'api.stripe.com' }, { host: 'checkout.stripe.com', pathPattern: '^/c/pay/' }, { host: 'billing.stripe.com', pathPattern: '^/p/session/' },
69
- { host: 'connect.stripe.com', pathPattern: '^/setup/' }, { host: 'dashboard.stripe.com', pathPattern: '^/settings/public/?$' }, { host: 'verify.stripe.com', pathPattern: '^/start/' }, { host: 'js.stripe.com', pathPattern: '^/v3/financial-connections/|^/v3/?$|^/(v3|[a-z]+)/stripe\\.js$' },
71
+ { host: 'connect.stripe.com', pathPattern: '^/setup/|^/oauth/(authorize|token|deauthorize)/?$' }, { host: 'dashboard.stripe.com', pathPattern: '^/settings/public/?$|^/(test/)?settings/connect(/onboarding-options/oauth)?/?$' }, { host: 'verify.stripe.com', pathPattern: '^/start/' }, { host: 'js.stripe.com', pathPattern: '^/v3/financial-connections/|^/v3/?$|^/(v3|[a-z]+)/stripe\\.js$' },
70
72
  ],
71
73
  // DELIVER support: signed event synthesis from twin state (`world-stripe emit`).
72
74
  emitter: stripeEmitter,
@@ -535,6 +535,11 @@ export const manifest = {
535
535
  demand: '3 of 85 applications onboard connected accounts with Account Links', controls: ['Agree and submit', '← Return to platform'],
536
536
  source: 'https://docs.stripe.com/connect/hosted-onboarding',
537
537
  },
538
+ {
539
+ id: 'connect-oauth', kind: 'flow', host: 'connect.stripe.com', path: '/oauth/authorize', status: 'done',
540
+ demand: "Cal.com's Stripe app connects each organizer's Standard account through Connect OAuth", controls: ['Skip this form', 'Deny access'],
541
+ source: 'https://docs.stripe.com/connect/oauth-reference',
542
+ },
538
543
  {
539
544
  id: 'identity-verification', kind: 'flow', host: 'verify.stripe.com', path: '/start/{verification_session}', status: 'done',
540
545
  demand: 'a verification report is written only when a person completes this page', controls: ['Verify', 'Fail verification'],
@@ -546,9 +551,9 @@ export const manifest = {
546
551
  source: 'https://docs.stripe.com/js/financial_connections/collect_financial_connections_accounts',
547
552
  },
548
553
  {
549
- id: 'dashboard', kind: 'workspace', host: 'dashboard.stripe.com', path: '/test/{section}; /settings/public', status: 'done',
550
- demand: 'testers and agents inspect payments, customers and subscriptions; an operator sets the business name Checkout shows (Public details)',
551
- controls: ['Business name', 'Save'], source: 'https://docs.stripe.com/dashboard/basics',
554
+ id: 'dashboard', kind: 'workspace', host: 'dashboard.stripe.com', path: '/test/{section}; /settings/public; /settings/connect/onboarding-options/oauth', status: 'done',
555
+ demand: 'testers and agents inspect payments, customers and subscriptions; an operator sets the business name Checkout shows (Public details) and a Connect platform its OAuth client_id and redirect URIs',
556
+ controls: ['Business name', 'Save', 'Enable OAuth for Standard accounts', 'Redirect URIs'], source: 'https://docs.stripe.com/dashboard/basics',
552
557
  },
553
558
  ],
554
559
  resources: {
@@ -104,6 +104,13 @@ function afterTrial(session) {
104
104
  const coupon = session._coupon;
105
105
  return subtotal - (coupon && coupon.duration !== 'once' ? applyCouponDiscount(subtotal, coupon) : 0);
106
106
  }
107
+ const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
108
+ /** The email of the session's customer when it has a valid one: "If the Customer already has a valid email set, the email
109
+ * will be prefilled and not editable in Checkout" (docs.stripe.com/api/checkout/sessions/create#create_checkout_session-customer). */
110
+ function customerEmailOf(session) {
111
+ const email = session._customer?.email;
112
+ return typeof email === 'string' && EMAIL.test(email) ? email : undefined;
113
+ }
107
114
  function page(session, id, now, values = {}, error) {
108
115
  const lines = ((session.line_items?.data) ?? []).map((item) => ({
109
116
  name: String(item.description ?? 'Item'),
@@ -115,9 +122,12 @@ function page(session, id, now, values = {}, error) {
115
122
  const trial = trialOf(session, now);
116
123
  const submit = mode === 'setup' ? 'Save card' : mode === 'subscription' ? (trial ? 'Start trial' : 'Subscribe') : 'Pay';
117
124
  const trialText = trial ? trialSummary(session, trial) : undefined;
118
- const contact = typeof session.customer_email === 'string'
119
- ? [{ id: 'email', label: 'Email', type: 'email', autoComplete: 'email', value: session.customer_email }]
120
- : [{ id: 'email', label: 'Email', type: 'email', autoComplete: 'email', placeholder: 'email@example.com', value: values.email ?? '' }];
125
+ const onFile = customerEmailOf(session);
126
+ const contact = onFile
127
+ ? [{ id: 'email', label: 'Email', type: 'email', autoComplete: 'email', value: onFile, readOnly: true }]
128
+ : typeof session.customer_email === 'string'
129
+ ? [{ id: 'email', label: 'Email', type: 'email', autoComplete: 'email', value: session.customer_email }]
130
+ : [{ id: 'email', label: 'Email', type: 'email', autoComplete: 'email', placeholder: 'email@example.com', value: values.email ?? '' }];
121
131
  const card = [
122
132
  { id: 'cardNumber', label: 'Card number', autoComplete: 'cc-number', placeholder: '1234 1234 1234 1234', value: values.cardNumber ?? '' },
123
133
  { id: 'cardExpiry', label: 'Expiration', autoComplete: 'cc-exp', placeholder: 'MM / YY', value: values.cardExpiry ?? '', format: 'card-expiry' },
@@ -176,9 +186,10 @@ export function cardAnswer(v, nowSeconds, chargesNow) {
176
186
  return undefined;
177
187
  }
178
188
  async function pay(ctx, id, session, v) {
179
- const email = typeof session.customer_email === 'string' ? session.customer_email : (v.email ?? '').trim();
189
+ const onFile = customerEmailOf(session);
190
+ const email = onFile ?? (typeof session.customer_email === 'string' ? session.customer_email : (v.email ?? '').trim());
180
191
  if (session.mode !== 'setup' || !session.customer) {
181
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
192
+ if (!EMAIL.test(email))
182
193
  return page(session, id, Number(ctx.now()), v, 'Your email address is incomplete.');
183
194
  }
184
195
  // a trial charges nothing now unless a one-time price is on the first invoice
@@ -194,6 +205,9 @@ async function pay(ctx, id, session, v) {
194
205
  const customer = await created(ctx, 'customer', { id: cid, email, ...(v.billingName ? { name: v.billingName } : {}) }, { livemode: false, ...newCustomer(cid) });
195
206
  existing = { ...session, customer: customer.id };
196
207
  }
208
+ // "If the Customer does not have a valid email, Checkout will set the email entered during the session on the Customer" (the same page)
209
+ if (typeof session.customer === 'string' && !onFile && EMAIL.test(email))
210
+ await ctx.write('customer', session.customer, { email }, 'customer.update');
197
211
  const details = {
198
212
  customer_details: { email, name: v.billingName || null, address: { country: v.billingCountry || null, postal_code: v.billingPostalCode || null, city: null, line1: null, line2: null, state: null }, phone: null, tax_exempt: 'none', tax_ids: [] },
199
213
  ...(existing.customer !== session.customer ? { customer: existing.customer } : {}),
@@ -235,7 +249,7 @@ export function stripeCheckoutFlow(scope) {
235
249
  return gone();
236
250
  const stored = ctx.row(CS, id) ?? {};
237
251
  const coupon = stored._discount?.coupon;
238
- 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 };
252
+ 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 };
239
253
  return request.method === 'GET' ? page(full, id, Number(ctx.now())) : pay(ctx, id, full, values);
240
254
  };
241
255
  }
@@ -0,0 +1,27 @@
1
+ import { type SemanticsContext } from '@volter/world-core';
2
+ /** A code "expires in 5 minutes". */
3
+ export declare const CODE_TTL_SECONDS = 300;
4
+ /** One connection per connected account (the twin's one platform): its scope, tokens, and whether it was revoked. */
5
+ export declare const CONNECTIONS = "_oauth_connection";
6
+ type Scoped = {
7
+ root?: string;
8
+ clock?: () => string;
9
+ };
10
+ /** Whether a connected account's OAuth connection to the platform was revoked (deauthorized, or its code reused). */
11
+ export declare function connectionRevoked(ctx: SemanticsContext, account: string): boolean;
12
+ /** The connected account an OAuth key acts as — the connection's current access_token (sk_test_oauth_…) or its
13
+ * stripe_publishable_key (pk_test_oauth_…) — with the scope it was issued; undefined for any other key, and null for an
14
+ * OAuth-shaped key no live connection holds (revoked, replaced by a refresh, or never issued). */
15
+ export declare function oauthKeyAccount(ctx: SemanticsContext, key: string): {
16
+ account: string;
17
+ scope: string;
18
+ } | null | undefined;
19
+ /** The prefill the request carries, invalid values silently dropped (docs.stripe.com/connect/oauth-reference). Only
20
+ * what the new account's object shows the platform, or the page shows the person, is read: the person's birth date and
21
+ * the business address are the account application's, which the twin does not model (the test-mode skip). */
22
+ export declare function prefill(q: URLSearchParams): Record<string, string>;
23
+ /** A key shown the way Stripe's errors show one: its prefix and its last four. */
24
+ export declare const redactKey: (key: string) => string;
25
+ /** connect.stripe.com's OAuth endpoints, or undefined for any other request. */
26
+ export declare function stripeConnectOAuthFlow(scope: Scoped): (request: Request) => Promise<Response | undefined>;
27
+ export {};
@@ -0,0 +1,414 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // STRIPE CONNECT OAUTH (Standard accounts) — a hosted flow (docs/contributing/architecture.md, "Screens") and the two
3
+ // connect.stripe.com endpoints behind it, as docs.stripe.com/connect/oauth-reference and
4
+ // docs.stripe.com/connect/oauth-standard-accounts describe them. A platform sends a person to
5
+ // GET connect.stripe.com/oauth/authorize with its client_id; the person connects (in test mode Stripe lets them "Force-skip
6
+ // the account form", docs.stripe.com/connect/testing#using-oauth) or denies; Stripe sends them back to the redirect_uri
7
+ // with a `code`, the `scope` granted and the `state` (or `error=access_denied`); the platform's server turns the code into
8
+ // the connection with POST connect.stripe.com/oauth/token, authenticated by its secret key, and learns the account id
9
+ // (`stripe_user_id`) it then acts as with the Stripe-Account header. POST connect.stripe.com/oauth/deauthorize revokes the
10
+ // connection. The page is @volter/world-ui's consent piece under Stripe's skin; nothing of Stripe's page is copied.
11
+ //
12
+ // What the documentation says and the twin keeps:
13
+ // - authorize: `client_id` is the platform's (its Connect OAuth settings, ./connect-settings.tsx, where OAuth is enabled
14
+ // and the redirect URIs are registered); `response_type` "The only option at the moment is code"; `redirect_uri`, "If
15
+ // provided, this must exactly match one of the ... redirect_uri values in your application settings" and "Defaults to
16
+ // the redirect_uri in your application settings" ("the first URI configured"); `scope` read_write or read_only,
17
+ // "Defaults to read_only"; `state` passed back. `stripe_user[...]` prefills the new account, and "Any parameters with
18
+ // invalid values are silently ignored". Errors: "the user's browser won't be redirected except in the case of
19
+ // access_denied. Instead, errors will be returned in a JSON dictionary" of error, error_description and state, with
20
+ // the codes invalid_scope, invalid_redirect_uri, invalid_request (Missing response_type) and unsupported_response_type.
21
+ // Denied: `error=access_denied&error_description=The%20user%20denied%20your%20request`. Success: code, scope, state.
22
+ // - token: the code "can only be used once and expires in 5 minutes"; "Consuming an authorization code more than once
23
+ // revokes the account connection"; grant_type authorization_code or refresh_token; a refresh `scope` of "equal or lesser
24
+ // scope" and "Any existing access token with the same scope and mode ... is revoked"; the response's scope,
25
+ // stripe_user_id, livemode, token_type "bearer", access_token, stripe_publishable_key and refresh_token; errors
26
+ // invalid_request, invalid_grant ("Authorization code does not exist: {AUTHORIZATION_CODE}", verbatim from the guide),
27
+ // unsupported_grant_type and invalid_scope as `{ error, error_description }`.
28
+ // - deauthorize: client_id and stripe_user_id, answered `{ stripe_user_id }`; errors invalid_request and invalid_client
29
+ // ("stripe_user_id doesn't exist or isn't connected to your application"); afterwards "the account can't be accessed by
30
+ // your platform ... through the API" (stripe-server.ts refuses the Stripe-Account header for it, account_invalid).
31
+ // - events: account.application.authorized when the person connects and account.application.deauthorized when the
32
+ // connection is revoked, each the connected account's (top-level `account`, Connect endpoints), carrying the
33
+ // `application` object ({ id: the client_id, object: 'application', name }).
34
+ //
35
+ // Where the documentation stops and the twin decides:
36
+ // - the twin is a test-mode Stripe: its client_id is a test one (./connect-settings.tsx PLATFORM_CLIENT_ID), redirect
37
+ // URIs may be http and localhost ("Your test client_id allows you to ... Set your redirect_uri to a non-HTTPS URL /
38
+ // to localhost"), a code, token and connection are livemode false, and a live secret key (sk_live_) is refused as the
39
+ // documented mode mismatch (invalid_grant at token, invalid_client at deauthorize).
40
+ // - the only way through the page is the test-mode skip: it creates a NEW Standard account from the prefill and connects
41
+ // it; logging in to connect an existing Stripe account is not modelled. A skipped test account can take payments at
42
+ // once (charges and payouts enabled, card_payments and transfers active, nothing due), and controls itself (controller
43
+ // type `account`), since the platform did not create it.
44
+ // - an unknown client_id answers invalid_client "No application matches the supplied client identifier", and a
45
+ // platform that has not enabled OAuth invalid_client naming its settings page; a missing client_id is invalid_request.
46
+ // The JSON errors are HTTP 400; the order of the checks is client_id, OAuth enabled, redirect_uri, response_type,
47
+ // scope. With no redirect URI registered and none given, invalid_redirect_uri. read_only is accepted from any platform
48
+ // (the docs limit it to extensions; the twin does not tell a platform from an extension).
49
+ // - the secret key may come as Bearer, as Basic's user, or as the `client_secret` parameter (the reference's curl uses
50
+ // `-u`, stripe-node a Bearer header); without one the answer is 401 invalid_request, and a key that is not a secret
51
+ // key (sk_…/rk_…) 401 invalid_request too. The token and deauthorize wording other than the one quoted above is the
52
+ // twin's. An expired code is invalid_grant "Authorization code expired: {code}"; a reused one invalid_grant "This
53
+ // authorization code has already been used. All tokens issued with this code have been revoked." and revokes.
54
+ // - tokens are deterministic, not secret: access_token `sk_test_oauth_{account}_{n}`, refresh_token `rt_twin_{n}`,
55
+ // stripe_publishable_key `pk_test_oauth_{account}`; a refresh answers the same refresh_token (`rt_twin_{n}` of the code
56
+ // `ac_twin_{n}` that made the connection). The access_token and publishable key are deprecated by Stripe (use the
57
+ // Stripe-Account header) and act as the account (stripe-server.ts actingAsOAuthKey); a read_only one is not held to
58
+ // reads (manifest todo `stripe.connect.oauth_scope_enforcement`).
59
+ // - concurrency: a code's number is taken with its write in one move; a code redeemed twice at once lets one exchange
60
+ // win, and whichever of the two finishes last revokes the connection, so a double exchange always ends revoked.
61
+ import { semanticsContext } from '@volter/world-core';
62
+ import { Consent, CONSENT_CSS, flowPage } from '@volter/world-ui';
63
+ import surface from '../generated/surface.gen.json' with { type: 'json' };
64
+ import { manifest } from "../manifest.js";
65
+ import { created } from "../semantics/shared.js";
66
+ import { OAUTH_CONNECTIONS, publicBusinessName } from "../stripe-shared.js";
67
+ import { accountSettings, afterStripeWrite, PLATFORM_ACCOUNT_ID } from "../stripe-twin.js";
68
+ import { formOf, seeOther, STRIPE_CONSENT_SKIN } from "./consent-skin.js";
69
+ import { connectOAuthSettings, PLATFORM_CLIENT_ID, SETTINGS_URL } from "./connect-settings.js";
70
+ const OPERATION = surface.operations.find((o) => o.id === 'PostAccountsAccount');
71
+ /** A code "expires in 5 minutes". */
72
+ export const CODE_TTL_SECONDS = 300;
73
+ const CODES = '_oauth_code';
74
+ /** One connection per connected account (the twin's one platform): its scope, tokens, and whether it was revoked. */
75
+ export const CONNECTIONS = OAUTH_CONNECTIONS;
76
+ const SCOPES = ['read_write', 'read_only'];
77
+ const json = (body, status = 200) => Response.json(body, { status, headers: { 'request-id': 'req_twin', 'cache-control': 'no-store' } });
78
+ const oauthError = (error, description, status = 400, extra = {}) => json({ error, error_description: description, ...extra }, status);
79
+ /** Whether a connected account's OAuth connection to the platform was revoked (deauthorized, or its code reused). */
80
+ export function connectionRevoked(ctx, account) {
81
+ return ctx.rowsRaw(CONNECTIONS).some((c) => c.id === account && c.revoked === true);
82
+ }
83
+ /** The connected account an OAuth key acts as — the connection's current access_token (sk_test_oauth_…) or its
84
+ * stripe_publishable_key (pk_test_oauth_…) — with the scope it was issued; undefined for any other key, and null for an
85
+ * OAuth-shaped key no live connection holds (revoked, replaced by a refresh, or never issued). */
86
+ export function oauthKeyAccount(ctx, key) {
87
+ if (!/^(sk|pk)_test_oauth_/.test(key))
88
+ return undefined;
89
+ const held = ctx.rowsRaw(CONNECTIONS).find((c) => c.revoked !== true && (c.access_token === key || c.publishable_key === key));
90
+ return held ? { account: String(held.id), scope: String(key.startsWith('pk_') ? held.scope : held.access_scope ?? held.scope) } : null;
91
+ }
92
+ // ── authorize ──
93
+ /** A query parameter, or undefined when absent (an empty value is absent too, as a browser form sends one). */
94
+ const param = (q, k) => { const v = q.get(k); return v === null || v === '' ? undefined : v; };
95
+ /** The authorize request checked as Stripe checks it, or the JSON error it answers. */
96
+ function checked(ctx, q) {
97
+ const state = param(q, 'state');
98
+ const fail = (error, description) => oauthError(error, description, 400, state !== undefined ? { state } : {});
99
+ const client = param(q, 'client_id');
100
+ if (!client)
101
+ return fail('invalid_request', 'No client_id provided.');
102
+ if (client !== PLATFORM_CLIENT_ID)
103
+ return fail('invalid_client', 'No application matches the supplied client identifier');
104
+ const settings = connectOAuthSettings(ctx);
105
+ if (!settings.oauth_enabled)
106
+ return fail('invalid_client', `OAuth is not enabled for this application. Enable it in your Connect OAuth settings (${SETTINGS_URL}).`);
107
+ const given = q.get('redirect_uri');
108
+ let redirect;
109
+ if (given !== null) {
110
+ if (!settings.redirect_uris.includes(given))
111
+ return fail('invalid_redirect_uri', `Invalid redirect URI '${given}'. Ensure this uri exactly matches one of the uris specified in your application settings`);
112
+ redirect = given;
113
+ }
114
+ else {
115
+ redirect = settings.redirect_uris[0];
116
+ if (!redirect)
117
+ return fail('invalid_redirect_uri', 'No redirect URI is configured in your application settings');
118
+ }
119
+ const type = q.get('response_type');
120
+ if (type === null || type === '')
121
+ return fail('invalid_request', 'Missing response_type parameter.');
122
+ if (type !== 'code')
123
+ return fail('unsupported_response_type', `Unsupported response_type parameter: ${type}. Currently the only supported response_type is code.`);
124
+ const scope = param(q, 'scope') ?? 'read_only';
125
+ if (!SCOPES.includes(scope))
126
+ return fail('invalid_scope', `Invalid scope parameter provided: '${scope}'. Accepted scopes are 'read_write' or 'read_only'.`);
127
+ return { redirect, scope: scope, state };
128
+ }
129
+ /** The redirect_uri with these parameters added, each percent-encoded (a space as %20, as Stripe's examples show). */
130
+ function withParams(uri, params) {
131
+ const [base, fragment] = uri.split('#', 2);
132
+ const added = params.filter((p) => p[1] !== undefined).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
133
+ return `${base}${base.includes('?') ? (base.endsWith('?') || base.endsWith('&') ? '' : '&') : '?'}${added}${fragment !== undefined ? `#${fragment}` : ''}`;
134
+ }
135
+ /** The prefill the request carries, invalid values silently dropped (docs.stripe.com/connect/oauth-reference). Only
136
+ * what the new account's object shows the platform, or the page shows the person, is read: the person's birth date and
137
+ * the business address are the account application's, which the twin does not model (the test-mode skip). */
138
+ export function prefill(q) {
139
+ const v = (k) => param(q, `stripe_user[${k}]`)?.trim() || undefined;
140
+ const out = {};
141
+ const keep = (k, ok = () => true) => { const s = v(k); if (s !== undefined && ok(s))
142
+ out[k] = s; };
143
+ keep('email', (s) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s));
144
+ keep('url', (s) => /^https?:\/\/\S+$/i.test(s));
145
+ keep('country', (s) => /^[A-Z]{2}$/.test(s));
146
+ keep('phone_number', (s) => /^\d{10}$/.test(s) && out.country !== undefined);
147
+ keep('business_name');
148
+ keep('business_type', (s) => ['sole_prop', 'corporation', 'non_profit', 'partnership', 'llc'].includes(s));
149
+ keep('first_name');
150
+ keep('last_name');
151
+ keep('product_description');
152
+ keep('currency', (s) => /^[a-z]{3}$/.test(s) && out.country !== undefined);
153
+ return out;
154
+ }
155
+ // the default currency of an account in a country, where the prefill names none (docs.stripe.com/connect/currencies:
156
+ // the account's default is its country's currency); a country outside this list is given usd
157
+ const COUNTRY_CURRENCY = { US: 'usd', CA: 'cad', GB: 'gbp', AU: 'aud', NZ: 'nzd', JP: 'jpy', SG: 'sgd', HK: 'hkd', CH: 'chf', SE: 'sek', NO: 'nok', DK: 'dkk', PL: 'pln', MX: 'mxn', BR: 'brl', IN: 'inr', DE: 'eur', FR: 'eur', ES: 'eur', IT: 'eur', NL: 'eur', IE: 'eur', BE: 'eur', AT: 'eur', PT: 'eur', FI: 'eur' };
158
+ const BUSINESS_TYPE = { sole_prop: 'individual', corporation: 'company', llc: 'company', partnership: 'company', non_profit: 'non_profit' };
159
+ /** The Standard account the skipped form creates, from the prefill. */
160
+ async function createAccount(ctx, p) {
161
+ const country = p.country ?? 'US';
162
+ const id = ctx.mint('account');
163
+ const none = { alternatives: [], current_deadline: null, currently_due: [], disabled_reason: null, errors: [], eventually_due: [], past_due: [], pending_verification: [] };
164
+ await created(ctx, 'account', { id }, {
165
+ type: 'standard', country, default_currency: p.currency ?? COUNTRY_CURRENCY[country] ?? 'usd', email: p.email ?? null,
166
+ business_type: p.business_type ? BUSINESS_TYPE[p.business_type] ?? null : null,
167
+ business_profile: { name: p.business_name ?? null, url: p.url ?? null, product_description: p.product_description ?? null, support_phone: p.phone_number ?? null, mcc: null },
168
+ charges_enabled: true, payouts_enabled: true, details_submitted: true,
169
+ capabilities: { card_payments: 'active', transfers: 'active' },
170
+ controller: { type: 'account' },
171
+ requirements: none, future_requirements: none,
172
+ settings: accountSettings(undefined, p.business_name ? { dashboard: { display_name: p.business_name, timezone: 'Etc/UTC' } } : undefined),
173
+ external_accounts: { object: 'list', data: [], has_more: false, total_count: 0, url: `/v1/accounts/${id}/external_accounts` },
174
+ tos_acceptance: { date: null, ip: null, user_agent: null }, metadata: {}, livemode: false,
175
+ }, { operation: 'account.oauth_connect' });
176
+ return id;
177
+ }
178
+ /** The platform's application as the account.application.* events carry it. */
179
+ function application(ctx) {
180
+ return { id: PLATFORM_CLIENT_ID, object: 'application', name: publicBusinessName(ctx.get('account', PLATFORM_ACCOUNT_ID)) };
181
+ }
182
+ /** Send one of the account.application.* events as the connected account's. */
183
+ async function applicationEvent(ctx, type, account) {
184
+ await afterStripeWrite('application', type, application(ctx), ctx.root, ctx.occurredAt, undefined, account);
185
+ }
186
+ /** A code's number: `ac_twin_{n}`, the refresh token its exchange mints being `rt_twin_{n}`, so the one follows the
187
+ * other and neither is counted separately. */
188
+ const codeNumber = (code) => code.replace(/^ac_twin_/, '');
189
+ function page(ctx, action, a, p) {
190
+ const name = publicBusinessName(ctx.get('account', PLATFORM_ACCOUNT_ID));
191
+ const who = [p.email, [p.first_name, p.last_name].filter(Boolean).join(' '), p.business_name].filter((s) => s).join(' · ');
192
+ return flowPage({
193
+ title: `Connect to ${name} – Stripe`,
194
+ css: [CONSENT_CSS, STRIPE_CONSENT_SKIN],
195
+ body: (_jsx(Consent, { heading: `Connect your Stripe account to ${name}`, app: { name }, request: "wants to connect to your Stripe account \u00B7 test mode", permissions: [
196
+ a.scope === 'read_write'
197
+ ? { title: 'Read and write access', detail: 'Create payments, customers and other data on your account, and read its data' }
198
+ : { title: 'Read-only access', detail: 'Read your account’s data' },
199
+ { title: 'A new test account', detail: who || 'No details prefilled' },
200
+ ], action: action, fields: {}, deny: { name: 'decision', value: 'deny', label: 'Deny access' }, allow: { name: 'decision', value: 'skip', label: 'Skip this form' }, note: `Test mode: skipping the form creates a new test account and connects it. You will be returned to ${new URL(a.redirect).host}.` })),
201
+ });
202
+ }
203
+ /** The person connects: a new Standard account, its connection's code, the authorized event, and the redirect. */
204
+ async function connect(ctx, a, p) {
205
+ const account = await createAccount(ctx, p);
206
+ // the code's number is taken and its row written in one move, so two people connecting at once get two codes
207
+ const fields = { client_id: PLATFORM_CLIENT_ID, account, scope: a.scope, redirect_uri: a.redirect, expires_at: Number(ctx.now()) + CODE_TTL_SECONDS, used: false, livemode: false };
208
+ const code = await ctx.atomically((rows) => {
209
+ const id = `ac_twin_${rows(CODES).length + 1}`;
210
+ return { value: id, write: { resource: CODES, id, fields, operation: 'oauth_code.issue' } };
211
+ });
212
+ await applicationEvent(ctx, 'account.application.authorized', account);
213
+ return seeOther(withParams(a.redirect, [['scope', a.scope], ['code', code], ['state', a.state]]));
214
+ }
215
+ async function authorize(request, scope) {
216
+ const url = new URL(request.url);
217
+ const form = request.method === 'POST' ? await formOf(request.clone()) : {};
218
+ const ctx = await semanticsContext(manifest, request, OPERATION, scope);
219
+ const a = checked(ctx, url.searchParams);
220
+ if (a instanceof Response)
221
+ return a;
222
+ const p = prefill(url.searchParams);
223
+ if (request.method === 'GET')
224
+ return page(ctx, `${url.pathname}${url.search}`, a, p);
225
+ if (form.decision === 'deny')
226
+ return seeOther(withParams(a.redirect, [['error', 'access_denied'], ['error_description', 'The user denied your request'], ['state', a.state]]));
227
+ if (form.decision === 'skip')
228
+ return connect(ctx, a, p);
229
+ return page(ctx, `${url.pathname}${url.search}`, a, p);
230
+ }
231
+ // ── token and deauthorize ──
232
+ /** The request's parameters: a form, as stripe-node and curl send them, or JSON. */
233
+ async function bodyOf(request) {
234
+ const text = await request.clone().text();
235
+ if ((request.headers.get('content-type') ?? '').includes('json') || text.trim().startsWith('{')) {
236
+ try {
237
+ return Object.fromEntries(Object.entries(JSON.parse(text)).map(([k, v]) => [k, String(v)]));
238
+ }
239
+ catch {
240
+ return {};
241
+ }
242
+ }
243
+ return Object.fromEntries(new URLSearchParams(text));
244
+ }
245
+ /** The secret key a request carries: Bearer, Basic's user, or the client_secret parameter. */
246
+ function secretKey(request, params) {
247
+ const header = request.headers.get('authorization') ?? '';
248
+ const bearer = /^bearer\s+(\S+)/i.exec(header)?.[1];
249
+ if (bearer)
250
+ return bearer;
251
+ const basic = /^basic\s+(\S+)/i.exec(header)?.[1];
252
+ if (basic) {
253
+ try {
254
+ const user = atob(basic).split(':')[0];
255
+ if (user)
256
+ return user;
257
+ }
258
+ catch { /* not base64 */ }
259
+ }
260
+ return params.client_secret || undefined;
261
+ }
262
+ /** A key shown the way Stripe's errors show one: its prefix and its last four. */
263
+ export const redactKey = (key) => {
264
+ const prefix = /^(sk|rk|pk)_(test|live)_/.exec(key)?.[0] ?? '';
265
+ const rest = key.slice(prefix.length);
266
+ return rest.length > 4 ? `${prefix}${'*'.repeat(rest.length - 4)}${rest.slice(-4)}` : `${prefix}${'*'.repeat(rest.length)}`;
267
+ };
268
+ /** The refusal of a request without a usable secret key, or undefined when it has one (answered with whether it is live). */
269
+ function keyRefused(key) {
270
+ if (!key)
271
+ return oauthError('invalid_request', 'No API key provided. Provide your secret key in the Authorization header, using Bearer auth (e.g. \'Authorization: Bearer YOUR_SECRET_KEY\').', 401);
272
+ if (!/^(sk|rk)_(test|live)_/.test(key))
273
+ return oauthError('invalid_request', `Invalid API Key provided: ${redactKey(key)}. This endpoint requires a secret key.`, 401);
274
+ // a connected account's access_token is that account's key, never the platform's ("Make this call using your secret API
275
+ // key", the reference): it neither exchanges codes nor refreshes nor deauthorizes
276
+ if (/^sk_test_oauth_/.test(key))
277
+ return oauthError('invalid_request', `Invalid API Key provided: ${redactKey(key)}. This endpoint requires your platform's secret key.`, 401);
278
+ return undefined;
279
+ }
280
+ const live = (key) => /^(sk|rk)_live_/.test(key);
281
+ /** A new access token for a connection: its generation counted from the ones it had. */
282
+ const accessToken = (account, n) => `sk_test_oauth_${account}_${n}`;
283
+ /** Revoke an account's connection, once: the check and the write are one move, and only the move that revoked it sends
284
+ * account.application.deauthorized. Answers whether this call revoked it. */
285
+ async function revoke(ctx, account) {
286
+ const revoked = await ctx.atomically((rows) => {
287
+ const held = rows(CONNECTIONS).find((c) => c.id === account);
288
+ if (!held || held.revoked === true)
289
+ return { value: false };
290
+ return { value: true, write: { resource: CONNECTIONS, id: account, fields: { revoked: true, access_token: null, revoked_at: Number(ctx.now()) }, operation: 'oauth_connection.revoke' } };
291
+ });
292
+ if (revoked)
293
+ await applicationEvent(ctx, 'account.application.deauthorized', account);
294
+ return revoked;
295
+ }
296
+ async function token(request, scope) {
297
+ const params = await bodyOf(request);
298
+ const key = secretKey(request, params);
299
+ const refused = keyRefused(key);
300
+ if (refused)
301
+ return refused;
302
+ const ctx = await semanticsContext(manifest, request, OPERATION, scope);
303
+ const grant = params.grant_type;
304
+ if (!grant)
305
+ return oauthError('invalid_request', 'No grant type specified');
306
+ if (grant !== 'authorization_code' && grant !== 'refresh_token')
307
+ return oauthError('unsupported_grant_type', `Unsupported grant type: ${grant}. The only currently supported types are authorization_code and refresh_token.`);
308
+ if (grant === 'authorization_code') {
309
+ const code = params.code;
310
+ if (!code)
311
+ return oauthError('invalid_request', 'No authorization code provided');
312
+ if (live(key))
313
+ return oauthError('invalid_grant', `Authorization code provided does not belong to the livemode of the API key: ${code} is a test mode code, but a live mode key was used.`);
314
+ const found = await ctx.atomically((rows) => {
315
+ const row = rows(CODES).find((r) => r.id === code);
316
+ if (!row)
317
+ return { value: { outcome: 'missing' } };
318
+ // a second use is marked in the same move, so the exchange that spent it sees it after writing its connection
319
+ if (row.used === true)
320
+ return { value: { outcome: 'used', row }, write: { resource: CODES, id: code, fields: { reused: true }, operation: 'oauth_code.reuse' } };
321
+ if (Number(ctx.now()) >= Number(row.expires_at))
322
+ return { value: { outcome: 'expired', row } };
323
+ return { value: { outcome: 'ok', row }, write: { resource: CODES, id: code, fields: { used: true, used_at: Number(ctx.now()) }, operation: 'oauth_code.redeem' } };
324
+ });
325
+ if (found.outcome === 'missing')
326
+ return oauthError('invalid_grant', `Authorization code does not exist: ${code}`);
327
+ const account = String(found.row.account);
328
+ if (found.outcome === 'used') {
329
+ // "Consuming an authorization code more than once revokes the account connection"
330
+ await revoke(ctx, account);
331
+ return oauthError('invalid_grant', 'This authorization code has already been used. All tokens issued with this code have been revoked.');
332
+ }
333
+ if (found.outcome === 'expired')
334
+ return oauthError('invalid_grant', `Authorization code expired: ${code}`);
335
+ const granted = String(found.row.scope);
336
+ const refresh = `rt_twin_${codeNumber(code)}`;
337
+ const connection = { client_id: PLATFORM_CLIENT_ID, account, scope: granted, code, refresh_token: refresh, access_token: accessToken(account, 1), access_scope: granted, generation: 1, publishable_key: `pk_test_oauth_${account}`, revoked: false, livemode: false };
338
+ await ctx.record(CONNECTIONS, connection, account);
339
+ // the code used again while this exchange was writing: that use found no connection to revoke, so this one does
340
+ if (ctx.rowsRaw(CODES).some((r) => r.id === code && r.reused === true)) {
341
+ await revoke(ctx, account);
342
+ return oauthError('invalid_grant', 'This authorization code has already been used. All tokens issued with this code have been revoked.');
343
+ }
344
+ return json(tokenBody(connection, granted));
345
+ }
346
+ const refresh = params.refresh_token;
347
+ if (!refresh)
348
+ return oauthError('invalid_request', 'No refresh token provided');
349
+ const held = ctx.rowsRaw(CONNECTIONS).find((c) => c.refresh_token === refresh && c.revoked !== true);
350
+ if (!held)
351
+ return oauthError('invalid_grant', `Refresh token does not exist: ${refresh}`);
352
+ if (live(key))
353
+ return oauthError('invalid_grant', 'The refresh token provided does not belong to the livemode of the API key.');
354
+ const asked = params.scope || String(held.scope);
355
+ if (!SCOPES.includes(asked))
356
+ return oauthError('invalid_scope', `Invalid scope parameter provided: '${asked}'. Accepted scopes are 'read_write' or 'read_only'.`);
357
+ if (asked === 'read_write' && held.scope !== 'read_write')
358
+ return oauthError('invalid_scope', `The requested scope 'read_write' is greater than the scope of the refresh token ('${String(held.scope)}').`);
359
+ const account = String(held.id);
360
+ // "Any existing access token with the same scope and mode ... is revoked": the connection holds the one now issued; the
361
+ // generation is read and written in one move, so two refreshes at once issue two tokens, and a refresh never lands
362
+ // on a connection revoked meanwhile
363
+ const renewed = await ctx.atomically((rows) => {
364
+ const now = rows(CONNECTIONS).find((c) => c.id === account && c.revoked !== true);
365
+ if (!now)
366
+ return { value: undefined };
367
+ const generation = Number(now.generation ?? 1) + 1;
368
+ const fields = { access_token: accessToken(account, generation), access_scope: asked, generation };
369
+ return { value: { ...now, ...fields }, write: { resource: CONNECTIONS, id: account, fields, operation: 'oauth_connection.refresh' } };
370
+ });
371
+ if (!renewed)
372
+ return oauthError('invalid_grant', `Refresh token does not exist: ${refresh}`);
373
+ return json(tokenBody(renewed, asked));
374
+ }
375
+ /** The token endpoint's answer, as the reference lists it. */
376
+ function tokenBody(c, scope) {
377
+ return { access_token: c.access_token, livemode: false, refresh_token: c.refresh_token, scope, stripe_publishable_key: c.publishable_key, stripe_user_id: c.account ?? c.id, token_type: 'bearer' };
378
+ }
379
+ async function deauthorize(request, scope) {
380
+ const params = await bodyOf(request);
381
+ const key = secretKey(request, params);
382
+ const refused = keyRefused(key);
383
+ if (refused)
384
+ return refused;
385
+ const ctx = await semanticsContext(manifest, request, OPERATION, scope);
386
+ const client = params.client_id;
387
+ const account = params.stripe_user_id;
388
+ if (!client)
389
+ return oauthError('invalid_request', 'No client_id provided');
390
+ if (!account)
391
+ return oauthError('invalid_request', 'No stripe_user_id provided');
392
+ if (client !== PLATFORM_CLIENT_ID)
393
+ return oauthError('invalid_client', `No such application: '${client}'`);
394
+ if (live(key))
395
+ return oauthError('invalid_client', `The API key mode (live) does not match the mode of the client_id ${client} (test).`);
396
+ // the check and the revocation are one move (revoke): of two deauthorizations at once, one revokes and the other finds
397
+ // the account no longer connected
398
+ if (!(await revoke(ctx, account)))
399
+ return oauthError('invalid_client', `This application is not connected to stripe account ${account}, or that account does not exist.`);
400
+ return json({ stripe_user_id: account });
401
+ }
402
+ /** connect.stripe.com's OAuth endpoints, or undefined for any other request. */
403
+ export function stripeConnectOAuthFlow(scope) {
404
+ return async (request) => {
405
+ const path = new URL(request.url).pathname.replace(/\/+$/, '');
406
+ if (path === '/oauth/authorize' && (request.method === 'GET' || request.method === 'POST'))
407
+ return authorize(request, scope);
408
+ if (path === '/oauth/token' && request.method === 'POST')
409
+ return token(request, scope);
410
+ if (path === '/oauth/deauthorize' && request.method === 'POST')
411
+ return deauthorize(request, scope);
412
+ return undefined;
413
+ };
414
+ }