mppx 0.8.11 → 0.8.13

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 (218) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +16 -1
  3. package/dist/cli/account.d.ts +88 -0
  4. package/dist/cli/account.d.ts.map +1 -1
  5. package/dist/cli/account.js +39 -9
  6. package/dist/cli/account.js.map +1 -1
  7. package/dist/cli/cli.d.ts +2 -1
  8. package/dist/cli/cli.d.ts.map +1 -1
  9. package/dist/cli/cli.js +59 -2
  10. package/dist/cli/cli.js.map +1 -1
  11. package/dist/cli/sessions/Manager.d.ts +31 -0
  12. package/dist/cli/sessions/Manager.d.ts.map +1 -0
  13. package/dist/cli/sessions/Manager.js +56 -0
  14. package/dist/cli/sessions/Manager.js.map +1 -0
  15. package/dist/cli/sessions/commands.d.ts +36 -0
  16. package/dist/cli/sessions/commands.d.ts.map +1 -0
  17. package/dist/cli/sessions/commands.js +407 -0
  18. package/dist/cli/sessions/commands.js.map +1 -0
  19. package/dist/cli/sessions/request.d.ts +35 -0
  20. package/dist/cli/sessions/request.d.ts.map +1 -0
  21. package/dist/cli/sessions/request.js +271 -0
  22. package/dist/cli/sessions/request.js.map +1 -0
  23. package/dist/cli/sessions/store.d.ts +134 -0
  24. package/dist/cli/sessions/store.d.ts.map +1 -0
  25. package/dist/cli/sessions/store.js +723 -0
  26. package/dist/cli/sessions/store.js.map +1 -0
  27. package/dist/cli/utils.d.ts +2 -0
  28. package/dist/cli/utils.d.ts.map +1 -1
  29. package/dist/cli/utils.js +12 -0
  30. package/dist/cli/utils.js.map +1 -1
  31. package/dist/cli/validate/index.d.ts.map +1 -1
  32. package/dist/cli/validate/index.js +12 -6
  33. package/dist/cli/validate/index.js.map +1 -1
  34. package/dist/cli/validate/messages.d.ts +2 -0
  35. package/dist/cli/validate/messages.d.ts.map +1 -0
  36. package/dist/cli/validate/messages.js +8 -0
  37. package/dist/cli/validate/messages.js.map +1 -0
  38. package/dist/client/internal/Fetch.d.ts.map +1 -1
  39. package/dist/client/internal/Fetch.js +35 -6
  40. package/dist/client/internal/Fetch.js.map +1 -1
  41. package/dist/client/internal/MethodResponse.d.ts +23 -0
  42. package/dist/client/internal/MethodResponse.d.ts.map +1 -0
  43. package/dist/client/internal/MethodResponse.js +15 -0
  44. package/dist/client/internal/MethodResponse.js.map +1 -0
  45. package/dist/client/node.d.ts +29 -0
  46. package/dist/client/node.d.ts.map +1 -0
  47. package/dist/client/node.js +177 -0
  48. package/dist/client/node.js.map +1 -0
  49. package/dist/internal/types.d.ts +24 -0
  50. package/dist/internal/types.d.ts.map +1 -1
  51. package/dist/stripe/server/Charge.d.ts +8 -4
  52. package/dist/stripe/server/Charge.d.ts.map +1 -1
  53. package/dist/stripe/server/Charge.js.map +1 -1
  54. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  55. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  56. package/dist/stripe/server/internal/html.gen.js +1 -1
  57. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  58. package/dist/tempo/client/Methods.d.ts +1 -1
  59. package/dist/tempo/internal/types.d.ts +10 -1
  60. package/dist/tempo/internal/types.d.ts.map +1 -1
  61. package/dist/tempo/legacy/client/ChannelOps.d.ts +2 -0
  62. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  63. package/dist/tempo/legacy/client/ChannelOps.js +4 -0
  64. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  65. package/dist/tempo/legacy/client/Session.d.ts +2 -2
  66. package/dist/tempo/legacy/client/Session.d.ts.map +1 -1
  67. package/dist/tempo/legacy/client/Session.js +7 -0
  68. package/dist/tempo/legacy/client/Session.js.map +1 -1
  69. package/dist/tempo/legacy/server/Session.d.ts +3 -3
  70. package/dist/tempo/legacy/server/Session.d.ts.map +1 -1
  71. package/dist/tempo/legacy/server/Session.js.map +1 -1
  72. package/dist/tempo/server/Charge.d.ts +3 -2
  73. package/dist/tempo/server/Charge.d.ts.map +1 -1
  74. package/dist/tempo/server/Charge.js.map +1 -1
  75. package/dist/tempo/server/Methods.d.ts +2 -23
  76. package/dist/tempo/server/Methods.d.ts.map +1 -1
  77. package/dist/tempo/server/Methods.js +2 -2
  78. package/dist/tempo/server/Methods.js.map +1 -1
  79. package/dist/tempo/server/Subscription.d.ts +3 -2
  80. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  81. package/dist/tempo/server/Subscription.js.map +1 -1
  82. package/dist/tempo/server/index.d.ts +1 -1
  83. package/dist/tempo/server/index.d.ts.map +1 -1
  84. package/dist/tempo/server/index.js.map +1 -1
  85. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  86. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  87. package/dist/tempo/server/internal/html.gen.js +1 -1
  88. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  89. package/dist/tempo/session/Snapshot.d.ts +9 -0
  90. package/dist/tempo/session/Snapshot.d.ts.map +1 -1
  91. package/dist/tempo/session/Snapshot.js +6 -0
  92. package/dist/tempo/session/Snapshot.js.map +1 -1
  93. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  94. package/dist/tempo/session/client/ChannelOps.js +11 -2
  95. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  96. package/dist/tempo/session/client/CredentialState.d.ts +21 -1
  97. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  98. package/dist/tempo/session/client/CredentialState.js +66 -1
  99. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  100. package/dist/tempo/session/client/Session.d.ts +1 -1
  101. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  102. package/dist/tempo/session/client/Session.js +53 -1
  103. package/dist/tempo/session/client/Session.js.map +1 -1
  104. package/dist/tempo/session/client/SessionManager.d.ts +2 -2
  105. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  106. package/dist/tempo/session/client/SessionManager.js +130 -30
  107. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  108. package/dist/tempo/session/client/Transports.d.ts +21 -2
  109. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  110. package/dist/tempo/session/client/Transports.js +129 -18
  111. package/dist/tempo/session/client/Transports.js.map +1 -1
  112. package/dist/tempo/session/client/internal/SessionManager.d.ts +19 -0
  113. package/dist/tempo/session/client/internal/SessionManager.d.ts.map +1 -0
  114. package/dist/tempo/session/client/internal/SessionManager.js +13 -0
  115. package/dist/tempo/session/client/internal/SessionManager.js.map +1 -0
  116. package/dist/tempo/session/precompile/Chain.js +13 -4
  117. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  118. package/dist/tempo/session/server/CredentialVerification.d.ts +3 -0
  119. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  120. package/dist/tempo/session/server/CredentialVerification.js +14 -0
  121. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  122. package/dist/tempo/session/server/MeteredStream.d.ts +7 -1
  123. package/dist/tempo/session/server/MeteredStream.d.ts.map +1 -1
  124. package/dist/tempo/session/server/MeteredStream.js +5 -4
  125. package/dist/tempo/session/server/MeteredStream.js.map +1 -1
  126. package/dist/tempo/session/server/RequestState.d.ts +5 -1
  127. package/dist/tempo/session/server/RequestState.d.ts.map +1 -1
  128. package/dist/tempo/session/server/RequestState.js +10 -4
  129. package/dist/tempo/session/server/RequestState.js.map +1 -1
  130. package/dist/tempo/session/server/Session.d.ts +21 -6
  131. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  132. package/dist/tempo/session/server/Session.js +4 -1
  133. package/dist/tempo/session/server/Session.js.map +1 -1
  134. package/dist/tempo/session/server/Settlement.d.ts +20 -0
  135. package/dist/tempo/session/server/Settlement.d.ts.map +1 -1
  136. package/dist/tempo/session/server/Settlement.js +20 -0
  137. package/dist/tempo/session/server/Settlement.js.map +1 -1
  138. package/dist/tempo/session/server/Ws.d.ts +2 -0
  139. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  140. package/dist/tempo/session/server/Ws.js.map +1 -1
  141. package/dist/tempo/session/server/index.d.ts +1 -1
  142. package/dist/tempo/session/server/index.d.ts.map +1 -1
  143. package/dist/tempo/subscription/KeyAuthorization.d.ts +21 -21
  144. package/dist/validation/core.d.ts.map +1 -1
  145. package/dist/validation/core.js +6 -2
  146. package/dist/validation/core.js.map +1 -1
  147. package/dist/viem/Client.d.ts.map +1 -1
  148. package/dist/viem/Client.js +24 -17
  149. package/dist/viem/Client.js.map +1 -1
  150. package/package.json +6 -1
  151. package/src/cli/account.ts +45 -10
  152. package/src/cli/cli.test.ts +165 -70
  153. package/src/cli/cli.ts +67 -2
  154. package/src/cli/mcp.test.ts +11 -0
  155. package/src/cli/sessions/Manager.test.ts +249 -0
  156. package/src/cli/sessions/Manager.ts +93 -0
  157. package/src/cli/sessions/commands.ts +444 -0
  158. package/src/cli/sessions/request.test.ts +51 -0
  159. package/src/cli/sessions/request.ts +353 -0
  160. package/src/cli/sessions/store.test.ts +581 -0
  161. package/src/cli/sessions/store.ts +940 -0
  162. package/src/cli/utils.test.ts +23 -0
  163. package/src/cli/utils.ts +10 -0
  164. package/src/cli/validate/index.ts +12 -14
  165. package/src/cli/validate/messages.ts +7 -0
  166. package/src/cli/validate.test.ts +38 -5
  167. package/src/client/internal/Fetch.test.ts +29 -0
  168. package/src/client/internal/Fetch.ts +51 -6
  169. package/src/client/internal/MethodResponse.ts +39 -0
  170. package/src/client/node.test.ts +115 -0
  171. package/src/client/node.ts +247 -0
  172. package/src/internal/types.test-d.ts +21 -0
  173. package/src/internal/types.ts +33 -0
  174. package/src/server/Mppx.test-d.ts +42 -0
  175. package/src/stripe/Methods.test.ts +10 -0
  176. package/src/stripe/server/Charge.test-d.ts +66 -0
  177. package/src/stripe/server/Charge.ts +8 -5
  178. package/src/stripe/server/internal/html.gen.ts +1 -1
  179. package/src/tempo/internal/types.ts +13 -2
  180. package/src/tempo/legacy/client/ChannelOps.test.ts +21 -0
  181. package/src/tempo/legacy/client/ChannelOps.ts +7 -0
  182. package/src/tempo/legacy/client/Session.test.ts +102 -1
  183. package/src/tempo/legacy/client/Session.ts +10 -0
  184. package/src/tempo/legacy/server/Defaults.test-d.ts +9 -0
  185. package/src/tempo/legacy/server/Session.ts +6 -5
  186. package/src/tempo/server/Charge.ts +6 -4
  187. package/src/tempo/server/Methods.ts +5 -4
  188. package/src/tempo/server/Subscription.ts +6 -4
  189. package/src/tempo/server/index.ts +5 -1
  190. package/src/tempo/server/internal/html.gen.ts +1 -1
  191. package/src/tempo/session/README.md +201 -0
  192. package/src/tempo/session/Snapshot.ts +21 -0
  193. package/src/tempo/session/client/ChannelOps.test.ts +40 -0
  194. package/src/tempo/session/client/ChannelOps.ts +12 -2
  195. package/src/tempo/session/client/CredentialState.test.ts +2 -2
  196. package/src/tempo/session/client/CredentialState.ts +103 -1
  197. package/src/tempo/session/client/Session.test.ts +86 -4
  198. package/src/tempo/session/client/Session.ts +73 -1
  199. package/src/tempo/session/client/SessionManager.test.ts +137 -4
  200. package/src/tempo/session/client/SessionManager.ts +141 -33
  201. package/src/tempo/session/client/Transports.test.ts +225 -8
  202. package/src/tempo/session/client/Transports.ts +156 -21
  203. package/src/tempo/session/client/internal/SessionManager.ts +36 -0
  204. package/src/tempo/session/precompile/Chain.test.ts +26 -0
  205. package/src/tempo/session/precompile/Chain.ts +17 -4
  206. package/src/tempo/session/server/CredentialVerification.ts +18 -1
  207. package/src/tempo/session/server/MeteredStream.ts +12 -5
  208. package/src/tempo/session/server/RequestState.test.ts +39 -3
  209. package/src/tempo/session/server/RequestState.ts +13 -6
  210. package/src/tempo/session/server/Session.test.ts +461 -0
  211. package/src/tempo/session/server/Session.ts +31 -8
  212. package/src/tempo/session/server/Settlement.ts +45 -0
  213. package/src/tempo/session/server/Ws.test.ts +113 -0
  214. package/src/tempo/session/server/Ws.ts +2 -0
  215. package/src/tempo/session/server/index.ts +2 -0
  216. package/src/validation/core.ts +7 -1
  217. package/src/viem/Client.test.ts +44 -1
  218. package/src/viem/Client.ts +31 -17
