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
@@ -0,0 +1,581 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+
5
+ import type { Address, Hex } from 'viem'
6
+
7
+ vi.mock('node:fs/promises', async (importOriginal) => ({
8
+ ...(await importOriginal<typeof import('node:fs/promises')>()),
9
+ }))
10
+
11
+ import type * as Challenge from '../../Challenge.js'
12
+ import type { ChannelEntry } from '../../tempo/session/client/ChannelOps.js'
13
+ import { entryKey } from '../../tempo/session/client/ChannelStore.js'
14
+ import type { SessionReceipt } from '../../tempo/session/precompile/Protocol.js'
15
+ import sessions from './commands.js'
16
+ import {
17
+ createSessionRegistry,
18
+ SessionBusyError,
19
+ SessionStateError,
20
+ sessionScopeKey,
21
+ toChannelStore,
22
+ type SessionPersistenceContext,
23
+ type SessionRegistry,
24
+ type SessionScope,
25
+ } from './store.js'
26
+
27
+ const payer = '0x1111111111111111111111111111111111111111' as Address
28
+ const payee = '0x2222222222222222222222222222222222222222' as Address
29
+ const token = '0x3333333333333333333333333333333333333333' as Address
30
+ const escrow = '0x4444444444444444444444444444444444444444' as Address
31
+ const operator = '0x0000000000000000000000000000000000000000' as Address
32
+ const channelId = `0x${'aa'.repeat(32)}` as Hex
33
+ const mainnetChannelId = `0x${'bb'.repeat(32)}` as Hex
34
+
35
+ let temporaryDirectory: string
36
+ let stateRoot: string
37
+
38
+ beforeEach(async () => {
39
+ temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'mppx-sessions-'))
40
+ vi.stubEnv('XDG_STATE_HOME', temporaryDirectory)
41
+ stateRoot = path.join(temporaryDirectory, 'mppx', 'sessions', 'v1')
42
+ })
43
+
44
+ afterEach(async () => {
45
+ vi.unstubAllEnvs()
46
+ await fs.rm(temporaryDirectory, { force: true, recursive: true })
47
+ })
48
+
49
+ function channel(overrides: Partial<ChannelEntry> = {}): ChannelEntry {
50
+ return {
51
+ channelId,
52
+ cumulativeAmount: 10n,
53
+ deposit: 100n,
54
+ descriptor: {
55
+ payer,
56
+ payee,
57
+ operator,
58
+ token,
59
+ salt: `0x${'55'.repeat(32)}`,
60
+ authorizedSigner: payer,
61
+ expiringNonceHash: `0x${'66'.repeat(32)}`,
62
+ },
63
+ escrow,
64
+ chainId: 42431,
65
+ opened: true,
66
+ ...overrides,
67
+ }
68
+ }
69
+
70
+ function challenge(id = 'challenge-1', chainId = 42431): Challenge.Challenge {
71
+ return {
72
+ id,
73
+ realm: 'api.example.test',
74
+ method: 'tempo',
75
+ intent: 'session',
76
+ request: {
77
+ amount: '1',
78
+ currency: token,
79
+ recipient: payee,
80
+ methodDetails: { chainId, escrowContract: escrow },
81
+ },
82
+ }
83
+ }
84
+
85
+ function receipt(overrides: Partial<SessionReceipt> = {}): SessionReceipt {
86
+ return {
87
+ method: 'tempo',
88
+ intent: 'session',
89
+ status: 'success',
90
+ timestamp: '2026-07-16T00:01:00.000Z',
91
+ reference: channelId,
92
+ challengeId: 'challenge-1',
93
+ channelId,
94
+ acceptedCumulative: '10',
95
+ spent: '2',
96
+ units: 3,
97
+ ...overrides,
98
+ }
99
+ }
100
+
101
+ function scope(overrides: Partial<SessionScope> = {}): SessionScope {
102
+ return { payer, payee, token, escrow, chainId: 42431, ...overrides }
103
+ }
104
+
105
+ function registryOptions(
106
+ overrides: Parameters<typeof createSessionRegistry>[0] = {},
107
+ ): Parameters<typeof createSessionRegistry>[0] {
108
+ return { stateRoot, ...overrides }
109
+ }
110
+
111
+ async function serveSessions(argv: string[]) {
112
+ let output = ''
113
+ let exitCode: number | undefined
114
+ await sessions.serve(argv, {
115
+ stdout(value: string) {
116
+ output += value
117
+ },
118
+ exit(code: number) {
119
+ exitCode = code
120
+ },
121
+ })
122
+ return { exitCode, output }
123
+ }
124
+
125
+ async function seedCommandSessions(registry: SessionRegistry) {
126
+ await registry.upsert({
127
+ status: 'open',
128
+ channel: channel({
129
+ cumulativeAmount: 9_007_199_254_740_993_123_456_789n,
130
+ deposit: 9_999_999_999_999_999_999_999_999n,
131
+ }),
132
+ account: { name: 'testnet-payer', address: payer },
133
+ endpoint: 'https://api.example.test/query?chainId=testnet&sql=select%201',
134
+ challenge: challenge('testnet-challenge'),
135
+ spent: 1_234_567_890_123_456_789n,
136
+ units: 7,
137
+ })
138
+ await registry.upsert({
139
+ status: 'closing',
140
+ channel: channel({ channelId: mainnetChannelId, chainId: 4217, cumulativeAmount: 20n }),
141
+ account: { name: 'mainnet-payer', address: payer },
142
+ endpoint: 'https://api.example.test/query?chainId=mainnet',
143
+ challenge: challenge('mainnet-challenge', 4217),
144
+ spent: 5n,
145
+ units: 2,
146
+ })
147
+ }
148
+
149
+ function commandRegistry() {
150
+ const timestamps = [new Date('2026-07-16T00:00:00.000Z'), new Date('2026-07-16T00:01:00.000Z')]
151
+ return createSessionRegistry(registryOptions({ now: () => timestamps.shift()! }))
152
+ }
153
+
154
+ describe('createSessionRegistry', () => {
155
+ test('uses XDG_STATE_HOME for the versioned state root', () => {
156
+ const registry = createSessionRegistry()
157
+ expect(registry.root).toBe(stateRoot)
158
+ })
159
+
160
+ test('persists sanitized state atomically with private permissions', async () => {
161
+ const times = [new Date('2026-07-16T00:00:00.000Z'), new Date('2026-07-15T00:00:00.000Z')]
162
+ const registry = createSessionRegistry(
163
+ registryOptions({ now: () => times.shift() ?? new Date('2026-07-17T00:00:00.000Z') }),
164
+ )
165
+ const firstChannel = Object.assign(channel(), { privateKey: 'do-not-store' })
166
+ const firstAccount = Object.assign(
167
+ { name: 'testnet', address: payer },
168
+ { privateKey: 'do-not-store' },
169
+ )
170
+
171
+ const first = await registry.upsert({
172
+ status: 'opening',
173
+ channel: firstChannel,
174
+ account: firstAccount,
175
+ endpoint: 'https://api.example.test/query?chainId=testnet&sql=select%201#response',
176
+ challenge: challenge(),
177
+ receipt: receipt(),
178
+ spent: 2n,
179
+ units: 3,
180
+ })
181
+ const second = await registry.upsert({
182
+ status: 'open',
183
+ channel: channel({ cumulativeAmount: 8n }),
184
+ account: { address: payer },
185
+ endpoint: 'https://api.example.test/query?chainId=testnet&sql=select%201#ignored',
186
+ challenge: challenge('challenge-2'),
187
+ spent: 1n,
188
+ units: 1,
189
+ })
190
+
191
+ expect(first.endpoint).toBe('https://api.example.test/query?chainId=testnet&sql=select%201')
192
+ expect(second).toMatchObject({
193
+ status: 'open',
194
+ account: { name: 'testnet', address: payer },
195
+ spent: 2n,
196
+ units: 3,
197
+ createdAt: '2026-07-16T00:00:00.000Z',
198
+ updatedAt: '2026-07-16T00:00:00.000Z',
199
+ })
200
+ expect(second.channel.cumulativeAmount).toBe(10n)
201
+ expect(second.receipt).toEqual(receipt())
202
+
203
+ const file = path.join(stateRoot, 'channels', `${channelId}.json`)
204
+ const source = await fs.readFile(file, 'utf8')
205
+ const stored: unknown = JSON.parse(source)
206
+ expect(stored).toMatchObject({
207
+ version: 1,
208
+ method: 'tempo',
209
+ intent: 'session',
210
+ channel: { cumulativeAmount: '10', deposit: '100' },
211
+ spent: '2',
212
+ units: 3,
213
+ })
214
+ expect(source).not.toContain('privateKey')
215
+ expect((await fs.stat(stateRoot)).mode & 0o777).toBe(0o700)
216
+ expect((await fs.stat(path.join(stateRoot, 'channels'))).mode & 0o777).toBe(0o700)
217
+ expect((await fs.stat(file)).mode & 0o777).toBe(0o600)
218
+ expect(
219
+ (await fs.readdir(path.join(stateRoot, 'channels'))).filter((name) => name.includes('.tmp-')),
220
+ ).toEqual([])
221
+ })
222
+
223
+ test('manages preferred sessions and removes mappings before records', async () => {
224
+ const registry = createSessionRegistry(registryOptions())
225
+ await registry.upsert({
226
+ status: 'open',
227
+ channel: channel(),
228
+ account: { name: 'testnet', address: payer },
229
+ endpoint: 'https://api.example.test/query?chainId=testnet',
230
+ challenge: challenge(),
231
+ })
232
+
233
+ await registry.setPreferred(scope(), channelId)
234
+ expect(await registry.getPreferred(scope())).toBe(channelId)
235
+ await registry.clearPreferred(scope(), `0x${'bb'.repeat(32)}`)
236
+ expect(await registry.getPreferred(scope())).toBe(channelId)
237
+ expect(await registry.list()).toEqual([
238
+ expect.objectContaining({ channel: expect.objectContaining({ channelId }) }),
239
+ ])
240
+ await expect(
241
+ registry.setPreferred(
242
+ scope({ payee: '0x7777777777777777777777777777777777777777' }),
243
+ channelId,
244
+ ),
245
+ ).rejects.toBeInstanceOf(SessionStateError)
246
+
247
+ await registry.remove(channelId)
248
+ expect(await registry.get(channelId)).toBeUndefined()
249
+ expect(await registry.getPreferred(scope())).toBeUndefined()
250
+ })
251
+
252
+ test('does not overwrite a corrupt managed record', async () => {
253
+ const file = path.join(stateRoot, 'channels', `${channelId}.json`)
254
+ await fs.mkdir(path.dirname(file), { recursive: true })
255
+ await fs.writeFile(file, '{invalid json')
256
+
257
+ await expect(createSessionRegistry(registryOptions()).get(channelId)).rejects.toMatchObject({
258
+ code: 'SESSION_STATE_INVALID',
259
+ file,
260
+ })
261
+ await expect(
262
+ createSessionRegistry(registryOptions()).upsert({
263
+ status: 'open',
264
+ channel: channel(),
265
+ account: { address: payer },
266
+ endpoint: 'https://api.example.test/query',
267
+ challenge: challenge(),
268
+ }),
269
+ ).rejects.toBeInstanceOf(SessionStateError)
270
+ expect(await fs.readFile(file, 'utf8')).toBe('{invalid json')
271
+ })
272
+
273
+ test('rejects live and remote locks, then reclaims a dead same-host lock', async () => {
274
+ const first = createSessionRegistry(
275
+ registryOptions({ hostname: 'host-a', pid: 101, isProcessAlive: () => true }),
276
+ )
277
+ const live = await first.acquire(scope())
278
+ const contender = createSessionRegistry(
279
+ registryOptions({ hostname: 'host-a', pid: 202, isProcessAlive: (pid) => pid === 101 }),
280
+ )
281
+
282
+ await expect(contender.acquire(scope())).rejects.toMatchObject({
283
+ code: 'SESSION_BUSY',
284
+ exitCode: 75,
285
+ scope: sessionScopeKey(scope()),
286
+ owner: { hostname: 'host-a', pid: 101 },
287
+ })
288
+ await live.release()
289
+
290
+ const deadOwner = createSessionRegistry(
291
+ registryOptions({ hostname: 'host-a', pid: 303, isProcessAlive: () => true }),
292
+ )
293
+ const abandoned = await deadOwner.acquire(scope())
294
+ const reclaimer = createSessionRegistry(
295
+ registryOptions({ hostname: 'host-a', pid: 404, isProcessAlive: () => false }),
296
+ )
297
+ const reclaimed = await reclaimer.acquire(scope())
298
+ await abandoned.release()
299
+ const observer = createSessionRegistry(
300
+ registryOptions({ hostname: 'host-a', pid: 405, isProcessAlive: () => true }),
301
+ )
302
+ await expect(observer.acquire(scope())).rejects.toBeInstanceOf(SessionBusyError)
303
+ await reclaimed.release()
304
+
305
+ const remoteOwner = createSessionRegistry(
306
+ registryOptions({ hostname: 'host-b', pid: 505, isProcessAlive: () => true }),
307
+ )
308
+ const remote = await remoteOwner.acquire(scope())
309
+ await expect(reclaimer.acquire(scope())).rejects.toMatchObject({
310
+ code: 'SESSION_BUSY',
311
+ owner: { hostname: 'host-b', pid: 505 },
312
+ })
313
+ await remote.release()
314
+ })
315
+
316
+ test('allows only one concurrent dead-lock reclaimer to acquire', async () => {
317
+ const deadOwner = createSessionRegistry(
318
+ registryOptions({ hostname: 'host-a', pid: 101, isProcessAlive: () => true }),
319
+ )
320
+ await deadOwner.acquire(scope())
321
+ const [lockName] = await fs.readdir(path.join(stateRoot, 'locks'))
322
+ if (!lockName) throw new Error('Expected a lock file.')
323
+ const lock = path.join(stateRoot, 'locks', lockName)
324
+ const stale = JSON.parse(await fs.readFile(lock, 'utf8')) as { token: string }
325
+
326
+ let unsafeUnlinks = 0
327
+ let releaseFirstUnlink: (() => void) | undefined
328
+ const secondUnlink = new Promise<void>((resolve) => {
329
+ releaseFirstUnlink = resolve
330
+ })
331
+ const { unlink } = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises')
332
+ const unlinkSpy = vi.spyOn(fs, 'unlink').mockImplementation(async (file) => {
333
+ if (String(file) !== lock) return unlink(file)
334
+ try {
335
+ await fs.access(`${lock}.reclaim`)
336
+ return unlink(file)
337
+ } catch (error) {
338
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
339
+ }
340
+ unsafeUnlinks++
341
+ if (unsafeUnlinks === 1) {
342
+ await secondUnlink
343
+ return unlink(file)
344
+ }
345
+ if (unsafeUnlinks === 2) {
346
+ releaseFirstUnlink?.()
347
+ await vi.waitUntil(
348
+ async () => {
349
+ try {
350
+ const current = JSON.parse(await fs.readFile(lock, 'utf8')) as { token: string }
351
+ return current.token !== stale.token
352
+ } catch {
353
+ return false
354
+ }
355
+ },
356
+ { interval: 1, timeout: 1_000 },
357
+ )
358
+ }
359
+ return unlink(file)
360
+ })
361
+
362
+ const isProcessAlive = (pid: number) => pid === 202 || pid === 303
363
+ const first = createSessionRegistry(
364
+ registryOptions({ hostname: 'host-a', pid: 202, isProcessAlive }),
365
+ )
366
+ const second = createSessionRegistry(
367
+ registryOptions({ hostname: 'host-a', pid: 303, isProcessAlive }),
368
+ )
369
+ try {
370
+ const results = await Promise.allSettled([first.acquire(scope()), second.acquire(scope())])
371
+ unlinkSpy.mockRestore()
372
+ const acquired = results.filter(
373
+ (result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof first.acquire>>> =>
374
+ result.status === 'fulfilled',
375
+ )
376
+ const rejected = results.filter(
377
+ (result): result is PromiseRejectedResult => result.status === 'rejected',
378
+ )
379
+ expect(acquired).toHaveLength(1)
380
+ expect(rejected).toHaveLength(1)
381
+ expect(rejected[0]?.reason).toBeInstanceOf(SessionBusyError)
382
+ await acquired[0]?.value.release()
383
+ } finally {
384
+ unlinkSpy.mockRestore()
385
+ }
386
+ })
387
+ })
388
+
389
+ describe('toChannelStore', () => {
390
+ test('uses preferred open sessions and never reuses opening sessions', async () => {
391
+ const registry = createSessionRegistry(registryOptions())
392
+ let status: SessionPersistenceContext['status'] = 'opening'
393
+ const context = (): SessionPersistenceContext => ({
394
+ status,
395
+ account: { name: 'testnet', address: payer },
396
+ endpoint: 'https://api.example.test/query?chainId=testnet',
397
+ challenge: challenge(),
398
+ })
399
+ const store = toChannelStore(registry, { scope: scope(), selection: 'new', context })
400
+
401
+ expect(await store.get(entryKey(channel()))).toBeUndefined()
402
+ await store.set(channel())
403
+ expect((await registry.get(channelId))?.status).toBe('opening')
404
+ expect(await registry.getPreferred(scope())).toBe(channelId)
405
+ expect(await store.get(entryKey(channel()))).toBeUndefined()
406
+
407
+ await store.delete(entryKey(channel()))
408
+ expect((await registry.get(channelId))?.status).toBe('stale')
409
+ expect(await registry.getPreferred(scope())).toBeUndefined()
410
+
411
+ status = 'open'
412
+ const reopened = toChannelStore(registry, { scope: scope(), selection: 'new', context })
413
+ await reopened.set(channel())
414
+ const automatic = toChannelStore(registry, { scope: scope(), selection: 'auto', context })
415
+ expect(await automatic.get(entryKey(channel()))).toEqual(channel())
416
+ })
417
+
418
+ test('retains closing state when the manager deletes after creating a close credential', async () => {
419
+ const registry = createSessionRegistry(registryOptions())
420
+ let status: SessionPersistenceContext['status'] = 'open'
421
+ const context = (): SessionPersistenceContext => ({
422
+ status,
423
+ account: { address: payer },
424
+ endpoint: 'https://api.example.test/query?chainId=testnet',
425
+ challenge: challenge(),
426
+ spent: 4n,
427
+ units: 5,
428
+ })
429
+ const store = toChannelStore(registry, { scope: scope(), selection: 'new', context })
430
+ await store.set(channel())
431
+ status = 'closing'
432
+
433
+ await store.delete(entryKey(channel()))
434
+
435
+ expect(await registry.get(channelId)).toMatchObject({
436
+ status: 'closing',
437
+ spent: 4n,
438
+ units: 5,
439
+ })
440
+ expect(await registry.getPreferred(scope())).toBe(channelId)
441
+ })
442
+
443
+ test('resets persistence context when a rejected channel is replaced', async () => {
444
+ const registry = createSessionRegistry(registryOptions())
445
+ await registry.upsert({
446
+ status: 'open',
447
+ channel: channel(),
448
+ account: { address: payer },
449
+ endpoint: 'https://api.example.test/query?chainId=testnet',
450
+ challenge: challenge(),
451
+ receipt: receipt(),
452
+ spent: 2n,
453
+ units: 3,
454
+ })
455
+ await registry.setPreferred(scope(), channelId)
456
+
457
+ let status: SessionPersistenceContext['status'] = 'open'
458
+ let latestReceipt: SessionReceipt | undefined = receipt()
459
+ let spent = 2n
460
+ let units = 3
461
+ const context = (): SessionPersistenceContext => ({
462
+ status,
463
+ account: { address: payer },
464
+ endpoint: 'https://api.example.test/query?chainId=testnet',
465
+ challenge: challenge(),
466
+ ...(latestReceipt && { receipt: latestReceipt }),
467
+ spent,
468
+ units,
469
+ })
470
+ const store = toChannelStore(registry, {
471
+ scope: scope(),
472
+ selection: 'auto',
473
+ context,
474
+ onNewChannel() {
475
+ status = 'opening'
476
+ latestReceipt = undefined
477
+ spent = 0n
478
+ units = 0
479
+ },
480
+ })
481
+ expect(await store.get(entryKey(channel()))).toEqual(channel())
482
+ await store.delete(entryKey(channel()))
483
+
484
+ const replacementId = `0x${'bb'.repeat(32)}` as Hex
485
+ await store.set(channel({ channelId: replacementId }))
486
+
487
+ expect(await registry.get(channelId)).toMatchObject({ status: 'stale', spent: 2n, units: 3 })
488
+ expect(await registry.get(replacementId)).toMatchObject({
489
+ status: 'opening',
490
+ spent: 0n,
491
+ units: 0,
492
+ })
493
+ expect((await registry.get(replacementId))?.receipt).toBeUndefined()
494
+ })
495
+ })
496
+
497
+ describe('session commands', () => {
498
+ test('list and view return stable JSON projections with filters', async () => {
499
+ await seedCommandSessions(commandRegistry())
500
+
501
+ const listed = JSON.parse((await serveSessions(['list', '--json'])).output)
502
+ expect(listed.sessions).toEqual([
503
+ {
504
+ status: 'open',
505
+ channelId,
506
+ account: 'testnet-payer',
507
+ payer,
508
+ payee,
509
+ authorizedSigner: payer,
510
+ token,
511
+ escrow,
512
+ chainId: 42431,
513
+ cumulativeAmount: '9007199254740993123456789',
514
+ confirmedSpend: '1234567890123456789',
515
+ deposit: '9999999999999999999999999',
516
+ units: 7,
517
+ resourceUrl: 'https://api.example.test/query?chainId=testnet&sql=select%201',
518
+ createdAt: '2026-07-16T00:00:00.000Z',
519
+ updatedAt: '2026-07-16T00:00:00.000Z',
520
+ },
521
+ expect.objectContaining({
522
+ status: 'closing',
523
+ channelId: mainnetChannelId,
524
+ account: 'mainnet-payer',
525
+ chainId: 4217,
526
+ cumulativeAmount: '20',
527
+ confirmedSpend: '5',
528
+ }),
529
+ ])
530
+
531
+ const viewed = await serveSessions(['view', channelId, '--json'])
532
+ const byAccount = await serveSessions(['list', '--account', 'testnet-payer', '--json'])
533
+ const byNetwork = await serveSessions(['list', '--network', 'mainnet', '--json'])
534
+ const mismatch = await serveSessions([
535
+ 'list',
536
+ '--account',
537
+ 'testnet-payer',
538
+ '--network',
539
+ 'mainnet',
540
+ '--json',
541
+ ])
542
+ expect(JSON.parse(viewed.output)).toEqual(listed.sessions[0])
543
+ expect(JSON.parse(byAccount.output).sessions).toMatchObject([{ channelId }])
544
+ expect(JSON.parse(byNetwork.output).sessions).toMatchObject([{ channelId: mainnetChannelId }])
545
+ expect(JSON.parse(mismatch.output)).toEqual({ sessions: [] })
546
+ })
547
+
548
+ test('view rejects a missing channel', async () => {
549
+ const missing = `0x${'cc'.repeat(32)}`
550
+ const result = await serveSessions(['view', missing, '--json'])
551
+ expect(result).toMatchObject({ exitCode: 2 })
552
+ expect(result.output).toContain(`Session ${missing} was not found.`)
553
+ })
554
+
555
+ test('close all reports failures in session order', async () => {
556
+ await seedCommandSessions(commandRegistry())
557
+ vi.stubEnv('MPPX_PRIVATE_KEY', `0x${'11'.repeat(32)}`)
558
+ const originalExitCode = process.exitCode
559
+ const originalWrite = process.stderr.write
560
+ let stderr = ''
561
+ process.stderr.write = ((chunk: unknown) => {
562
+ stderr += String(chunk)
563
+ return true
564
+ }) as typeof process.stderr.write
565
+ try {
566
+ const result = await serveSessions(['close', '--all', '--yes', '--json'])
567
+ const output = JSON.parse(result.output)
568
+ expect(output.closed).toEqual([])
569
+ expect(output.failed.map((failure: { channelId: string }) => failure.channelId)).toEqual([
570
+ channelId,
571
+ mainnetChannelId,
572
+ ])
573
+ expect(process.exitCode).toBe(1)
574
+ expect(stderr).toContain(channelId)
575
+ expect(stderr).toContain(mainnetChannelId)
576
+ } finally {
577
+ process.exitCode = originalExitCode
578
+ process.stderr.write = originalWrite
579
+ }
580
+ })
581
+ })