mppx 0.8.14 → 0.8.15
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 +21 -0
- package/dist/cli/validate/payment.d.ts.map +1 -1
- package/dist/cli/validate/payment.js +18 -4
- package/dist/cli/validate/payment.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +93 -10
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/client/internal/MethodChallenge.d.ts +2 -0
- package/dist/client/internal/MethodChallenge.d.ts.map +1 -1
- package/dist/client/internal/MethodChallenge.js +4 -0
- package/dist/client/internal/MethodChallenge.js.map +1 -1
- package/dist/client/internal/MethodResponse.d.ts +15 -0
- package/dist/client/internal/MethodResponse.d.ts.map +1 -1
- package/dist/client/internal/MethodResponse.js +10 -0
- package/dist/client/internal/MethodResponse.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/server/Charge.d.ts +9 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Relay.d.ts +13 -4
- package/dist/tempo/server/Relay.d.ts.map +1 -1
- package/dist/tempo/server/Relay.js +4 -2
- package/dist/tempo/server/Relay.js.map +1 -1
- package/dist/tempo/server/SponsorBudget.d.ts.map +1 -1
- package/dist/tempo/server/SponsorBudget.js +7 -1
- package/dist/tempo/server/SponsorBudget.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/client/Session.d.ts.map +1 -1
- package/dist/tempo/session/client/Session.js +128 -14
- package/dist/tempo/session/client/Session.js.map +1 -1
- package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +62 -20
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/tempo/session/client/Transports.d.ts +9 -1
- package/dist/tempo/session/client/Transports.d.ts.map +1 -1
- package/dist/tempo/session/client/Transports.js +36 -13
- package/dist/tempo/session/client/Transports.js.map +1 -1
- package/dist/tempo/session/server/Ws.d.ts.map +1 -1
- package/dist/tempo/session/server/Ws.js +3 -0
- package/dist/tempo/session/server/Ws.js.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.d.ts +1 -829
- package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.js +14 -3
- package/dist/tempo/subscription/KeyAuthorization.js.map +1 -1
- package/dist/viem/Client.d.ts.map +1 -1
- package/dist/viem/Client.js +6 -1
- package/dist/viem/Client.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/validate/payment.ts +18 -4
- package/src/cli/validate.test.ts +16 -0
- package/src/client/internal/Fetch.test.ts +77 -0
- package/src/client/internal/Fetch.ts +113 -11
- package/src/client/internal/MethodChallenge.ts +5 -0
- package/src/client/internal/MethodResponse.ts +28 -0
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/Charge.ts +9 -1
- package/src/tempo/server/Relay.test.ts +156 -2
- package/src/tempo/server/Relay.ts +13 -4
- package/src/tempo/server/SponsorBudget.test.ts +29 -0
- package/src/tempo/server/SponsorBudget.ts +7 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/README.md +7 -0
- package/src/tempo/session/client/Session.test.ts +299 -33
- package/src/tempo/session/client/Session.ts +152 -16
- package/src/tempo/session/client/SessionManager.test.ts +216 -9
- package/src/tempo/session/client/SessionManager.ts +63 -17
- package/src/tempo/session/client/Transports.test.ts +22 -0
- package/src/tempo/session/client/Transports.ts +58 -22
- package/src/tempo/session/server/Session.test.ts +66 -4
- package/src/tempo/session/server/Ws.ts +3 -0
- package/src/tempo/subscription/KeyAuthorization.test.ts +70 -1
- package/src/tempo/subscription/KeyAuthorization.ts +14 -3
- package/src/viem/Client.test.ts +18 -1
- package/src/viem/Client.ts +7 -1
|
@@ -113,6 +113,20 @@ function makeChannelStore(seed: readonly ChannelEntry[] = []) {
|
|
|
113
113
|
return { store, set, delete: remove, map }
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
function makeDurableChannelStore() {
|
|
117
|
+
const backend = new Map<string, string>()
|
|
118
|
+
const store = createJsonChannelStore({
|
|
119
|
+
get: (key) => backend.get(key),
|
|
120
|
+
set: (key, value) => {
|
|
121
|
+
backend.set(key, value)
|
|
122
|
+
},
|
|
123
|
+
delete: (key) => {
|
|
124
|
+
backend.delete(key)
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
return { backend, store }
|
|
128
|
+
}
|
|
129
|
+
|
|
116
130
|
function makeChallenge(overrides: Record<string, unknown> = {}): Challenge.Challenge {
|
|
117
131
|
return Challenge.from({
|
|
118
132
|
id: challengeId,
|
|
@@ -657,7 +671,7 @@ describe('Session', () => {
|
|
|
657
671
|
})
|
|
658
672
|
})
|
|
659
673
|
|
|
660
|
-
test('rolls back
|
|
674
|
+
test('rolls back optimistic opens across replacement challenges', async () => {
|
|
661
675
|
const { store, delete: remove } = makeChannelStore()
|
|
662
676
|
const postedPayloads: SessionCredentialPayload[] = []
|
|
663
677
|
const challenge = (feePayer: boolean) =>
|
|
@@ -669,6 +683,8 @@ describe('Session', () => {
|
|
|
669
683
|
feePayer,
|
|
670
684
|
},
|
|
671
685
|
})
|
|
686
|
+
const original = challenge(true)
|
|
687
|
+
const replacement = challenge(false)
|
|
672
688
|
let openCount = 0
|
|
673
689
|
const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
|
|
674
690
|
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
@@ -677,10 +693,11 @@ describe('Session', () => {
|
|
|
677
693
|
: undefined
|
|
678
694
|
if (payload) postedPayloads.push(payload)
|
|
679
695
|
|
|
680
|
-
if (!payload) return Promise.resolve(make402Response(
|
|
696
|
+
if (!payload) return Promise.resolve(make402Response(original))
|
|
681
697
|
if (payload.action === 'open') {
|
|
682
698
|
openCount++
|
|
683
|
-
if (openCount === 1) return Promise.resolve(make402Response(
|
|
699
|
+
if (openCount === 1) return Promise.resolve(make402Response(replacement))
|
|
700
|
+
if (openCount === 2) return Promise.resolve(make402Response(original))
|
|
684
701
|
return Promise.resolve(makeOkResponse())
|
|
685
702
|
}
|
|
686
703
|
return Promise.resolve(new Response('unexpected voucher', { status: 500 }))
|
|
@@ -696,12 +713,33 @@ describe('Session', () => {
|
|
|
696
713
|
const response = await s.fetch('https://api.example.com/data')
|
|
697
714
|
|
|
698
715
|
expect(response.status).toBe(200)
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
expect(
|
|
702
|
-
expect(
|
|
703
|
-
|
|
704
|
-
|
|
716
|
+
expect(postedPayloads.map((payload) => payload.action)).toEqual(['open', 'open', 'open'])
|
|
717
|
+
expect(new Set(postedPayloads.map((payload) => payload.channelId)).size).toBe(3)
|
|
718
|
+
expect(remove).toHaveBeenCalledTimes(2)
|
|
719
|
+
expect(s.channelId).toBe(postedPayloads[2]?.channelId)
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
test('reuses prepared voucher state when an identical challenge succeeds', async () => {
|
|
723
|
+
const vouchers: Extract<SessionCredentialPayload, { action: 'voucher' }>[] = []
|
|
724
|
+
const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
|
|
725
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
726
|
+
if (!authorization) return Promise.resolve(make402Response())
|
|
727
|
+
const payload = Credential.deserialize<SessionCredentialPayload>(authorization).payload
|
|
728
|
+
if (payload.action !== 'voucher') throw new Error('expected voucher')
|
|
729
|
+
vouchers.push(payload)
|
|
730
|
+
return Promise.resolve(vouchers.length === 1 ? make402Response() : makeOkResponse())
|
|
731
|
+
})
|
|
732
|
+
const s = sessionManager({
|
|
733
|
+
account,
|
|
734
|
+
client,
|
|
735
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
736
|
+
channelStore: makeChannelStore([channelEntry()]).store,
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
expect((await s.fetch('https://api.example.com/data')).status).toBe(200)
|
|
740
|
+
expect(vouchers.map((voucher) => voucher.cumulativeAmount)).toEqual(['2000000', '2000000'])
|
|
741
|
+
expect(s.cumulative).toBe(2_000_000n)
|
|
742
|
+
expect(s.state.status).toBe('active')
|
|
705
743
|
})
|
|
706
744
|
|
|
707
745
|
test('does not bootstrap when disabled', async () => {
|
|
@@ -989,6 +1027,60 @@ describe('Session', () => {
|
|
|
989
1027
|
})
|
|
990
1028
|
})
|
|
991
1029
|
|
|
1030
|
+
test('preserves a snapshot-acknowledged open when its voucher fails', async () => {
|
|
1031
|
+
const { backend, store } = makeDurableChannelStore()
|
|
1032
|
+
const payloads: SessionCredentialPayload[] = []
|
|
1033
|
+
const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
|
|
1034
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
1035
|
+
if (!authorization)
|
|
1036
|
+
return Promise.resolve(make402Response(makeChallenge({ suggestedDeposit: '10000000' })))
|
|
1037
|
+
const payload = Credential.deserialize<SessionCredentialPayload>(authorization).payload
|
|
1038
|
+
payloads.push(payload)
|
|
1039
|
+
if (payload.action !== 'open')
|
|
1040
|
+
return Promise.resolve(new Response('failed', { status: 500 }))
|
|
1041
|
+
return Promise.resolve(
|
|
1042
|
+
make402Response(
|
|
1043
|
+
makeChallenge({
|
|
1044
|
+
methodDetails: {
|
|
1045
|
+
escrowContract: tip20ChannelEscrow,
|
|
1046
|
+
chainId: 4217,
|
|
1047
|
+
sessionProtocol: Constants.SessionProtocols.v2,
|
|
1048
|
+
sessionSnapshot: {
|
|
1049
|
+
acceptedCumulative: '1000000',
|
|
1050
|
+
chainId: 4217,
|
|
1051
|
+
channelId: payload.channelId,
|
|
1052
|
+
deposit: '10000000',
|
|
1053
|
+
descriptor: payload.descriptor,
|
|
1054
|
+
escrow: tip20ChannelEscrow,
|
|
1055
|
+
requiredCumulative: '2000000',
|
|
1056
|
+
settled: '0',
|
|
1057
|
+
spent: '1000000',
|
|
1058
|
+
units: 1,
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
}),
|
|
1062
|
+
),
|
|
1063
|
+
)
|
|
1064
|
+
})
|
|
1065
|
+
const s = sessionManager({
|
|
1066
|
+
account,
|
|
1067
|
+
client,
|
|
1068
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
1069
|
+
maxDeposit: '10',
|
|
1070
|
+
channelStore: store,
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
expect((await s.fetch('https://api.example.com/data')).status).toBe(500)
|
|
1074
|
+
expect(payloads.map((payload) => payload.action)).toEqual(['open', 'voucher'])
|
|
1075
|
+
expect(s.channelId).toBe(payloads[0]?.channelId)
|
|
1076
|
+
expect(s.cumulative).toBe(1_000_000n)
|
|
1077
|
+
expect(JSON.parse([...backend.values()][0]!)).toMatchObject({
|
|
1078
|
+
channelId: payloads[0]?.channelId,
|
|
1079
|
+
cumulativeAmount: '1000000',
|
|
1080
|
+
opened: true,
|
|
1081
|
+
})
|
|
1082
|
+
})
|
|
1083
|
+
|
|
992
1084
|
test('preemptively top-ups before signing an HTTP voucher that exceeds deposit', async () => {
|
|
993
1085
|
const postedPayloads: SessionCredentialPayload[] = []
|
|
994
1086
|
let challengeCount = 0
|
|
@@ -1055,6 +1147,121 @@ describe('Session', () => {
|
|
|
1055
1147
|
units: 2,
|
|
1056
1148
|
})
|
|
1057
1149
|
})
|
|
1150
|
+
|
|
1151
|
+
test('preserves a successful automatic top-up when the paid retry fails', async () => {
|
|
1152
|
+
const { map, store } = makeChannelStore()
|
|
1153
|
+
const payloads: SessionCredentialPayload[] = []
|
|
1154
|
+
const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
|
|
1155
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
1156
|
+
if (!authorization)
|
|
1157
|
+
return Promise.resolve(make402Response(makeChallenge({ suggestedDeposit: '1000000' })))
|
|
1158
|
+
const payload = Credential.deserialize<SessionCredentialPayload>(authorization).payload
|
|
1159
|
+
payloads.push(payload)
|
|
1160
|
+
if (payload.action === 'open')
|
|
1161
|
+
return Promise.resolve(
|
|
1162
|
+
new Response('ok', {
|
|
1163
|
+
headers: {
|
|
1164
|
+
[Constants.Headers.paymentReceipt]: serializeSessionReceipt(
|
|
1165
|
+
createSessionReceipt({
|
|
1166
|
+
acceptedCumulative: 1_000_000n,
|
|
1167
|
+
challengeId,
|
|
1168
|
+
channelId: payload.channelId,
|
|
1169
|
+
spent: 1_000_000n,
|
|
1170
|
+
units: 3,
|
|
1171
|
+
}),
|
|
1172
|
+
),
|
|
1173
|
+
},
|
|
1174
|
+
}),
|
|
1175
|
+
)
|
|
1176
|
+
if (payload.action === 'topUp') return Promise.resolve(new Response(null, { status: 204 }))
|
|
1177
|
+
return Promise.resolve(make402Response(makeChallenge({ suggestedDeposit: '1000000' })))
|
|
1178
|
+
})
|
|
1179
|
+
const s = sessionManager({
|
|
1180
|
+
account,
|
|
1181
|
+
client,
|
|
1182
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
1183
|
+
maxDeposit: '3',
|
|
1184
|
+
channelStore: store,
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
await s.fetch('https://api.example.com/data')
|
|
1188
|
+
expect((await s.fetch('https://api.example.com/data')).status).toBe(402)
|
|
1189
|
+
|
|
1190
|
+
expect(payloads.map((payload) => payload.action)).toEqual([
|
|
1191
|
+
'open',
|
|
1192
|
+
'topUp',
|
|
1193
|
+
'voucher',
|
|
1194
|
+
'voucher',
|
|
1195
|
+
'voucher',
|
|
1196
|
+
])
|
|
1197
|
+
expect(s.cumulative).toBe(1_000_000n)
|
|
1198
|
+
expect(s.state).toMatchObject({ status: 'active', deposit: '2000000', units: 3 })
|
|
1199
|
+
expect([...map.values()][0]).toMatchObject({
|
|
1200
|
+
cumulativeAmount: 1_000_000n,
|
|
1201
|
+
deposit: 2_000_000n,
|
|
1202
|
+
})
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
test('preserves a topped-up resumed channel in durable storage after payment fails', async () => {
|
|
1206
|
+
const { store } = makeDurableChannelStore()
|
|
1207
|
+
const seeded = channelEntry({ cumulativeAmount: 1_000_000n, deposit: 1_000_000n })
|
|
1208
|
+
await store.set(seeded)
|
|
1209
|
+
const payloads: SessionCredentialPayload[] = []
|
|
1210
|
+
const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
|
|
1211
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
1212
|
+
if (!authorization)
|
|
1213
|
+
return Promise.resolve(make402Response(makeChallenge({ suggestedDeposit: '1000000' })))
|
|
1214
|
+
const payload = Credential.deserialize<SessionCredentialPayload>(authorization).payload
|
|
1215
|
+
payloads.push(payload)
|
|
1216
|
+
return Promise.resolve(
|
|
1217
|
+
payload.action === 'topUp'
|
|
1218
|
+
? new Response(null, { status: 204 })
|
|
1219
|
+
: new Response('failed', { status: 500 }),
|
|
1220
|
+
)
|
|
1221
|
+
})
|
|
1222
|
+
const s = sessionManager({
|
|
1223
|
+
account,
|
|
1224
|
+
client,
|
|
1225
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
1226
|
+
maxDeposit: '3',
|
|
1227
|
+
channelStore: store,
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
expect((await s.fetch('https://api.example.com/data')).status).toBe(500)
|
|
1231
|
+
|
|
1232
|
+
expect(payloads.map((payload) => payload.action)).toEqual(['topUp', 'voucher'])
|
|
1233
|
+
expect(s.cumulative).toBe(1_000_000n)
|
|
1234
|
+
expect(s.state).toMatchObject({ status: 'active', deposit: '2000000' })
|
|
1235
|
+
await expect(store.get(entryKey(seeded))).resolves.toMatchObject({
|
|
1236
|
+
cumulativeAmount: 1_000_000n,
|
|
1237
|
+
deposit: 2_000_000n,
|
|
1238
|
+
opened: true,
|
|
1239
|
+
})
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
test.each([
|
|
1243
|
+
['new', false],
|
|
1244
|
+
['replacement', true],
|
|
1245
|
+
] as const)('removes a failed %s durable channel', async (_kind, seeded) => {
|
|
1246
|
+
const { backend, store } = makeDurableChannelStore()
|
|
1247
|
+
if (seeded) await store.set(channelEntry())
|
|
1248
|
+
const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
|
|
1249
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
1250
|
+
return Promise.resolve(
|
|
1251
|
+
authorization ? new Response('failed', { status: 500 }) : make402Response(),
|
|
1252
|
+
)
|
|
1253
|
+
})
|
|
1254
|
+
const s = sessionManager({
|
|
1255
|
+
account,
|
|
1256
|
+
client,
|
|
1257
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
1258
|
+
channelStore: store,
|
|
1259
|
+
})
|
|
1260
|
+
|
|
1261
|
+
expect((await s.fetch('https://api.example.com/data')).status).toBe(500)
|
|
1262
|
+
expect(s.channelId).toBeUndefined()
|
|
1263
|
+
expect(backend.size).toBe(0)
|
|
1264
|
+
})
|
|
1058
1265
|
})
|
|
1059
1266
|
|
|
1060
1267
|
describe('.topUp()', () => {
|
|
@@ -58,6 +58,7 @@ import { applyTopUpResult, resolveManualTopUp, type TopUpRequirement } from './T
|
|
|
58
58
|
import {
|
|
59
59
|
openWebSocketSession,
|
|
60
60
|
prepareWebSocketSession,
|
|
61
|
+
probeWebSocketSession,
|
|
61
62
|
type WebSocketDriverOptions,
|
|
62
63
|
} from './Transports.js'
|
|
63
64
|
|
|
@@ -243,11 +244,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
243
244
|
|
|
244
245
|
/** Removes a failed channel from candidacy for the rest of this manager's life. */
|
|
245
246
|
async function ignoreChannel(entry: ChannelEntry) {
|
|
247
|
+
const key = entryKey(entry)
|
|
246
248
|
ignoredChannelIds.add(entry.channelId)
|
|
247
|
-
|
|
249
|
+
channelUse?.seenExisting.delete(key)
|
|
250
|
+
if (channelUse?.resumed === entry) channelUse.resumed = undefined
|
|
251
|
+
await Promise.resolve(backing.delete(key)).catch(() => undefined)
|
|
248
252
|
}
|
|
249
253
|
|
|
250
|
-
async function
|
|
254
|
+
async function rollbackCreatedChannels() {
|
|
251
255
|
const use = channelUse
|
|
252
256
|
if (!use?.created.size) return
|
|
253
257
|
for (const [key, entry] of use.created) {
|
|
@@ -255,19 +259,31 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
255
259
|
await Promise.resolve(backing.delete(key)).catch(() => undefined)
|
|
256
260
|
}
|
|
257
261
|
use.created.clear()
|
|
258
|
-
restoreRuntime(use.previous)
|
|
262
|
+
await restoreRuntime(use.committed ?? use.previous)
|
|
259
263
|
}
|
|
260
264
|
|
|
261
|
-
|
|
265
|
+
/** Commits a newly created channel once an authoritative server snapshot references it. */
|
|
266
|
+
function commitReferencedChannel(challenge: Challenge.Challenge): boolean {
|
|
262
267
|
const snapshot = Constants.getMethodDetail<{ channelId?: unknown }>(
|
|
263
268
|
challenge.request.methodDetails,
|
|
264
269
|
Constants.MethodDetailKeys.sessionSnapshot,
|
|
265
270
|
)
|
|
266
271
|
if (typeof snapshot?.channelId !== 'string') return false
|
|
267
272
|
const use = channelUse
|
|
268
|
-
if (!use?.created.size) return false
|
|
269
|
-
for (const entry of use.created
|
|
270
|
-
if (
|
|
273
|
+
if (!use?.created.size || !runtime.channel) return false
|
|
274
|
+
for (const [key, entry] of use.created) {
|
|
275
|
+
if (
|
|
276
|
+
entry.channelId.toLowerCase() !== snapshot.channelId.toLowerCase() ||
|
|
277
|
+
runtime.channel.channelId.toLowerCase() !== snapshot.channelId.toLowerCase()
|
|
278
|
+
)
|
|
279
|
+
continue
|
|
280
|
+
use.created.delete(key)
|
|
281
|
+
use.committed = captureRuntimeStateSnapshot({
|
|
282
|
+
channel: runtime.channel,
|
|
283
|
+
spent: runtime.spent,
|
|
284
|
+
state: runtime.state,
|
|
285
|
+
})
|
|
286
|
+
return true
|
|
271
287
|
}
|
|
272
288
|
return false
|
|
273
289
|
}
|
|
@@ -276,6 +292,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
276
292
|
return dispatchSessionEvent(runtime, event)
|
|
277
293
|
}
|
|
278
294
|
|
|
295
|
+
function activeUnits() {
|
|
296
|
+
const state =
|
|
297
|
+
runtime.state.status === 'active'
|
|
298
|
+
? runtime.state
|
|
299
|
+
: (channelUse?.committed?.state ?? channelUse?.previous.state)
|
|
300
|
+
return state?.status === 'active' ? state.units : 0
|
|
301
|
+
}
|
|
302
|
+
|
|
279
303
|
function commitDurableTopUp(entry: ChannelEntry) {
|
|
280
304
|
const use = channelUse
|
|
281
305
|
const baseline = use?.committed?.channel?.entry ?? use?.resumed
|
|
@@ -312,7 +336,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
312
336
|
challengeId: runtime.lastChallenge.id,
|
|
313
337
|
entry,
|
|
314
338
|
spent: runtime.spent.toString(),
|
|
315
|
-
units:
|
|
339
|
+
units: activeUnits(),
|
|
316
340
|
})
|
|
317
341
|
}
|
|
318
342
|
commitDurableTopUp(entry)
|
|
@@ -332,11 +356,15 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
332
356
|
const use = channelUse
|
|
333
357
|
const isRepeatedRetryChallenge =
|
|
334
358
|
use && use.challengesReceived > 0 && challenge.id === runtime.lastChallenge?.id
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
359
|
+
const isSameRetryChallenge =
|
|
360
|
+
isRepeatedRetryChallenge &&
|
|
361
|
+
Challenge.serialize(challenge) === Challenge.serialize(runtime.lastChallenge!)
|
|
339
362
|
if (use) use.challengesReceived++
|
|
363
|
+
if (isSameRetryChallenge) return undefined
|
|
364
|
+
if (!commitReferencedChannel(challenge)) {
|
|
365
|
+
if (use?.created.size) await rollbackCreatedChannels()
|
|
366
|
+
else if (isRepeatedRetryChallenge) await restoreRuntime(use.committed ?? use.previous)
|
|
367
|
+
}
|
|
340
368
|
runtime.lastChallenge = challenge
|
|
341
369
|
dispatch({ type: 'challengeReceived', challengeId: challenge.id })
|
|
342
370
|
return undefined
|
|
@@ -558,7 +586,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
558
586
|
challengeId: runtime.lastChallenge.id,
|
|
559
587
|
entry: applied.channel,
|
|
560
588
|
spent: runtime.spent.toString(),
|
|
561
|
-
units:
|
|
589
|
+
units: activeUnits(),
|
|
562
590
|
})
|
|
563
591
|
commitDurableTopUp(applied.channel)
|
|
564
592
|
}
|
|
@@ -589,7 +617,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
589
617
|
})
|
|
590
618
|
}
|
|
591
619
|
|
|
592
|
-
function restoreCumulative(channelId: Hex.Hex, cumulativeAmount: bigint) {
|
|
620
|
+
async function restoreCumulative(channelId: Hex.Hex, cumulativeAmount: bigint) {
|
|
593
621
|
const restored = restoreCumulativeAuthorization({
|
|
594
622
|
channel: runtime.channel,
|
|
595
623
|
channelId,
|
|
@@ -606,14 +634,16 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
606
634
|
spent: restored.spent,
|
|
607
635
|
units: restored.units,
|
|
608
636
|
})
|
|
637
|
+
await backing.set(runtime.channel)
|
|
609
638
|
}
|
|
610
639
|
}
|
|
611
640
|
|
|
612
|
-
function restoreRuntime(snapshot: RuntimeSnapshot) {
|
|
641
|
+
async function restoreRuntime(snapshot: RuntimeSnapshot) {
|
|
613
642
|
const restored = restoreRuntimeStateSnapshot(snapshot, runtime.channel)
|
|
614
643
|
runtime.channel = restored.channel
|
|
615
644
|
runtime.spent = restored.spent
|
|
616
645
|
runtime.state = restored.state
|
|
646
|
+
if (runtime.channel?.opened) await backing.set(runtime.channel)
|
|
617
647
|
}
|
|
618
648
|
|
|
619
649
|
function toPaymentResponse(response: Response): PaymentResponse {
|
|
@@ -679,7 +709,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
679
709
|
try {
|
|
680
710
|
response = await wrappedFetch(input, effectiveInit)
|
|
681
711
|
} catch (error) {
|
|
682
|
-
|
|
712
|
+
if (use.created.size) await rollbackCreatedChannels()
|
|
713
|
+
else await restoreRuntime(use.committed ?? previous)
|
|
683
714
|
if (await retryWithoutResumed()) continue
|
|
684
715
|
throw error
|
|
685
716
|
}
|
|
@@ -706,7 +737,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
706
737
|
}
|
|
707
738
|
}
|
|
708
739
|
if (!attemptedHttpManagement && !paymentResponse.ok && !paymentResponse.receipt) {
|
|
709
|
-
|
|
740
|
+
if (use.created.size) await rollbackCreatedChannels()
|
|
741
|
+
else await restoreRuntime(use.committed ?? previous)
|
|
710
742
|
if (await retryWithoutResumed()) continue
|
|
711
743
|
return paymentResponse
|
|
712
744
|
}
|
|
@@ -838,6 +870,20 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
838
870
|
setSocketSession(session) {
|
|
839
871
|
runtime.socketSession = session
|
|
840
872
|
},
|
|
873
|
+
async refreshChallenge() {
|
|
874
|
+
const refreshed = await probeWebSocketSession({
|
|
875
|
+
fetch: config.fetch,
|
|
876
|
+
input: httpUrl,
|
|
877
|
+
probeInit: requestInitWithSessionHint(
|
|
878
|
+
httpUrl,
|
|
879
|
+
signalInit,
|
|
880
|
+
runtime.channel?.opened ? runtime.channel.channelId : undefined,
|
|
881
|
+
),
|
|
882
|
+
signal: init?.signal,
|
|
883
|
+
})
|
|
884
|
+
runtime.lastChallenge = refreshed.challenge
|
|
885
|
+
return refreshed.challenge
|
|
886
|
+
},
|
|
841
887
|
assertVoucherWithinLocalLimit,
|
|
842
888
|
acceptReceipt: updateSpentFromReceipt,
|
|
843
889
|
rejectCloseReady: receipts.rejectCloseReady,
|
|
@@ -310,6 +310,28 @@ describe('HttpManagement', () => {
|
|
|
310
310
|
expect(restoreCumulative).toHaveBeenCalledWith(channelId, 5n)
|
|
311
311
|
})
|
|
312
312
|
|
|
313
|
+
test('retryHttpPaymentRequired restores cumulative authorization when retry throws', async () => {
|
|
314
|
+
const entry = channel({ cumulativeAmount: 5n })
|
|
315
|
+
const restoreCumulative = vi.fn()
|
|
316
|
+
|
|
317
|
+
await expect(
|
|
318
|
+
retryHttpPaymentRequired({
|
|
319
|
+
createSessionCredential: async () => 'voucher-credential',
|
|
320
|
+
fetch: async () => {
|
|
321
|
+
throw new Error('network failed')
|
|
322
|
+
},
|
|
323
|
+
getChannel: () => entry,
|
|
324
|
+
input: 'https://example.test/resource',
|
|
325
|
+
response: response402(challenge(snapshot())),
|
|
326
|
+
restoreCumulative,
|
|
327
|
+
setChallenge() {},
|
|
328
|
+
topUpIfNeeded: async () => {},
|
|
329
|
+
}),
|
|
330
|
+
).rejects.toThrow('network failed')
|
|
331
|
+
|
|
332
|
+
expect(restoreCumulative).toHaveBeenCalledWith(channelId, 5n)
|
|
333
|
+
})
|
|
334
|
+
|
|
313
335
|
test('closeHttpSession posts a close credential and parses the receipt', async () => {
|
|
314
336
|
const entry = channel()
|
|
315
337
|
const createSessionCredential = vi.fn(async (_challenge, context: SessionContext) => {
|
|
@@ -47,6 +47,8 @@ export const WebSocketReadyState = {
|
|
|
47
47
|
/** Client-side close code used for payment protocol errors. */
|
|
48
48
|
export const ClientWebSocketProtocolErrorCloseCode = 3008
|
|
49
49
|
|
|
50
|
+
const webSocketChallengeRefreshWindowMs = 5_000
|
|
51
|
+
|
|
50
52
|
type EventType = 'close' | 'error' | 'message' | 'open'
|
|
51
53
|
type ManagedEventMap = {
|
|
52
54
|
close: { code: number; reason: string; type: 'close'; wasClean: boolean }
|
|
@@ -489,7 +491,7 @@ export type RetryHttpPaymentRequiredParameters = {
|
|
|
489
491
|
/** Failed HTTP response that may contain a session challenge. */
|
|
490
492
|
response: Response
|
|
491
493
|
/** Restores local cumulative authorization if the voucher retry fails. */
|
|
492
|
-
restoreCumulative(channelId: Hex.Hex, cumulativeAmount: bigint): void
|
|
494
|
+
restoreCumulative(channelId: Hex.Hex, cumulativeAmount: bigint): void | Promise<void>
|
|
493
495
|
/** Stores the selected follow-up challenge on the manager. */
|
|
494
496
|
setChallenge(challenge: TempoSessionChallenge): void
|
|
495
497
|
/** Performs automatic top-up before the voucher retry when deposit is insufficient. */
|
|
@@ -692,18 +694,25 @@ export async function retryHttpPaymentRequired(
|
|
|
692
694
|
currentChannel.cumulativeAmount > requiredCumulative
|
|
693
695
|
? currentChannel.cumulativeAmount
|
|
694
696
|
: requiredCumulative
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
697
|
+
const restore = () => parameters.restoreCumulative(snapshot.channelId, cumulativeBeforeVoucher)
|
|
698
|
+
let retry: Response
|
|
699
|
+
try {
|
|
700
|
+
const credential = await parameters.createSessionCredential(challenge, {
|
|
701
|
+
action: 'voucher',
|
|
702
|
+
channelId: snapshot.channelId,
|
|
703
|
+
descriptor: currentChannel.descriptor,
|
|
704
|
+
cumulativeAmountRaw: cumulativeAmount.toString(),
|
|
705
|
+
})
|
|
706
|
+
retry = await parameters.fetch(
|
|
707
|
+
parameters.input,
|
|
708
|
+
requestInitWithAuthorization(parameters.input, parameters.init, credential),
|
|
709
|
+
)
|
|
710
|
+
} catch (error) {
|
|
711
|
+
await restore()
|
|
712
|
+
throw error
|
|
713
|
+
}
|
|
705
714
|
if (!retry.ok && !retry.headers.get(Constants.Headers.paymentReceipt)) {
|
|
706
|
-
|
|
715
|
+
await restore()
|
|
707
716
|
}
|
|
708
717
|
return retry
|
|
709
718
|
}
|
|
@@ -1110,6 +1119,15 @@ export type PrepareWebSocketSessionParameters = {
|
|
|
1110
1119
|
signal?: AbortSignal | undefined
|
|
1111
1120
|
}
|
|
1112
1121
|
|
|
1122
|
+
/** Inputs for probing a paid WebSocket endpoint without creating a credential. */
|
|
1123
|
+
export type ProbeWebSocketSessionParameters = Omit<
|
|
1124
|
+
PrepareWebSocketSessionParameters,
|
|
1125
|
+
'createSessionCredential'
|
|
1126
|
+
>
|
|
1127
|
+
|
|
1128
|
+
/** Result of resolving the current payment challenge for a WebSocket endpoint. */
|
|
1129
|
+
export type ProbedWebSocketSession = Omit<PreparedWebSocketSession, 'credential'>
|
|
1130
|
+
|
|
1113
1131
|
/** Result of the HTTP probe and opening credential creation for a WebSocket session. */
|
|
1114
1132
|
export type PreparedWebSocketSession = {
|
|
1115
1133
|
/** Selected tempo/session challenge from the HTTP probe. */
|
|
@@ -1173,6 +1191,8 @@ export type OpenWebSocketSessionParameters = {
|
|
|
1173
1191
|
getChannel(): ChannelEntry | null
|
|
1174
1192
|
/** Stores the active socket state in the manager. */
|
|
1175
1193
|
setSocketSession(session: ActiveSocketSession): void
|
|
1194
|
+
/** Fetches the route's current challenge after the active one expires. */
|
|
1195
|
+
refreshChallenge(): Promise<TempoSessionChallenge>
|
|
1176
1196
|
/** Validates a cumulative amount against local client policy. */
|
|
1177
1197
|
assertVoucherWithinLocalLimit(cumulativeAmount: bigint): void
|
|
1178
1198
|
/** Applies an incoming receipt to manager state. */
|
|
@@ -1213,10 +1233,10 @@ export type ValidateSocketPaymentReceiptParameters = ExpectedSocketReceiptParame
|
|
|
1213
1233
|
expectedCloseAmount: string | null
|
|
1214
1234
|
}
|
|
1215
1235
|
|
|
1216
|
-
/** Probes a WebSocket endpoint over HTTP and
|
|
1217
|
-
export async function
|
|
1218
|
-
parameters:
|
|
1219
|
-
): Promise<
|
|
1236
|
+
/** Probes a WebSocket endpoint over HTTP and returns its current session challenge. */
|
|
1237
|
+
export async function probeWebSocketSession(
|
|
1238
|
+
parameters: ProbeWebSocketSessionParameters,
|
|
1239
|
+
): Promise<ProbedWebSocketSession> {
|
|
1220
1240
|
const wsUrl = new URL(parameters.input.toString())
|
|
1221
1241
|
const httpUrl = webSocketProbeUrl(wsUrl)
|
|
1222
1242
|
parameters.onProbeUrl?.(httpUrl)
|
|
@@ -1239,12 +1259,22 @@ export async function prepareWebSocketSession(
|
|
|
1239
1259
|
|
|
1240
1260
|
return {
|
|
1241
1261
|
challenge,
|
|
1242
|
-
credential: await parameters.createSessionCredential(challenge, {}),
|
|
1243
1262
|
httpUrl,
|
|
1244
1263
|
wsUrl,
|
|
1245
1264
|
}
|
|
1246
1265
|
}
|
|
1247
1266
|
|
|
1267
|
+
/** Probes a WebSocket endpoint over HTTP and creates the opening credential. */
|
|
1268
|
+
export async function prepareWebSocketSession(
|
|
1269
|
+
parameters: PrepareWebSocketSessionParameters,
|
|
1270
|
+
): Promise<PreparedWebSocketSession> {
|
|
1271
|
+
const probed = await probeWebSocketSession(parameters)
|
|
1272
|
+
return {
|
|
1273
|
+
...probed,
|
|
1274
|
+
credential: await parameters.createSessionCredential(probed.challenge, {}),
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1248
1278
|
/** Creates the initial runtime state for a paid WebSocket from its opening credential. */
|
|
1249
1279
|
export function createActiveSocketSession(
|
|
1250
1280
|
parameters: CreateActiveSocketSessionParameters,
|
|
@@ -1452,9 +1482,18 @@ async function handleNeedVoucher(
|
|
|
1452
1482
|
event: NeedVoucherEvent,
|
|
1453
1483
|
) {
|
|
1454
1484
|
try {
|
|
1485
|
+
let challenge = parameters.driver.challenge
|
|
1486
|
+
if (
|
|
1487
|
+
challenge.expires &&
|
|
1488
|
+
new Date(challenge.expires).getTime() <= Date.now() + webSocketChallengeRefreshWindowMs
|
|
1489
|
+
) {
|
|
1490
|
+
challenge = await parameters.driver.refreshChallenge()
|
|
1491
|
+
parameters.driver.challenge = challenge
|
|
1492
|
+
parameters.socketState.challenge = challenge
|
|
1493
|
+
}
|
|
1455
1494
|
const resolution = await resolveNeedVoucherContext({
|
|
1456
1495
|
assertVoucherWithinLocalLimit: parameters.driver.assertVoucherWithinLocalLimit,
|
|
1457
|
-
challenge
|
|
1496
|
+
challenge,
|
|
1458
1497
|
event,
|
|
1459
1498
|
expectedChannelId: parameters.socketState.channelId,
|
|
1460
1499
|
getChannel: parameters.driver.getChannel,
|
|
@@ -1469,10 +1508,7 @@ async function handleNeedVoucher(
|
|
|
1469
1508
|
)
|
|
1470
1509
|
return
|
|
1471
1510
|
}
|
|
1472
|
-
const voucher = await parameters.driver.createSessionCredential(
|
|
1473
|
-
parameters.driver.challenge,
|
|
1474
|
-
resolution.context,
|
|
1475
|
-
)
|
|
1511
|
+
const voucher = await parameters.driver.createSessionCredential(challenge, resolution.context)
|
|
1476
1512
|
parameters.rawSocket.send(Ws.formatAuthorizationMessage(voucher))
|
|
1477
1513
|
} catch (error) {
|
|
1478
1514
|
parameters.failSocketFlow(
|