Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-stale-session-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Fixed stale Tempo session channel reuse after channel-not-found responses.
94 changes: 80 additions & 14 deletions src/tempo/client/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ export type SessionContext = z.infer<typeof sessionContextSchema>
* ```
*/
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({
Expand All @@ -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(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a comment here to what we are doing and why? this method is a little gnarly

([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<SessionSyncResult | undefined> {
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()}`
}
Expand Down Expand Up @@ -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 }) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this intentional?

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'
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can likely live alongside other errors / problem types -- we should centralize this since its part of the core control flow


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 {
Expand Down
17 changes: 15 additions & 2 deletions src/tempo/client/SessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -134,6 +134,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
channel = entry
},
})
const { method } = session

const wrappedFetch = Fetch.from({
fetch: fetchFn,
Expand All @@ -152,6 +153,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
spent = spent > next ? spent : next
}

async function handleSessionResponse(response: Response): Promise<void> {
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)
Expand Down Expand Up @@ -267,6 +276,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
): Promise<PaymentResponse> {
lastUrl = input
const response = await wrappedFetch(input, init)
await handleSessionResponse(response)
return toPaymentResponse(response)
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ''
Expand Down
54 changes: 54 additions & 0 deletions src/tempo/server/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down