@@ -3,12 +3,35 @@ import { afterEach, describe, expect, test, vi } from 'vp/test'
3
3
 
4
4
  import {
5
5
  fetchTokenInfo,
6
+ isAgentEnvironment,
6
7
  networkRpcUrls,
7
8
  resolveChain,
8
9
  resolveFundingNetwork,
9
10
  resolveRpcUrl,
10
11
  } from './utils.js'
11
12
 
13
+ describe('isAgentEnvironment', () => {
14
+ test('detects Claude Code', () => {
15
+ expect(isAgentEnvironment({ CLAUDECODE: '1' })).toBe(true)
16
+ })
17
+
18
+ test('detects Codex by exact env var', () => {
19
+ expect(isAgentEnvironment({ CODEX: '1' })).toBe(true)
20
+ })
21
+
22
+ test('detects Codex wrapper env vars', () => {
23
+ expect(isAgentEnvironment({ CODEX_CI: '1' })).toBe(true)
24
+ expect(isAgentEnvironment({ CODEX_SANDBOX: 'seatbelt' })).toBe(true)
25
+ expect(isAgentEnvironment({ CODEX_THREAD_ID: '019f6bc0-b80f-7061-ac1b-7af8ba1ea513' })).toBe(
26
+ true,
27
+ )
28
+ })
29
+
30
+ test('ignores empty and unrelated env vars', () => {
31
+ expect(isAgentEnvironment({ CODEX_CI: '', CI: '1' })).toBe(false)
32
+ })
33
+ })
34
+
12
35
  describe('resolveRpcUrl', () => {
13
36
  afterEach(() => {
14
37
  delete process.env.MPPX_RPC_URL
package/src/cli/utils.ts CHANGED
@@ -179,6 +179,16 @@ export function parseMethodOpts(raw: string | string[] | undefined): Record<stri
179
179
  return result
180
180
  }
181
181
 
182
+ /** Returns true when the CLI is running inside an agent wrapper that prefers machine-readable output. */
183
+ export function isAgentEnvironment(env: NodeJS.ProcessEnv = process.env): boolean {
184
+ if (env.CLAUDECODE) return true
185
+ for (const [key, value] of Object.entries(env)) {
186
+ if (!value) continue
187
+ if (key === 'CODEX' || key.startsWith('CODEX_')) return true
188
+ }
189
+ return false
190
+ }
191
+
182
192
  export function isTempoAccount(accountName: string): boolean {
183
193
  return accountName.startsWith('tempo:')
184
194
  }
@@ -1,9 +1,10 @@
1
1
  import { Cli, z } from 'incur'
2
2
 
3
3
  import { validate as validateCore, validateStream } from '../../validation/core.js'
4
- import { pc } from '../utils.js'
4
+ import { isAgentEnvironment, pc } from '../utils.js'
5
5
  import type { Counts } from './helpers.js'
6
6
  import { printResults, printSection } from './helpers.js'
7
+ import { missingDiscoverySuggestion } from './messages.js'
7
8
 
8
9
  const validate = Cli.create('validate', {
9
10
  description: 'Validate an MPP server implementation end-to-end',
@@ -22,7 +23,10 @@ const validate = Cli.create('validate', {
22
23
  header: z.array(z.string()).optional().describe('Request header (key:value, repeatable)'),
23
24
  verbose: z.number().default(0).meta({ count: true }).describe('Verbosity level'),
24
25
  yes: z.boolean().default(false).describe('Auto-approve mainnet payments'),
25
- outputJson: z.boolean().default(false).describe('Output results as JSON'),
26
+ outputJson: z
27
+ .boolean()
28
+ .default(false)
29
+ .describe('Output results as JSON (auto-enabled in known agent environments)'),
26
30
  }),
27
31
  alias: {
28
32
  endpoint: 'e',
@@ -32,8 +36,10 @@ const validate = Cli.create('validate', {
32
36
  outputJson: 'j',
33
37
  },
34
38
  async run(c) {
39
+ const outputJson = c.options.outputJson || isAgentEnvironment()
40
+
35
41
  // JSON mode: batch everything
36
- if (c.options.outputJson) {
42
+ if (outputJson) {
37
43
  const result = await validateCore({
38
44
  url: c.args.url,
39
45
  endpoint: c.options.endpoint,
@@ -82,17 +88,9 @@ const validate = Cli.create('validate', {
82
88
  discoveryFound = event.discovery.found
83
89
  if (!discoveryFound && !c.options.endpoint) {
84
90
  console.log('')
85
- console.log(pc.yellow(' No discovery document found.'))
86
- console.log(
87
- pc.dim(
88
- ' MPP servers must serve an OpenAPI document at /openapi.json with x-payment-info extensions.',
89
- ),
90
- )
91
- console.log(
92
- pc.dim(
93
- ' To test a specific endpoint: mppx validate <url> --endpoint POST:/your/path',
94
- ),
95
- )
91
+ const [headline, ...lines] = missingDiscoverySuggestion.split('\n')
92
+ console.log(pc.yellow(` ${headline}`))
93
+ for (const line of lines) console.log(line ? pc.dim(` ${line}`) : '')
96
94
  console.log('')
97
95
  process.exit(1)
98
96
  }
@@ -0,0 +1,7 @@
1
+ export const missingDiscoverySuggestion = [
2
+ 'No discovery document found, endpoints could not be automatically found.',
3
+ 'MPP servers must serve an OpenAPI document at /openapi.json with x-payment-info extensions.',
4
+ 'The MPP SDKs can automatically expose this endpoint. See https://mpp.dev/sdk.',
5
+ '',
6
+ 'To test a specific endpoint: mppx validate <url> --endpoint POST:/your/path',
7
+ ].join('\n')
@@ -6,6 +6,10 @@ import * as Http from '~test/Http.js'
6
6
  import * as Challenge from '../Challenge.js'
7
7
  import * as Constants from '../Constants.js'
8
8
  import * as Receipt from '../Receipt.js'
9
+ import { missingDiscoverySuggestion } from './validate/messages.js'
10
+
11
+ // Keep validate tests out of JSON mode by default, even when the suite runs inside an agent.
12
+ const isAgentEnvironmentMock = vi.fn(() => false)
9
13
 
10
14
  // Keep CLI orchestration tests independent of the public Tempo RPC and faucet.
11
15
  vi.doMock('viem/tempo', async () => {
@@ -28,11 +32,20 @@ vi.doMock('viem/actions', async () => {
28
32
  }
29
33
  })
30
34
 
35
+ vi.doMock('./utils.js', async () => {
36
+ const actual = await vi.importActual<typeof import('./utils.js')>('./utils.js')
37
+ return {
38
+ ...actual,
39
+ isAgentEnvironment: isAgentEnvironmentMock,
40
+ }
41
+ })
42
+
31
43
  // Auto-cleanup for test servers
32
44
  const servers: Http.TestServer[] = []
33
45
  afterEach(() => {
34
46
  servers.forEach((s) => s.close())
35
47
  servers.length = 0
48
+ isAgentEnvironmentMock.mockReturnValue(false)
36
49
  })
37
50
 
38
51
  async function testServer(handler: http.RequestListener) {
@@ -167,11 +180,8 @@ describe('validate: discovery', () => {
167
180
  })
168
181
  const { output, exitCode } = await serve(['validate', server.url])
169
182
  expect(exitCode).toBe(1)
170
- expect(output).toContain('No discovery document found.')
171
- expect(output).toContain('MPP servers must serve an OpenAPI document at /openapi.json')
172
- expect(output).toContain(
173
- 'To test a specific endpoint: mppx validate <url> --endpoint POST:/your/path',
174
- )
183
+ for (const line of missingDiscoverySuggestion.split('\n').filter(Boolean))
184
+ expect(output).toContain(line)
175
185
  })
176
186
 
177
187
  test('strips /openapi.json from input URL', { timeout: 15_000 }, async () => {
@@ -774,6 +784,19 @@ describe('validate: JSON mode', () => {
774
784
  expect(allPayment.every((r: { severity: string }) => r.severity === 'skip')).toBe(true)
775
785
  })
776
786
 
787
+ test(
788
+ 'auto-enables JSON output when an agent environment is detected',
789
+ { timeout: 15_000 },
790
+ async () => {
791
+ const server = await mppServer(mainnetChallenge())
792
+ isAgentEnvironmentMock.mockReturnValue(true)
793
+ const { output } = await serve(['validate', server.url])
794
+ const result = parseJson(output)
795
+ expect(result.summary.skipped).toBeGreaterThan(0)
796
+ expect(result.endpoints.length).toBeGreaterThan(0)
797
+ },
798
+ )
799
+
777
800
  test('no console leaks before JSON', { timeout: 15_000 }, async () => {
778
801
  const server = await mppServer(mainnetChallenge())
779
802
  const { output } = await serve(['validate', server.url, '--outputJson', '--yes'])
@@ -791,4 +814,14 @@ describe('validate: JSON mode', () => {
791
814
  const result = parseJson(output)
792
815
  expect(Array.isArray(result.suggestions)).toBe(true)
793
816
  })
817
+
818
+ test('includes missing discovery suggestions', { timeout: 15_000 }, async () => {
819
+ const server = await testServer((_req, res) => {
820
+ res.writeHead(404)
821
+ res.end()
822
+ })
823
+ const { output } = await serve(['validate', server.url, '--outputJson'])
824
+ const result = parseJson(output)
825
+ expect(result.suggestions).toEqual([missingDiscoverySuggestion])
826
+ })
794
827
  })
@@ -10,6 +10,7 @@ import { rpcUrl } from '~test/tempo/rpc.js'
10
10
  import { accounts, asset, chain, client, http } from '~test/tempo/viem.js'
11
11
 
12
12
  import * as Fetch from './Fetch.js'
13
+ import * as MethodResponse from './MethodResponse.js'
13
14
 
14
15
  const realm = 'api.example.com'
15
16
  const secretKey = 'test-secret-key-test-secret-key-32'
@@ -391,6 +392,34 @@ function encodeRawPaymentRequiredHeader(value: unknown): string {
391
392
  return Base64.fromString(JSON.stringify(value))
392
393
  }
393
394
 
395
+ describe('Fetch.from: method responses', () => {
396
+ test('handles successful paid responses without touching free responses', async () => {
397
+ const method = { ...noopMethod }
398
+ const handle = vi.fn(
399
+ async ({ response }: MethodResponse.HandlerParameters) =>
400
+ new Response(`handled:${await response.text()}`, response),
401
+ )
402
+ MethodResponse.register(method, handle)
403
+
404
+ let paidResponse: Response | undefined
405
+ const mockFetch: typeof globalThis.fetch = async (input, init) => {
406
+ if (input.toString().endsWith('/free')) return new Response('free')
407
+ if (!new Headers(init?.headers).has('Authorization')) return make402()
408
+ paidResponse = new Response('paid')
409
+ vi.spyOn(paidResponse, 'clone')
410
+ return paidResponse
411
+ }
412
+ const fetch = Fetch.from({ fetch: mockFetch, methods: [method] })
413
+
414
+ expect(await (await fetch('https://example.com/free')).text()).toBe('free')
415
+ expect(handle).not.toHaveBeenCalled()
416
+ expect(await (await fetch('https://example.com/paid')).text()).toBe('handled:paid')
417
+ expect(handle).toHaveBeenCalledOnce()
418
+ expect(handle).toHaveBeenCalledWith(expect.objectContaining({ credential: 'credential' }))
419
+ expect(paidResponse?.clone).not.toHaveBeenCalled()
420
+ })
421
+ })
422
+
394
423
  describe('Fetch.from: init passthrough (non-402)', () => {
395
424
  test('preserves init object identity while adding Accept-Payment', async () => {
396
425
  const receivedInits: (RequestInit | undefined)[] = []
@@ -6,6 +6,7 @@ import type { MaybePromise } from '../../internal/types.js'
6
6
  import type * as Method from '../../Method.js'
7
7
  import type * as z from '../../zod.js'
8
8
  import * as Transport from '../Transport.js'
9
+ import * as MethodResponse from './MethodResponse.js'
9
10
 
10
11
  // We tag wrappers with a global symbol so we can recognize wrappers created by mppx,
11
12
  // even across multiple module instances/bundles. This lets restore() avoid clobbering
@@ -18,6 +19,7 @@ type WrappedFetch = typeof globalThis.fetch & {
18
19
  }
19
20
 
20
21
  let originalFetch: typeof globalThis.fetch | undefined
22
+ const eventObserverChecks = new WeakMap<object, (name: keyof ClientEventMap) => boolean>()
21
23
 
22
24
  export type ClientEventMap<
23
25
  methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
@@ -174,7 +176,11 @@ export function from<const methods extends readonly Method.AnyClient[]>(
174
176
  // which can duplicate retries and make restore semantics fragile.
175
177
  const baseFetch = unwrapFetch(fetch)
176
178
 
177
- const wrappedFetch = async (input: RequestInfo | URL, init?: from.RequestInit<methods>) => {
179
+ const request = async (
180
+ input: RequestInfo | URL,
181
+ init: from.RequestInit<methods> | undefined,
182
+ canRefetch: boolean,
183
+ ): Promise<Response> => {
178
184
  const callerHeaders = getCallerHeaders(input, init?.headers)
179
185
  const hasExplicitAcceptPayment = callerHeaders.has(Constants.Headers.acceptPayment)
180
186
  const paymentPreferences = resolvePaymentPreferences(callerHeaders, resolvedAcceptPayment)
@@ -272,8 +278,13 @@ export function from<const methods extends readonly Method.AnyClient[]>(
272
278
  }),
273
279
  )
274
280
 
281
+ const paymentInput = resolvePaymentRetryInput(
282
+ response,
283
+ initialRequest.input,
284
+ initialRequest.input,
285
+ )
275
286
  response = await baseFetch(
276
- resolvePaymentRetryInput(response, initialRequest.input, initialRequest.input),
287
+ paymentInput,
277
288
  transport.setCredential(
278
289
  {
279
290
  ...fetchInit,
@@ -283,7 +294,8 @@ export function from<const methods extends readonly Method.AnyClient[]>(
283
294
  { challenge: selectedChallenge },
284
295
  ),
285
296
  )
286
- if (response.ok)
297
+ // Cloning an unobserved stream would tee and buffer it indefinitely.
298
+ if (response.ok && hasEventObservers(events, 'payment.response'))
287
299
  await events.emit(
288
300
  'payment.response',
289
301
  createPaymentResponsePayload({
@@ -295,8 +307,23 @@ export function from<const methods extends readonly Method.AnyClient[]>(
295
307
  response,
296
308
  }),
297
309
  )
298
- if (!(await transport.isPaymentRequired(response, transportRequest as never)))
299
- return response
310
+ if (!(await transport.isPaymentRequired(response, transportRequest as never))) {
311
+ if (!response.ok) return response
312
+ return MethodResponse.handle(selected.method, {
313
+ challenge: selectedChallenge,
314
+ credential,
315
+ fetch: baseFetch,
316
+ headers: initialRequest.headers,
317
+ input: paymentInput,
318
+ ...(canRefetch
319
+ ? {
320
+ refetch: () => request(cloneRequestInput(initialRequest.input), init, false),
321
+ }
322
+ : {}),
323
+ response,
324
+ signal: resolveRequestSignal(input, init),
325
+ })
326
+ }
300
327
  }
301
328
  return response
302
329
  } catch (error) {
@@ -315,6 +342,8 @@ export function from<const methods extends readonly Method.AnyClient[]>(
315
342
  throw error
316
343
  }
317
344
  }
345
+ const wrappedFetch = (input: RequestInfo | URL, init?: from.RequestInit<methods>) =>
346
+ request(input, init, true)
318
347
 
319
348
  // Record the wrapped target so future polyfill() / restore() calls can detect origin
320
349
  // and safely unwrap only mppx-installed wrappers.
@@ -491,7 +520,7 @@ export function createEventDispatcher<
491
520
  }
492
521
  }
493
522
 
494
- return {
523
+ const dispatcher: ClientEventDispatcher<methods, response> = {
495
524
  async emit(name, payload) {
496
525
  switch (name) {
497
526
  case 'challenge.received': {
@@ -525,6 +554,15 @@ export function createEventDispatcher<
525
554
  },
526
555
  on,
527
556
  }
557
+ eventObserverChecks.set(
558
+ dispatcher,
559
+ (name) => handlers[name as keyof typeof handlers].size > 0 || handlers['*'].size > 0,
560
+ )
561
+ return dispatcher
562
+ }
563
+
564
+ function hasEventObservers(dispatcher: ClientEventDispatcher, name: keyof ClientEventMap): boolean {
565
+ return eventObserverChecks.get(dispatcher)?.(name) ?? true
528
566
  }
529
567
 
530
568
  async function emitChallengeReceived<methods extends readonly Method.AnyClient[], response>(
@@ -812,6 +850,13 @@ function cloneRequestInput(input: RequestInfo | URL): RequestInfo | URL {
812
850
  }
813
851
  }
814
852
 
853
+ function resolveRequestSignal(
854
+ input: RequestInfo | URL,
855
+ init: RequestInit | undefined,
856
+ ): AbortSignal | undefined {
857
+ return init?.signal ?? (input instanceof Request ? input.signal : undefined)
858
+ }
859
+
815
860
  /** @internal */
816
861
  function isWrappedFetch(fetch: typeof globalThis.fetch): fetch is WrappedFetch {
817
862
  return Boolean((fetch as WrappedFetch)[MPPX_FETCH_WRAPPER])
@@ -0,0 +1,39 @@
1
+ import type * as Challenge from '../../Challenge.js'
2
+ import type { MaybePromise } from '../../internal/types.js'
3
+ import type * as Method from '../../Method.js'
4
+
5
+ const handlers = new WeakMap<Method.AnyClient, Handler>()
6
+
7
+ /** Inputs available when a client method handles a successful paid response. */
8
+ export type HandlerParameters = {
9
+ challenge: Challenge.Challenge
10
+ credential: string
11
+ fetch: typeof globalThis.fetch
12
+ headers: Headers
13
+ input: RequestInfo | URL
14
+ refetch?: (() => Promise<Response>) | undefined
15
+ response: Response
16
+ signal?: AbortSignal | undefined
17
+ }
18
+
19
+ /** Internal client-method response adapter. */
20
+ export type Handler = (parameters: HandlerParameters) => MaybePromise<Response>
21
+
22
+ /** Registers an internal response adapter without changing the public method shape. */
23
+ export function register<const method extends Method.AnyClient>(
24
+ method: method,
25
+ handler: Handler,
26
+ ): method {
27
+ handlers.set(method, handler)
28
+ return method
29
+ }
30
+
31
+ /** Removes response handling from a method whose caller owns the response lifecycle. */
32
+ export function unregister(method: Method.AnyClient): void {
33
+ handlers.delete(method)
34
+ }
35
+
36
+ /** Lets the selected client method handle a successful paid response. */
37
+ export function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<Response> {
38
+ return Promise.resolve(handlers.get(method)?.(parameters) ?? parameters.response)
39
+ }
@@ -0,0 +1,115 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import { homedir, tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { DatabaseSync } from 'node:sqlite'
5
+
6
+ import { describe, expect, test } from 'vp/test'
7
+
8
+ import type { ChannelEntry } from '../tempo/session/client/ChannelOps.js'
9
+ import { entryKey } from '../tempo/session/client/ChannelStore.js'
10
+ import { createSqliteChannelStore, defaultChannelDatabasePath } from './node.js'
11
+
12
+ const channel: ChannelEntry = {
13
+ channelId: `0x${'11'.repeat(32)}`,
14
+ cumulativeAmount: 2_000_000n,
15
+ deposit: 10_000_000n,
16
+ descriptor: {
17
+ authorizedSigner: '0x0000000000000000000000000000000000000001',
18
+ expiringNonceHash: `0x${'22'.repeat(32)}`,
19
+ operator: '0x0000000000000000000000000000000000000000',
20
+ payee: '0x0000000000000000000000000000000000000002',
21
+ payer: '0x0000000000000000000000000000000000000003',
22
+ salt: `0x${'33'.repeat(32)}`,
23
+ token: '0x0000000000000000000000000000000000000004',
24
+ },
25
+ escrow: '0x0000000000000000000000000000000000000005',
26
+ chainId: 4217,
27
+ opened: true,
28
+ }
29
+
30
+ describe('SQLite ChannelStore', () => {
31
+ test('defaults to the shared Tempo wallet channels database', () => {
32
+ expect(defaultChannelDatabasePath()).toBe(join(homedir(), '.tempo', 'wallet', 'channels.db'))
33
+ })
34
+
35
+ test('persists a namespaced channel across fresh client instances', () => {
36
+ const directory = mkdtempSync(join(tmpdir(), 'mppx-channels-'))
37
+ const path = join(directory, 'channels.db')
38
+ try {
39
+ const first = createSqliteChannelStore({ namespace: 'https://api.example.com', path })
40
+ first.set(channel)
41
+ first.close()
42
+
43
+ const second = createSqliteChannelStore({ namespace: 'https://api.example.com', path })
44
+ expect(second.get(entryKey(channel))).toEqual(channel)
45
+ second.set({ ...channel, cumulativeAmount: 1_000_000n, deposit: 5_000_000n })
46
+ expect(second.get(entryKey(channel))).toMatchObject({
47
+ cumulativeAmount: 2_000_000n,
48
+ deposit: 10_000_000n,
49
+ })
50
+ second.close()
51
+ } finally {
52
+ rmSync(directory, { recursive: true })
53
+ }
54
+ })
55
+
56
+ test('rehydrates an existing wallet-cli v2 channel row', () => {
57
+ const directory = mkdtempSync(join(tmpdir(), 'mppx-wallet-channels-'))
58
+ const path = join(directory, 'channels.db')
59
+ try {
60
+ const database = new DatabaseSync(path)
61
+ database.exec(`CREATE TABLE channels (
62
+ channel_id TEXT PRIMARY KEY,
63
+ version INTEGER NOT NULL DEFAULT 1,
64
+ origin TEXT NOT NULL,
65
+ request_url TEXT NOT NULL DEFAULT '',
66
+ chain_id INTEGER NOT NULL,
67
+ escrow_contract TEXT NOT NULL,
68
+ token TEXT NOT NULL,
69
+ payee TEXT NOT NULL,
70
+ payer TEXT NOT NULL,
71
+ authorized_signer TEXT NOT NULL,
72
+ salt TEXT NOT NULL,
73
+ deposit TEXT NOT NULL,
74
+ cumulative_amount TEXT NOT NULL,
75
+ challenge_echo TEXT NOT NULL,
76
+ state TEXT NOT NULL DEFAULT 'active',
77
+ close_requested_at INTEGER NOT NULL DEFAULT 0,
78
+ grace_ready_at INTEGER NOT NULL DEFAULT 0,
79
+ created_at INTEGER NOT NULL,
80
+ last_used_at INTEGER NOT NULL,
81
+ accepted_cumulative TEXT NOT NULL DEFAULT '0',
82
+ server_spent TEXT NOT NULL DEFAULT '0',
83
+ session_protocol TEXT NOT NULL DEFAULT 'v1',
84
+ descriptor_json TEXT
85
+ )`)
86
+ database
87
+ .prepare(`INSERT INTO channels (
88
+ channel_id, origin, chain_id, escrow_contract, token, payee, payer,
89
+ authorized_signer, salt, deposit, cumulative_amount, challenge_echo,
90
+ state, created_at, last_used_at, session_protocol, descriptor_json
91
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', 'active', 1, 1, 'v2', ?)`)
92
+ .run(
93
+ channel.channelId,
94
+ 'https://api.example.com',
95
+ channel.chainId,
96
+ channel.escrow,
97
+ channel.descriptor.token,
98
+ channel.descriptor.payee,
99
+ channel.descriptor.payer,
100
+ channel.descriptor.authorizedSigner,
101
+ channel.descriptor.salt,
102
+ channel.deposit.toString(),
103
+ channel.cumulativeAmount.toString(),
104
+ JSON.stringify(channel.descriptor),
105
+ )
106
+ database.close()
107
+
108
+ const store = createSqliteChannelStore({ namespace: 'https://api.example.com', path })
109
+ expect(store.get(entryKey(channel))).toEqual(channel)
110
+ store.close()
111
+ } finally {
112
+ rmSync(directory, { recursive: true })
113
+ }
114
+ })
115
+ })