diff --git a/.changeset/fix-stale-session-channel.md b/.changeset/fix-stale-session-channel.md new file mode 100644 index 00000000..ecda1042 --- /dev/null +++ b/.changeset/fix-stale-session-channel.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Fixed stale Tempo session channel reuse after channel-not-found responses. diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index 41b4f540..85790352 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -71,6 +71,15 @@ export type SessionContext = z.infer * ``` */ export function session(parameters: session.Parameters = {}) { + return createSessionController(parameters).method +} + +/** + * Creates a session method with its internal response handling controls. + * + * @internal + */ +export function createSessionController(parameters: session.Parameters = {}) { const { decimals = defaults.decimals } = parameters const getClient = Client.getResolver({ @@ -94,6 +103,41 @@ export function session(parameters: session.Parameters = {}) { parameters.onChannelUpdate?.(entry) } + function evictChannel(channelId: string): boolean { + const key = + channelIdToKey.get(channelId) ?? + Array.from(channelIdToKey).find( + ([cachedChannelId]) => cachedChannelId.toLowerCase() === channelId.toLowerCase(), + )?.[1] + if (!key) return false + + const entry = channels.get(key) + channels.delete(key) + if (entry) { + channelIdToKey.delete(entry.channelId) + escrowContractMap.delete(entry.channelId) + } else { + channelIdToKey.delete(channelId) + escrowContractMap.delete(channelId) + } + return true + } + + async function syncFromResponse( + response: Response, + options: { channelId?: string | undefined } = {}, + ): Promise { + if (response.status !== 410) return undefined + if (!isProblemJson(response)) return undefined + + const problem = await parseProblemDetails(response) + if (problem?.type !== ChannelNotFoundProblemType) return undefined + if (!options.channelId) return undefined + + if (!evictChannel(options.channelId)) return undefined + return { channelEvicted: true, channelId: options.channelId } + } + function channelKey(payee: Address, currency: Address, escrow: Address): string { return `${payee.toLowerCase()}:${currency.toLowerCase()}:${escrow.toLowerCase()}` } @@ -341,24 +385,46 @@ export function session(parameters: session.Parameters = {}) { return serializeCredential(challenge, payload, chainId, account) } - return Method.toClient(Methods.session, { - context: sessionContextSchema, + return { + method: Method.toClient(Methods.session, { + context: sessionContextSchema, - async createCredential({ challenge, context }) { - const chainId = challenge.request.methodDetails?.chainId ?? 0 - const client = await getClient({ chainId }) - const account = getAccount(client, context) + async createCredential({ challenge, context }) { + const chainId = challenge.request.methodDetails?.chainId ?? 0 + const client = await getClient({ chainId }) + const account = getAccount(client, context) - if (!context?.action && (parameters.deposit !== undefined || maxDeposit !== undefined)) - return autoManageCredential(challenge, account, context) + if (!context?.action && (parameters.deposit !== undefined || maxDeposit !== undefined)) + return autoManageCredential(challenge, account, context) - if (context?.action) return manualCredential(challenge, account, context) + if (context?.action) return manualCredential(challenge, account, context) - throw new Error( - 'No `action` in context and no `deposit` or `maxDeposit` configured. Either provide context with action/channelId/cumulativeAmount, or configure `deposit`/`maxDeposit` for auto-management.', - ) - }, - }) + throw new Error( + 'No `action` in context and no `deposit` or `maxDeposit` configured. Either provide context with action/channelId/cumulativeAmount, or configure `deposit`/`maxDeposit` for auto-management.', + ) + }, + }), + syncFromResponse, + } +} + +type SessionSyncResult = { + channelEvicted: true + channelId: string +} + +const ChannelNotFoundProblemType = 'https://paymentauth.org/problems/session/channel-not-found' + +function isProblemJson(response: Response): boolean { + return response.headers.get('Content-Type')?.includes('application/problem+json') ?? false +} + +async function parseProblemDetails(response: Response): Promise<{ type?: string } | null> { + try { + return (await response.clone().json()) as { type?: string } + } catch { + return null + } } export declare namespace session { diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index dac4a8fb..6ddf040d 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -12,7 +12,7 @@ import { parseEvent } from '../session/Sse.js' import type { SessionCredentialPayload, SessionReceipt } from '../session/Types.js' import * as Ws from '../session/Ws.js' import type { ChannelEntry } from './ChannelOps.js' -import { session as sessionPlugin } from './Session.js' +import { createSessionController, session as sessionPlugin } from './Session.js' type WebSocketConstructor = { new (url: string | URL, protocols?: string | string[]): WebSocket @@ -122,7 +122,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa let wsDeliveredChunks = 0n let wsTickCost = 0n - const method = sessionPlugin({ + const session = createSessionController({ account: parameters.account, authorizedSigner: parameters.authorizedSigner, getClient: parameters.client ? () => parameters.client! : parameters.getClient, @@ -134,6 +134,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa channel = entry }, }) + const { method } = session const wrappedFetch = Fetch.from({ fetch: fetchFn, @@ -152,6 +153,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa spent = spent > next ? spent : next } + async function handleSessionResponse(response: Response): Promise { + const channelId = channel?.channelId + const result = await session.syncFromResponse(response, { channelId }) + if (!result?.channelEvicted) return + channel = null + spent = 0n + } + function assertReceiptWithinLocalState(receipt: SessionReceipt) { if (!channel || receipt.channelId !== channel.channelId) return const acceptedCumulative = BigInt(receipt.acceptedCumulative) @@ -267,6 +276,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa ): Promise { lastUrl = input const response = await wrappedFetch(input, init) + await handleSessionResponse(response) return toPaymentResponse(response) } @@ -428,6 +438,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa headers: { Authorization: credential }, }) if (!response.ok) { + await handleSessionResponse(response) const body = await response.text().catch(() => '') const wwwAuth = response.headers.get('WWW-Authenticate') ?? '' throw new Error( @@ -507,6 +518,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa headers: { Authorization: credential }, }) if (!voucherResponse.ok) { + await handleSessionResponse(voucherResponse) throw new Error(`Voucher POST failed with status ${voucherResponse.status}`) } break @@ -822,6 +834,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa headers: { Authorization: credential }, }) if (!response.ok) { + await handleSessionResponse(response) const body = await response.text().catch(() => '') const detail = (() => { if (!body) return '' diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 6e09a29b..5101edd6 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -4246,6 +4246,60 @@ describe.runIf(isLocalnet)('session', () => { expect(closeReceipt?.status).toBe('success') expect(closeReceipt?.spent).toBe('1000000') }) + + test('evicts cached channel after channel-not-found and reopens on next request', async () => { + const backingStore = Store.memory() + const channelStore = ChannelStore.fromStore(backingStore) + const routeHandler = Mppx_server.create({ + methods: [ + tempo_server.session({ + store: backingStore, + getClient: () => client, + account: recipientAccount, + currency, + escrowContract, + chainId: chain.id, + }), + ], + realm: 'api.example.com', + secretKey: 'secret', + }).session({ amount: '1', decimals: 6, unitType: 'token' }) + + const fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + const result = await routeHandler(request) + if (result.status === 402) return result.challenge + return result.withReceipt(new Response('ok')) + } + + const manager = sessionManager({ + account: payer, + client, + escrowContract, + fetch, + maxDeposit: '2', + }) + + const first = await manager.fetch('https://api.example.com/resource') + expect(first.status).toBe(200) + + const staleChannelId = manager.channelId + expect(staleChannelId).toBeTruthy() + await channelStore.updateChannel(staleChannelId!, () => null) + + const failed = await manager.fetch('https://api.example.com/resource') + expect(failed.status).toBe(410) + expect(await failed.json()).toMatchObject({ + type: 'https://paymentauth.org/problems/session/channel-not-found', + }) + expect(manager.channelId).toBeUndefined() + expect(manager.opened).toBe(false) + + const reopened = await manager.fetch('https://api.example.com/resource') + expect(reopened.status).toBe(200) + expect(manager.channelId).toBeTruthy() + expect(manager.channelId).not.toBe(staleChannelId) + }) }) describe('SSE', () => {