mppx 0.8.12 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +16 -1
- package/dist/cli/account.d.ts +88 -0
- package/dist/cli/account.d.ts.map +1 -1
- package/dist/cli/account.js +39 -9
- package/dist/cli/account.js.map +1 -1
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +59 -2
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/sessions/Manager.d.ts +31 -0
- package/dist/cli/sessions/Manager.d.ts.map +1 -0
- package/dist/cli/sessions/Manager.js +56 -0
- package/dist/cli/sessions/Manager.js.map +1 -0
- package/dist/cli/sessions/commands.d.ts +36 -0
- package/dist/cli/sessions/commands.d.ts.map +1 -0
- package/dist/cli/sessions/commands.js +407 -0
- package/dist/cli/sessions/commands.js.map +1 -0
- package/dist/cli/sessions/request.d.ts +35 -0
- package/dist/cli/sessions/request.d.ts.map +1 -0
- package/dist/cli/sessions/request.js +271 -0
- package/dist/cli/sessions/request.js.map +1 -0
- package/dist/cli/sessions/store.d.ts +134 -0
- package/dist/cli/sessions/store.d.ts.map +1 -0
- package/dist/cli/sessions/store.js +723 -0
- package/dist/cli/sessions/store.js.map +1 -0
- package/dist/cli/utils.d.ts +2 -0
- package/dist/cli/utils.d.ts.map +1 -1
- package/dist/cli/utils.js +12 -0
- package/dist/cli/utils.js.map +1 -1
- package/dist/cli/validate/index.d.ts.map +1 -1
- package/dist/cli/validate/index.js +12 -6
- package/dist/cli/validate/index.js.map +1 -1
- package/dist/cli/validate/messages.d.ts +2 -0
- package/dist/cli/validate/messages.d.ts.map +1 -0
- package/dist/cli/validate/messages.js +8 -0
- package/dist/cli/validate/messages.js.map +1 -0
- package/dist/client/node.d.ts +29 -0
- package/dist/client/node.d.ts.map +1 -0
- package/dist/client/node.js +177 -0
- package/dist/client/node.js.map +1 -0
- package/dist/internal/types.d.ts +24 -0
- package/dist/internal/types.d.ts.map +1 -1
- package/dist/stripe/server/Charge.d.ts +8 -4
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +1 -1
- package/dist/tempo/internal/types.d.ts +10 -1
- package/dist/tempo/internal/types.d.ts.map +1 -1
- package/dist/tempo/legacy/client/Session.d.ts +2 -2
- package/dist/tempo/legacy/server/Session.d.ts +3 -3
- package/dist/tempo/legacy/server/Session.d.ts.map +1 -1
- package/dist/tempo/legacy/server/Session.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +3 -2
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +2 -23
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +2 -2
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Subscription.d.ts +3 -2
- package/dist/tempo/server/Subscription.d.ts.map +1 -1
- package/dist/tempo/server/Subscription.js.map +1 -1
- package/dist/tempo/server/index.d.ts +1 -1
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/session/Snapshot.d.ts +9 -0
- package/dist/tempo/session/Snapshot.d.ts.map +1 -1
- package/dist/tempo/session/Snapshot.js +6 -0
- package/dist/tempo/session/Snapshot.js.map +1 -1
- package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/session/client/ChannelOps.js +11 -2
- package/dist/tempo/session/client/ChannelOps.js.map +1 -1
- package/dist/tempo/session/client/CredentialState.d.ts +21 -1
- package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
- package/dist/tempo/session/client/CredentialState.js +66 -1
- package/dist/tempo/session/client/CredentialState.js.map +1 -1
- package/dist/tempo/session/client/Session.d.ts +1 -1
- package/dist/tempo/session/client/SessionManager.d.ts +2 -2
- package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +128 -30
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/tempo/session/client/Transports.d.ts +11 -2
- package/dist/tempo/session/client/Transports.d.ts.map +1 -1
- package/dist/tempo/session/client/Transports.js +33 -9
- package/dist/tempo/session/client/Transports.js.map +1 -1
- package/dist/tempo/session/client/internal/SessionManager.d.ts +19 -0
- package/dist/tempo/session/client/internal/SessionManager.d.ts.map +1 -0
- package/dist/tempo/session/client/internal/SessionManager.js +13 -0
- package/dist/tempo/session/client/internal/SessionManager.js.map +1 -0
- package/dist/tempo/session/precompile/Chain.js +13 -4
- package/dist/tempo/session/precompile/Chain.js.map +1 -1
- package/dist/tempo/session/server/CredentialVerification.d.ts +3 -0
- package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
- package/dist/tempo/session/server/CredentialVerification.js +14 -0
- package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
- package/dist/tempo/session/server/MeteredStream.d.ts +7 -1
- package/dist/tempo/session/server/MeteredStream.d.ts.map +1 -1
- package/dist/tempo/session/server/MeteredStream.js +5 -4
- package/dist/tempo/session/server/MeteredStream.js.map +1 -1
- package/dist/tempo/session/server/RequestState.d.ts +5 -1
- package/dist/tempo/session/server/RequestState.d.ts.map +1 -1
- package/dist/tempo/session/server/RequestState.js +10 -4
- package/dist/tempo/session/server/RequestState.js.map +1 -1
- package/dist/tempo/session/server/Session.d.ts +21 -6
- package/dist/tempo/session/server/Session.d.ts.map +1 -1
- package/dist/tempo/session/server/Session.js +4 -1
- package/dist/tempo/session/server/Session.js.map +1 -1
- package/dist/tempo/session/server/Settlement.d.ts +20 -0
- package/dist/tempo/session/server/Settlement.d.ts.map +1 -1
- package/dist/tempo/session/server/Settlement.js +20 -0
- package/dist/tempo/session/server/Settlement.js.map +1 -1
- package/dist/tempo/session/server/Ws.d.ts +2 -0
- package/dist/tempo/session/server/Ws.d.ts.map +1 -1
- package/dist/tempo/session/server/Ws.js.map +1 -1
- package/dist/tempo/session/server/index.d.ts +1 -1
- package/dist/tempo/session/server/index.d.ts.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.d.ts +21 -21
- package/dist/validation/core.d.ts.map +1 -1
- package/dist/validation/core.js +6 -2
- package/dist/validation/core.js.map +1 -1
- package/dist/viem/Client.d.ts.map +1 -1
- package/dist/viem/Client.js +24 -17
- package/dist/viem/Client.js.map +1 -1
- package/package.json +6 -1
- package/src/cli/account.ts +45 -10
- package/src/cli/cli.test.ts +165 -70
- package/src/cli/cli.ts +67 -2
- package/src/cli/mcp.test.ts +11 -0
- package/src/cli/sessions/Manager.test.ts +249 -0
- package/src/cli/sessions/Manager.ts +93 -0
- package/src/cli/sessions/commands.ts +444 -0
- package/src/cli/sessions/request.test.ts +51 -0
- package/src/cli/sessions/request.ts +353 -0
- package/src/cli/sessions/store.test.ts +581 -0
- package/src/cli/sessions/store.ts +940 -0
- package/src/cli/utils.test.ts +23 -0
- package/src/cli/utils.ts +10 -0
- package/src/cli/validate/index.ts +12 -14
- package/src/cli/validate/messages.ts +7 -0
- package/src/cli/validate.test.ts +38 -5
- package/src/client/node.test.ts +115 -0
- package/src/client/node.ts +247 -0
- package/src/internal/types.test-d.ts +21 -0
- package/src/internal/types.ts +33 -0
- package/src/server/Mppx.test-d.ts +42 -0
- package/src/stripe/Methods.test.ts +10 -0
- package/src/stripe/server/Charge.test-d.ts +66 -0
- package/src/stripe/server/Charge.ts +8 -5
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/internal/types.ts +13 -2
- package/src/tempo/legacy/server/Defaults.test-d.ts +9 -0
- package/src/tempo/legacy/server/Session.ts +6 -5
- package/src/tempo/server/Charge.ts +6 -4
- package/src/tempo/server/Methods.ts +5 -4
- package/src/tempo/server/Subscription.ts +6 -4
- package/src/tempo/server/index.ts +5 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/README.md +201 -0
- package/src/tempo/session/Snapshot.ts +21 -0
- package/src/tempo/session/client/ChannelOps.test.ts +40 -0
- package/src/tempo/session/client/ChannelOps.ts +12 -2
- package/src/tempo/session/client/CredentialState.test.ts +2 -2
- package/src/tempo/session/client/CredentialState.ts +103 -1
- package/src/tempo/session/client/Session.test.ts +3 -3
- package/src/tempo/session/client/SessionManager.test.ts +137 -4
- package/src/tempo/session/client/SessionManager.ts +139 -33
- package/src/tempo/session/client/Transports.test.ts +150 -8
- package/src/tempo/session/client/Transports.ts +48 -10
- package/src/tempo/session/client/internal/SessionManager.ts +36 -0
- package/src/tempo/session/precompile/Chain.test.ts +26 -0
- package/src/tempo/session/precompile/Chain.ts +17 -4
- package/src/tempo/session/server/CredentialVerification.ts +18 -1
- package/src/tempo/session/server/MeteredStream.ts +12 -5
- package/src/tempo/session/server/RequestState.test.ts +39 -3
- package/src/tempo/session/server/RequestState.ts +13 -6
- package/src/tempo/session/server/Session.test.ts +461 -0
- package/src/tempo/session/server/Session.ts +31 -8
- package/src/tempo/session/server/Settlement.ts +45 -0
- package/src/tempo/session/server/Ws.test.ts +113 -0
- package/src/tempo/session/server/Ws.ts +2 -0
- package/src/tempo/session/server/index.ts +2 -0
- package/src/validation/core.ts +7 -1
- package/src/viem/Client.test.ts +44 -1
- 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
|
+
})
|