From f4912a7c1e48e23102268f5e75aa880d07fc4668 Mon Sep 17 00:00:00 2001 From: caso Date: Sun, 22 Mar 2026 02:52:18 +0100 Subject: [PATCH 1/6] docs: add SessionManager restore API design spec --- ...26-03-22-session-manager-restore-design.md | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-22-session-manager-restore-design.md diff --git a/docs/superpowers/specs/2026-03-22-session-manager-restore-design.md b/docs/superpowers/specs/2026-03-22-session-manager-restore-design.md new file mode 100644 index 00000000..43efe9d2 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-session-manager-restore-design.md @@ -0,0 +1,219 @@ +# SessionManager Restore API Design + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:writing-plans after this spec is approved to create the implementation plan. + +**Goal:** Add a minimal public API to `mppx` that lets callers restore an existing Tempo session channel into `SessionManager` after a process restart. + +**Architecture:** Keep persistence ownership outside `mppx`. `SessionManager` gains a small restore surface that seeds its in-memory channel state from caller-provided data, then continues using the existing `fetch()` and `sse()` payment flow. The new API should not change default behavior for fresh sessions. + +**Tech Stack:** TypeScript, Vitest, existing `mppx` Tempo session client. + +--- + +## Problem + +`SessionManager` currently stores all channel state in memory. After a client process restarts, a caller cannot resume a previously opened session channel even if it already knows the `channelId` and accepted cumulative amount. This forces callers to either open a new channel or reimplement the session lifecycle outside `mppx`. + +That limitation is a poor fit for long-lived clients like desktop apps, background agents, and local payment proxies. These clients often have a persisted wallet or channel database and need to resume the exact session state that existed before restart. + +## Non-Goals + +- `mppx` will not read or write SQLite, Redis, files, or any other storage backend in this change. +- `mppx` will not add a generic persistence adapter abstraction in this change. +- `mppx` will not include OpenCode-specific request normalization or provider compatibility logic. +- `mppx` will not attempt to automatically validate restored state against on-chain state in this first iteration. + +## Recommended API + +Add a minimal restore input to `sessionManager(...)`. + +Example shape: + +```ts +const s = sessionManager({ + account, + maxDeposit: '10', + restore: { + channelId, + cumulativeAmount: 450000n, + spent: 450000n, + }, +}) +``` + +Recommended public type: + +```ts +type Restore = { + channelId: Hex.Hex + cumulativeAmount: bigint + spent?: bigint | undefined +} +``` + +Recommended semantics: + +- `channelId` is required +- `cumulativeAmount` is required +- `spent` defaults to `cumulativeAmount` +- restored sessions are always treated as already-opened channels + +This keeps the API declarative, avoids mutation after construction, and makes the restored state visible at initialization time. + +## Why Constructor Restore Instead of injectChannel() + +Three approaches were considered: + +1. **Constructor restore (recommended)** + - smallest and clearest API + - declarative state initialization + - avoids awkward post-construction mutation timing + +2. **`injectChannel()` instance method** + - workable, but more imperative + - creates questions about when it is safe to call relative to `fetch()` / `sse()` + +3. **Persistence adapter abstraction** + - more reusable in theory + - much larger API/design surface + - unnecessary because callers can already load persisted state themselves + +The constructor `restore` option is the smallest change that solves the real problem. + +## Internal Design + +`SessionManager` currently owns these private mutable fields: + +- `channel` +- `lastChallenge` +- `lastUrl` +- `spent` + +This change should seed restored session state during construction, but it should not promise to construct a partial `ChannelEntry` that omits required low-level fields like `salt`, `escrowContract`, or `chainId`. + +Expected internal behavior: + +- initialize `spent` to `restore.spent ?? restore.cumulativeAmount` +- keep enough restored metadata in `SessionManager` to bridge the first paid request into the existing `session()` method context +- ensure the first 402 retry can reuse the restored `channelId` and `cumulativeAmount` even if the server challenge does not include `methodDetails.channelId` +- leave `lastChallenge` and `lastUrl` unset until the next real request +- keep `sessionPlugin(...).onChannelUpdate(...)` behavior unchanged so future server-driven updates continue to overwrite the restored snapshot naturally + +This preserves the current runtime model while allowing a caller to skip the “fresh session only” assumption. + +## Reuse Bridge on First 402 + +This is the critical implementation requirement. + +Restoring state into `SessionManager` is not enough unless the next 402 retry actually passes that state into the lower-level `session()` method when it creates the credential. + +The implementation must define a bridge so that, on the first paid request after restore, credential creation uses: + +- restored `channelId` +- restored `cumulativeAmount` + +instead of assuming a new channel should be opened. + +That bridge should work even when the challenge does not provide a recoverable `methodDetails.channelId`. + +## Runtime Invariants + +The restore input needs runtime validation, not only type validation. + +Required invariants: + +- `cumulativeAmount >= 0n` +- `spent >= 0n` when provided +- `spent <= cumulativeAmount` + +If any invariant is violated, `sessionManager(...)` should throw immediately during construction. + +## Behavior Rules + +### `fetch()` + +- restored sessions should be treated as already-opened channels +- the first paid request should reuse the restored channel rather than forcing a new open +- `cumulative` should report the restored amount immediately before any network call + +### `sse()` + +- SSE should use the restored `channelId` and `cumulativeAmount` when responding to `payment-need-voucher` +- receipt handling should continue to move `spent` forward based on accepted cumulative values + +### `open()` + +- if `opened === true`, `.open()` should remain a no-op just like an already-open session + +### `close()` + +- restored `spent` state should be used when constructing the close credential +- if no fresh request has happened since process restart and `lastChallenge` / `lastUrl` are still unset, `.close()` cannot proceed; this limitation should be documented rather than expanded in this first PR + +## Validation Rules + +The first version should keep validation intentionally small but explicit: + +- reject missing `channelId` or `cumulativeAmount` at type level +- normalize `spent` to `cumulativeAmount` if omitted +- enforce non-negative runtime values +- enforce `spent <= cumulativeAmount` + +The API should not perform deeper validation like “check chain state” or “verify deposit” in this first PR. Those are separate concerns and would expand the scope too much. + +## Testing Strategy + +Add targeted tests to `src/tempo/client/SessionManager.test.ts`. + +Required coverage: + +1. session creation with restore state + - `channelId` is exposed immediately + - `cumulative` matches restored value + - restored session reports `opened === true` + +2. `fetch()` with restored state + - request path reuses restored session behavior + - include a case where the server challenge does not expose `methodDetails.channelId` + - no regression for non-restored sessions + +3. `sse()` with restored state + - `payment-need-voucher` uses restored `channelId` + - voucher creation advances from restored `cumulativeAmount` + +4. `close()` with restored state + - close credential uses restored `spent` / cumulative state after a fresh request has provided `lastChallenge` + +5. restore validation + - rejects negative `cumulativeAmount` + - rejects negative `spent` + - rejects `spent > cumulativeAmount` + +## Documentation Changes + +Update the `SessionManager` docs/comments to describe: + +- the new `restore` option +- its intended use for process restarts and persisted callers +- that persistence remains the caller’s responsibility +- that `.close()` still requires a fresh request after restart so `lastChallenge` / `lastUrl` exist + +If there is a public docs page or example for `tempo.sessionManager`, add a small example showing a restored session. + +## Risks + +- callers may restore stale state; this API makes that possible intentionally, so documentation must be explicit that callers own correctness of persisted inputs +- adding too much validation now would turn this into a much larger feature + +## Out of Scope Follow-Ups + +Potential future work after this lands: + +- a dedicated `injectChannel()` helper if maintainers prefer that API shape +- persistence adapters for file/Redis/custom stores +- optional on-chain reconciliation for restored channels +- higher-level proxy/client examples for long-lived local payment daemons + +## Upstream Positioning + +This should be presented upstream as a generic client-resumption feature for long-lived session-based clients, not as an OpenCode-specific change. OpenCode remains an example consumer, but the underlying need applies to any restarted client that wants to reuse an existing Tempo payment channel. From fbe82bff873003e052804fb30d33c534ac7068d1 Mon Sep 17 00:00:00 2001 From: caso Date: Sun, 22 Mar 2026 03:53:48 +0100 Subject: [PATCH 2/6] feat: add SessionManager restore support --- examples/session/multi-fetch/README.md | 21 + examples/session/sse/README.md | 21 + src/tempo/client/Session.test.ts | 121 ++- src/tempo/client/Session.ts | 53 +- src/tempo/client/SessionManager.test.ts | 964 +++++++++++++++++++++++- src/tempo/client/SessionManager.ts | 126 +++- 6 files changed, 1261 insertions(+), 45 deletions(-) diff --git a/examples/session/multi-fetch/README.md b/examples/session/multi-fetch/README.md index 5e1bbc82..c95ce7b9 100644 --- a/examples/session/multi-fetch/README.md +++ b/examples/session/multi-fetch/README.md @@ -2,6 +2,27 @@ Multiple paid requests over a single payment channel, then close and settle. Demonstrates a batch scraping use case where each fetch increments the cumulative voucher by 0.002 pathUSD. +## Restore after restart + +Session persistence is caller-owned. If the client restarts between fetches, +save the latest `channelId`, cumulative amount, and optionally `spent`, then +resume with: + +```ts +const manager = tempo.sessionManager({ + account, + maxDeposit: '10', + restore: { + channelId: saved.channelId, + cumulativeAmount: saved.cumulativeAmount, + spent: saved.spent, + }, +}) +``` + +After restart, `.close()` still requires one fresh paid request first so the +manager can receive a new challenge and remember the request URL. + ## Setup ```bash diff --git a/examples/session/sse/README.md b/examples/session/sse/README.md index 7a35be5d..151590f3 100644 --- a/examples/session/sse/README.md +++ b/examples/session/sse/README.md @@ -2,6 +2,27 @@ Pay-per-token LLM streaming using the SSE handler API. The server uses `tempo.Sse.from()` to create an SSE response that charges per token via `stream.charge()`. The client uses `session.sse()` to consume tokens as an async iterable, automatically handling voucher top-ups and receipts. +## Restore after restart + +Session persistence is caller-owned. If the client restarts, save the latest +`channelId`, cumulative amount, and optionally `spent`, then pass them back via +`restore` when constructing the next `sessionManager` instance. + +```ts +const manager = tempo.sessionManager({ + account, + maxDeposit: '10', + restore: { + channelId: saved.channelId, + cumulativeAmount: saved.cumulativeAmount, + spent: saved.spent, + }, +}) +``` + +After restart, `.close()` still needs one fresh paid request first so the +manager can receive a new challenge and remember the request URL. + ## Setup ```bash diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index b5ffcce6..4deb5946 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -1,7 +1,7 @@ import { type Address, createClient, type Hex, http } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { Addresses } from 'viem/tempo' -import { beforeAll, describe, expect, test } from 'vp/test' +import { beforeAll, describe, expect, test, vi } from 'vitest' import { nodeEnv } from '~test/config.js' import { deployEscrow, openChannel } from '~test/tempo/session.js' import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js' @@ -202,9 +202,126 @@ describe('session (pure)', () => { expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`) }) }) + + describe('channel recovery', () => { + const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex + + test('recovers explicit channel before requiring deposit configuration', async () => { + vi.resetModules() + + const createVoucherPayload = vi.fn( + async (_client, _account, channelId, cumulativeAmount) => ({ + action: 'voucher' as const, + channelId, + cumulativeAmount: cumulativeAmount.toString(), + signature: '0xabc', + }), + ) + + vi.doMock('./ChannelOps.js', async () => { + const actual = await vi.importActual('./ChannelOps.js') + return { + ...actual, + createVoucherPayload, + tryRecoverChannel: vi.fn().mockResolvedValue({ + channelId, + salt: '0x' as Hex, + cumulativeAmount: 3_000_000n, + escrowContract: escrowAddress, + chainId: 42431, + opened: true, + }), + } + }) + + try { + const { session: isolatedSession } = await import('./Session.js') + const method = isolatedSession({ + getClient: () => pureClient, + account: pureAccount, + escrowContract: escrowAddress, + }) + + const result = await method.createCredential({ + challenge: makeChallenge(), + context: { + channelId, + cumulativeAmountRaw: '1000000', + }, + }) + + const cred = deserializePayload(result) + expect(cred.payload.action).toBe('voucher') + if (cred.payload.action === 'voucher') { + expect(cred.payload.channelId).toBe(channelId) + expect(cred.payload.cumulativeAmount).toBe('4000000') + } + } finally { + vi.doUnmock('./ChannelOps.js') + vi.resetModules() + } + }) + + test('keeps higher recovered cumulative than stale restore context', async () => { + vi.resetModules() + + const createVoucherPayload = vi.fn( + async (_client, _account, channelId, cumulativeAmount) => ({ + action: 'voucher' as const, + channelId, + cumulativeAmount: cumulativeAmount.toString(), + signature: '0xabc', + }), + ) + + vi.doMock('./ChannelOps.js', async () => { + const actual = await vi.importActual('./ChannelOps.js') + return { + ...actual, + createVoucherPayload, + tryRecoverChannel: vi.fn().mockResolvedValue({ + channelId, + salt: '0x' as Hex, + cumulativeAmount: 3_000_000n, + escrowContract: escrowAddress, + chainId: 42431, + opened: true, + }), + } + }) + + try { + const { session: isolatedSession } = await import('./Session.js') + const method = isolatedSession({ + getClient: () => pureClient, + account: pureAccount, + deposit: '10', + escrowContract: escrowAddress, + }) + + const result = await method.createCredential({ + challenge: makeChallenge(), + context: { + channelId, + cumulativeAmountRaw: '1000000', + }, + }) + + const cred = deserializePayload(result) + expect(cred.payload.action).toBe('voucher') + if (cred.payload.action === 'voucher') { + expect(cred.payload.channelId).toBe(channelId) + expect(cred.payload.cumulativeAmount).toBe('4000000') + } + } finally { + vi.doUnmock('./ChannelOps.js') + vi.resetModules() + } + }) + }) }) -describe.runIf(isLocalnet)('session (on-chain)', () => { +describe.skipIf(nodeEnv !== 'localnet')('session (on-chain)', () => { const payer = accounts[2] const payee = accounts[1].address let escrowContract: Address diff --git a/src/tempo/client/Session.ts b/src/tempo/client/Session.ts index 99ad988e..1052e7e1 100644 --- a/src/tempo/client/Session.ts +++ b/src/tempo/client/Session.ts @@ -20,6 +20,16 @@ import { tryRecoverChannel, } from './ChannelOps.js' +export class UnrecoverableRestoreError extends Error { + readonly channelId: Hex.Hex + + constructor(channelId: Hex.Hex, reason = 'closed or not found on-chain') { + super(`Channel ${channelId} cannot be reused (${reason}).`) + this.name = 'UnrecoverableRestoreError' + this.channelId = channelId + } +} + export const sessionContextSchema = z.object({ account: z.optional(z.custom()), action: z.optional(z.enum(['open', 'topUp', 'voucher', 'close'])), @@ -129,18 +139,6 @@ export function session(parameters: session.Parameters = {}) { .suggestedDeposit const suggestedDeposit = suggestedDepositRaw ? BigInt(suggestedDepositRaw) : undefined - const deposit = (() => { - if (context?.depositRaw) return BigInt(context.depositRaw) - if (suggestedDeposit !== undefined && maxDeposit !== undefined) - return suggestedDeposit < maxDeposit ? suggestedDeposit : maxDeposit - if (suggestedDeposit !== undefined) return suggestedDeposit - if (maxDeposit !== undefined) return maxDeposit - if (parameters.deposit !== undefined) return parseUnits(parameters.deposit, decimals) - throw new Error( - 'No deposit amount available. Set `deposit`, `maxDeposit`, or ensure the server challenge includes `suggestedDeposit`.', - ) - })() - const authorizedSigner = getAuthorizedSigner(account) const key = channelKey(payee, currency, escrowContract) @@ -161,16 +159,19 @@ export function session(parameters: session.Parameters = {}) { : context?.cumulativeAmount ? parseUnits(context.cumulativeAmount, decimals) : undefined - if (contextCumulative !== undefined) recovered.cumulativeAmount = contextCumulative + if (contextCumulative !== undefined) { + recovered.cumulativeAmount = + recovered.cumulativeAmount > contextCumulative + ? recovered.cumulativeAmount + : contextCumulative + } channels.set(key, recovered) channelIdToKey.set(recovered.channelId, key) escrowContractMap.set(recovered.channelId, escrowContract) entry = recovered notifyUpdate(entry) } else if (context?.channelId) { - throw new Error( - `Channel ${context.channelId} cannot be reused (closed or not found on-chain).`, - ) + throw new UnrecoverableRestoreError(context.channelId as Hex.Hex) } } } @@ -190,6 +191,18 @@ export function session(parameters: session.Parameters = {}) { ) notifyUpdate(entry) } else { + const deposit = (() => { + if (context?.depositRaw) return BigInt(context.depositRaw) + if (suggestedDeposit !== undefined && maxDeposit !== undefined) + return suggestedDeposit < maxDeposit ? suggestedDeposit : maxDeposit + if (suggestedDeposit !== undefined) return suggestedDeposit + if (maxDeposit !== undefined) return maxDeposit + if (parameters.deposit !== undefined) return parseUnits(parameters.deposit, decimals) + throw new Error( + 'No deposit amount available. Set `deposit`, `maxDeposit`, or ensure the server challenge includes `suggestedDeposit`.', + ) + })() + const result = await createOpenPayload(client, account, { authorizedSigner, escrowContract, @@ -349,7 +362,13 @@ export function session(parameters: session.Parameters = {}) { const client = await getClient({ chainId }) const account = getAccount(client, context) - if (!context?.action && (parameters.deposit !== undefined || maxDeposit !== undefined)) + const shouldAutoManage = + parameters.deposit !== undefined || + maxDeposit !== undefined || + context?.channelId !== undefined || + context?.depositRaw !== undefined + + if (!context?.action && shouldAutoManage) return autoManageCredential(challenge, account, context) if (context?.action) return manualCredential(challenge, account, context) diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index 7abbdb0b..3a073d59 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -1,9 +1,12 @@ -import type { Hex } from 'viem' -import { describe, expect, test, vi } from 'vp/test' +import type { Address, Hex } from 'viem' +import { describe, expect, test, vi } from 'vitest' import * as Challenge from '../../Challenge.js' +import { serializeSessionReceipt } from '../session/Receipt.js' import { formatNeedVoucherEvent, parseEvent } from '../session/Sse.js' import type { NeedVoucherEvent, SessionReceipt } from '../session/Types.js' +import type { ChannelEntry } from './ChannelOps.js' +import { UnrecoverableRestoreError } from './Session.js' import { sessionManager } from './SessionManager.js' const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex @@ -82,6 +85,59 @@ describe('Session', () => { expect(s.cumulative).toBe(0n) expect(s.opened).toBe(false) }) + + test('creates restored session with immediate state', () => { + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + maxDeposit: '10', + restore: { + channelId, + cumulativeAmount: 5n, + }, + }) + + expect(s.channelId).toBe(channelId) + expect(s.cumulative).toBe(5n) + expect(s.opened).toBe(true) + }) + + test('rejects negative restored cumulative amount', () => { + expect(() => + sessionManager({ + account: '0x0000000000000000000000000000000000000001', + restore: { + channelId, + cumulativeAmount: -1n, + }, + }), + ).toThrow('restore.cumulativeAmount must be >= 0n') + }) + + test('rejects negative restored spent amount', () => { + expect(() => + sessionManager({ + account: '0x0000000000000000000000000000000000000001', + restore: { + channelId, + cumulativeAmount: 5n, + spent: -1n, + }, + }), + ).toThrow('restore.spent must be >= 0n') + }) + + test('rejects restored spent greater than cumulative amount', () => { + expect(() => + sessionManager({ + account: '0x0000000000000000000000000000000000000001', + restore: { + channelId, + cumulativeAmount: 5n, + spent: 6n, + }, + }), + ).toThrow('restore.spent must be <= restore.cumulativeAmount') + }) }) describe('.fetch()', () => { @@ -111,6 +167,267 @@ describe('Session', () => { 'no `deposit` or `maxDeposit` configured', ) }) + + test('reuses restored session on first 402 retry without overriding better live state', async () => { + vi.resetModules() + + let onChannelUpdate: ((entry: ChannelEntry) => void) | undefined + const createCredential = vi + .fn() + .mockImplementation(async ({ context }: { context?: any }) => { + if (context?.channelId) { + onChannelUpdate?.({ + channelId, + salt: '0x01' as Hex, + cumulativeAmount: 3n, + escrowContract: '0x0000000000000000000000000000000000000001' as Address, + chainId: 4217, + opened: true, + }) + } + + return 'credential' + }) + + vi.doMock('./Session.js', () => ({ + session: vi.fn((parameters: { onChannelUpdate?: (entry: ChannelEntry) => void }) => { + onChannelUpdate = parameters.onChannelUpdate + return { + name: 'tempo', + intent: 'session', + context: { + parse(value: unknown) { + return value + }, + }, + createCredential, + } + }), + UnrecoverableRestoreError, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + make402Response( + makeChallenge({ + methodDetails: { + escrowContract: '0x9d136eEa063eDE5418A6BC7bEafF009bBb6CFa70', + chainId: 4217, + }, + }), + ), + ) + .mockResolvedValueOnce(makeOkResponse('paid')) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + restore: { + channelId, + cumulativeAmount: 5n, + }, + }) + + const res = await s.fetch('https://api.example.com/data') + + expect(res.status).toBe(200) + expect(s.cumulative).toBe(3n) + expect(createCredential).toHaveBeenCalledWith({ + challenge: expect.anything(), + context: { + channelId, + cumulativeAmountRaw: '5', + }, + }) + } finally { + vi.doUnmock('./Session.js') + vi.resetModules() + } + }) + + test('deactivates restored reuse hint after failed reuse so later open can proceed', async () => { + vi.resetModules() + + const createCredential = vi + .fn() + .mockRejectedValueOnce( + new UnrecoverableRestoreError(channelId, 'closed or not found on-chain'), + ) + .mockResolvedValueOnce('open-credential') + + vi.doMock('./Session.js', () => ({ + session: vi.fn(() => ({ + name: 'tempo', + intent: 'session', + context: { + parse(value: unknown) { + return value + }, + }, + createCredential, + })), + UnrecoverableRestoreError, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + make402Response( + makeChallenge({ + methodDetails: { + escrowContract: '0x9d136eEa063eDE5418A6BC7bEafF009bBb6CFa70', + chainId: 4217, + }, + }), + ), + ) + .mockResolvedValueOnce(makeOkResponse('opened')) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + restore: { + channelId, + cumulativeAmount: 5n, + }, + }) + + await expect(s.fetch('https://api.example.com/data')).rejects.toThrow('cannot be reused') + await expect(s.open({ deposit: 7n })).resolves.toBeUndefined() + + expect(createCredential).toHaveBeenNthCalledWith(1, { + challenge: expect.anything(), + context: { + channelId, + cumulativeAmountRaw: '5', + }, + }) + expect(createCredential).toHaveBeenNthCalledWith(2, { + challenge: expect.anything(), + context: { + depositRaw: '7', + }, + }) + } finally { + vi.doUnmock('./Session.js') + vi.resetModules() + } + }) + + test('keeps restore hint after transient reuse error so a later retry can reuse again', async () => { + vi.resetModules() + + const createCredential = vi + .fn() + .mockRejectedValueOnce(new Error('rpc timeout')) + .mockResolvedValueOnce('retry-credential') + + vi.doMock('./Session.js', () => ({ + session: vi.fn(() => ({ + name: 'tempo', + intent: 'session', + context: { + parse(value: unknown) { + return value + }, + }, + createCredential, + })), + UnrecoverableRestoreError, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const mockFetch = vi.fn().mockResolvedValue(make402Response()) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + restore: { + channelId, + cumulativeAmount: 5n, + }, + }) + + await expect(s.fetch('https://api.example.com/data')).rejects.toThrow('rpc timeout') + await expect(s.fetch('https://api.example.com/data')).resolves.toBeTruthy() + + expect(createCredential).toHaveBeenNthCalledWith(1, { + challenge: expect.anything(), + context: { + channelId, + cumulativeAmountRaw: '5', + }, + }) + expect(createCredential).toHaveBeenNthCalledWith(2, { + challenge: expect.anything(), + context: { + channelId, + cumulativeAmountRaw: '5', + }, + }) + } finally { + vi.doUnmock('./Session.js') + vi.resetModules() + } + }) + + test('keeps fresh-session behavior unchanged on first 402 retry without restore', async () => { + vi.resetModules() + + const createCredential = vi.fn().mockResolvedValue('credential') + + vi.doMock('./Session.js', () => ({ + session: vi.fn(() => ({ + name: 'tempo', + intent: 'session', + context: { + parse(value: unknown) { + return value + }, + }, + createCredential, + })), + UnrecoverableRestoreError, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + make402Response( + makeChallenge({ + methodDetails: { + escrowContract: '0x9d136eEa063eDE5418A6BC7bEafF009bBb6CFa70', + chainId: 4217, + }, + }), + ), + ) + .mockResolvedValueOnce(makeOkResponse('paid')) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + }) + + const res = await s.fetch('https://api.example.com/data') + + expect(res.status).toBe(200) + expect(createCredential).toHaveBeenCalledWith({ + challenge: expect.anything(), + }) + } finally { + vi.doUnmock('./Session.js') + vi.resetModules() + } + }) }) describe('.open()', () => { @@ -122,9 +439,241 @@ describe('Session', () => { await expect(s.open()).rejects.toThrow('No challenge available') }) + + test('is no-op for restored sessions already considered open', async () => { + const mockFetch = vi.fn() + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + restore: { + channelId, + cumulativeAmount: 5n, + }, + }) + + await expect(s.open()).resolves.toBeUndefined() + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('uses live cumulative after same-channel update instead of stale restored spent', async () => { + vi.resetModules() + + const createCredential = vi.fn().mockResolvedValue('credential') + let onChannelUpdate: ((entry: ChannelEntry) => void) | undefined + + vi.doMock('./Session.js', () => ({ + session: vi.fn((parameters: { onChannelUpdate?: (entry: ChannelEntry) => void }) => { + onChannelUpdate = parameters.onChannelUpdate + return { createCredential } + }), + UnrecoverableRestoreError, + })) + + vi.doMock('../../client/internal/Fetch.js', () => ({ + from: ({ + fetch, + onChallenge, + }: { + fetch: typeof globalThis.fetch + onChallenge: Function + }) => { + return async (input: RequestInfo | URL, init?: RequestInit) => { + await onChallenge(makeChallenge(), { createCredential }) + return fetch(input, init) + } + }, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeOkResponse()) + .mockResolvedValueOnce(makeOkResponse()) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + restore: { + channelId, + cumulativeAmount: 5n, + }, + }) + + onChannelUpdate?.({ + channelId, + salt: '0x01' as Hex, + cumulativeAmount: 3n, + escrowContract: '0x0000000000000000000000000000000000000001' as Address, + chainId: 4217, + opened: true, + }) + + await s.fetch('https://api.example.com/data') + await s.close() + + expect(createCredential).toHaveBeenCalledWith({ + challenge: expect.anything(), + context: { + action: 'close', + channelId, + cumulativeAmountRaw: '3', + }, + }) + } finally { + vi.doUnmock('./Session.js') + vi.doUnmock('../../client/internal/Fetch.js') + vi.resetModules() + } + }) }) describe('.sse() event parsing', () => { + test('restored sse resumes same channel when required cumulative exceeds current', async () => { + vi.resetModules() + + const createCredential = vi + .fn() + .mockImplementation(async ({ context }: { context?: any }) => { + return `voucher:${context?.cumulativeAmountRaw}` + }) + const helperCreateCredential = vi.fn().mockResolvedValue('restore:5000000') + + vi.doMock('./Session.js', () => ({ + session: vi.fn(() => ({ + createCredential, + })), + UnrecoverableRestoreError, + })) + + vi.doMock('../../client/internal/Fetch.js', () => ({ + from: ({ + fetch, + onChallenge, + }: { + fetch: typeof globalThis.fetch + onChallenge: Function + }) => { + return async (input: RequestInfo | URL, init?: RequestInit) => { + await onChallenge(makeChallenge(), { createCredential: helperCreateCredential }) + return fetch(input, init) + } + }, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const needVoucher: NeedVoucherEvent = { + channelId, + requiredCumulative: '6000000', + acceptedCumulative: '5000000', + deposit: '10000000', + } + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeSseResponse([formatNeedVoucherEvent(needVoucher)])) + .mockResolvedValueOnce(makeOkResponse()) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + restore: { + channelId, + cumulativeAmount: 5_000_000n, + }, + }) + + const iterable = await s.sse('https://api.example.com/stream') + for await (const _ of iterable) { + } + + expect(mockFetch).toHaveBeenNthCalledWith(2, 'https://api.example.com/stream', { + method: 'POST', + headers: { Authorization: 'voucher:6000000' }, + }) + expect(s.channelId).toBe(channelId) + expect(s.cumulative).toBe(6000000n) + } finally { + vi.doUnmock('./Session.js') + vi.doUnmock('../../client/internal/Fetch.js') + vi.resetModules() + } + }) + + test('restored sse keeps higher current cumulative when it already exceeds required', async () => { + vi.resetModules() + + const createCredential = vi + .fn() + .mockImplementation(async ({ context }: { context?: any }) => { + return `voucher:${context?.cumulativeAmountRaw}` + }) + const helperCreateCredential = vi.fn().mockResolvedValue('restore:7000000') + + vi.doMock('./Session.js', () => ({ + session: vi.fn(() => ({ + createCredential, + })), + UnrecoverableRestoreError, + })) + + vi.doMock('../../client/internal/Fetch.js', () => ({ + from: ({ + fetch, + onChallenge, + }: { + fetch: typeof globalThis.fetch + onChallenge: Function + }) => { + return async (input: RequestInfo | URL, init?: RequestInit) => { + await onChallenge(makeChallenge(), { createCredential: helperCreateCredential }) + return fetch(input, init) + } + }, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const needVoucher: NeedVoucherEvent = { + channelId, + requiredCumulative: '6000000', + acceptedCumulative: '5000000', + deposit: '10000000', + } + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeSseResponse([formatNeedVoucherEvent(needVoucher)])) + .mockResolvedValueOnce(makeOkResponse()) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + restore: { + channelId, + cumulativeAmount: 7_000_000n, + }, + }) + + const iterable = await s.sse('https://api.example.com/stream') + for await (const _ of iterable) { + } + + expect(mockFetch).toHaveBeenNthCalledWith(2, 'https://api.example.com/stream', { + method: 'POST', + headers: { Authorization: 'voucher:7000000' }, + }) + expect(s.channelId).toBe(channelId) + expect(s.cumulative).toBe(7000000n) + } finally { + vi.doUnmock('./Session.js') + vi.doUnmock('../../client/internal/Fetch.js') + vi.resetModules() + } + }) + test('yields only message data from SSE stream', async () => { const events = [ 'event: message\ndata: chunk1\n\n', @@ -155,9 +704,6 @@ describe('Session', () => { fetch: mockFetch as typeof globalThis.fetch, }) - // Manually set channel state to skip auto-open flow - ;(s as any).__test_setChannel?.() - const receiptCb = vi.fn() const iterable = await s.sse('https://api.example.com/stream', { onReceipt: receiptCb, @@ -172,6 +718,88 @@ describe('Session', () => { expect(receiptCb).toHaveBeenCalledOnce() expect(receiptCb.mock.calls[0]![0].units).toBe(2) }) + + test('keeps non-restored SSE voucher behavior unchanged', async () => { + vi.resetModules() + + const createCredential = vi + .fn() + .mockImplementation(async ({ context }: { context?: any }) => { + return `voucher:${context?.cumulativeAmountRaw}` + }) + let onChannelUpdate: ((entry: ChannelEntry) => void) | undefined + + vi.doMock('./Session.js', () => ({ + session: vi.fn((parameters: { onChannelUpdate?: (entry: ChannelEntry) => void }) => { + onChannelUpdate = parameters.onChannelUpdate + return { createCredential } + }), + UnrecoverableRestoreError, + })) + + vi.doMock('../../client/internal/Fetch.js', () => ({ + from: ({ + fetch, + onChallenge, + }: { + fetch: typeof globalThis.fetch + onChallenge: Function + }) => { + return async (input: RequestInfo | URL, init?: RequestInit) => { + await onChallenge(makeChallenge(), {}) + return fetch(input, init) + } + }, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const needVoucher: NeedVoucherEvent = { + channelId, + requiredCumulative: '2000000', + acceptedCumulative: '1000000', + deposit: '10000000', + } + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeSseResponse([formatNeedVoucherEvent(needVoucher)])) + .mockResolvedValueOnce(makeOkResponse()) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + }) + + onChannelUpdate?.({ + channelId, + salt: '0x01' as Hex, + cumulativeAmount: 1_000_000n, + escrowContract: '0x0000000000000000000000000000000000000001' as Address, + chainId: 4217, + opened: true, + }) + + const iterable = await s.sse('https://api.example.com/stream') + for await (const _ of iterable) { + } + + expect(createCredential).toHaveBeenLastCalledWith({ + challenge: expect.anything(), + context: { + action: 'voucher', + channelId, + cumulativeAmountRaw: '2000000', + }, + }) + expect(s.channelId).toBe(channelId) + expect(s.cumulative).toBe(2000000n) + } finally { + vi.doUnmock('./Session.js') + vi.doUnmock('../../client/internal/Fetch.js') + vi.resetModules() + } + }) }) describe('error handling', () => { @@ -236,6 +864,246 @@ describe('Session', () => { }) describe('.close()', () => { + test('uses newer receipt spent for restored-only close after a fresh request', async () => { + vi.resetModules() + + const createCredential = vi + .fn() + .mockImplementation(async ({ context }: { context?: any }) => { + if (context?.action === 'close') return `close:${context.cumulativeAmountRaw}` + return 'restore:5000000' + }) + + vi.doMock('./Session.js', () => ({ + session: vi.fn(() => ({ + createCredential, + })), + UnrecoverableRestoreError, + })) + + vi.doMock('../../client/internal/Fetch.js', () => ({ + from: ({ + fetch, + onChallenge, + }: { + fetch: typeof globalThis.fetch + onChallenge: Function + }) => { + return async (input: RequestInfo | URL, init?: RequestInit) => { + await onChallenge(makeChallenge(), { createCredential }) + return fetch(input, init) + } + }, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const receipt: SessionReceipt = { + method: 'tempo', + intent: 'session', + status: 'success', + timestamp: '2025-01-01T00:00:00.000Z', + reference: channelId, + challengeId, + channelId, + acceptedCumulative: '5000000', + spent: '4000000', + units: 4, + } + + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response('paid', { + status: 200, + headers: { 'Payment-Receipt': serializeSessionReceipt(receipt) }, + }), + ) + .mockResolvedValueOnce(makeOkResponse()) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + restore: { + channelId, + cumulativeAmount: 5_000_000n, + spent: 3_000_000n, + }, + }) + + await s.fetch('https://api.example.com/data') + await s.close() + + expect(createCredential).toHaveBeenLastCalledWith({ + challenge: expect.anything(), + context: { + action: 'close', + channelId, + cumulativeAmountRaw: '4000000', + }, + }) + } finally { + vi.doUnmock('./Session.js') + vi.doUnmock('../../client/internal/Fetch.js') + vi.resetModules() + } + }) + + test('same-channel live update does not raise restored spent above last accepted amount', async () => { + vi.resetModules() + + const createCredential = vi + .fn() + .mockImplementation(async ({ context }: { context?: any }) => { + if (context?.action === 'close') return `close:${context.cumulativeAmountRaw}` + return 'restore:5000000' + }) + let onChannelUpdate: ((entry: ChannelEntry) => void) | undefined + + vi.doMock('./Session.js', () => ({ + session: vi.fn((parameters: { onChannelUpdate?: (entry: ChannelEntry) => void }) => { + onChannelUpdate = parameters.onChannelUpdate + return { createCredential } + }), + UnrecoverableRestoreError, + })) + + vi.doMock('../../client/internal/Fetch.js', () => ({ + from: ({ + fetch, + onChallenge, + }: { + fetch: typeof globalThis.fetch + onChallenge: Function + }) => { + return async (input: RequestInfo | URL, init?: RequestInit) => { + await onChallenge(makeChallenge(), { createCredential }) + return fetch(input, init) + } + }, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeOkResponse('paid')) + .mockResolvedValueOnce(makeOkResponse()) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + restore: { + channelId, + cumulativeAmount: 5_000_000n, + spent: 3_000_000n, + }, + }) + + onChannelUpdate?.({ + channelId, + salt: '0x01' as Hex, + cumulativeAmount: 5_000_000n, + escrowContract: '0x0000000000000000000000000000000000000001' as Address, + chainId: 4217, + opened: true, + }) + + await s.fetch('https://api.example.com/data') + await s.close() + + expect(createCredential).toHaveBeenLastCalledWith({ + challenge: expect.anything(), + context: { + action: 'close', + channelId, + cumulativeAmountRaw: '3000000', + }, + }) + } finally { + vi.doUnmock('./Session.js') + vi.doUnmock('../../client/internal/Fetch.js') + vi.resetModules() + } + }) + + test('restored close stays unavailable before any fresh request', async () => { + const mockFetch = vi.fn() + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + restore: { + channelId, + cumulativeAmount: 5_000_000n, + spent: 3_000_000n, + }, + }) + + await expect(s.close()).resolves.toBeUndefined() + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('restored-only close clears opened state after success', async () => { + vi.resetModules() + + const createCredential = vi + .fn() + .mockImplementation(async ({ context }: { context?: any }) => { + if (context?.action === 'close') return `close:${context.cumulativeAmountRaw}` + return 'restore:5000000' + }) + + vi.doMock('./Session.js', () => ({ + session: vi.fn(() => ({ + createCredential, + })), + UnrecoverableRestoreError, + })) + + vi.doMock('../../client/internal/Fetch.js', () => ({ + from: ({ + fetch, + onChallenge, + }: { + fetch: typeof globalThis.fetch + onChallenge: Function + }) => { + return async (input: RequestInfo | URL, init?: RequestInit) => { + await onChallenge(makeChallenge(), { createCredential }) + return fetch(input, init) + } + }, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeOkResponse('paid')) + .mockResolvedValueOnce(makeOkResponse()) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + restore: { + channelId, + cumulativeAmount: 5_000_000n, + spent: 3_000_000n, + }, + }) + + expect(s.opened).toBe(true) + await s.fetch('https://api.example.com/data') + await s.close() + expect(s.opened).toBe(false) + } finally { + vi.doUnmock('./Session.js') + vi.doUnmock('../../client/internal/Fetch.js') + vi.resetModules() + } + }) + test('is no-op when not opened', async () => { const mockFetch = vi.fn() @@ -247,5 +1115,91 @@ describe('Session', () => { await s.close() expect(mockFetch).not.toHaveBeenCalled() }) + + test('keeps non-restored close behavior unchanged after live request', async () => { + vi.resetModules() + + const createCredential = vi + .fn() + .mockImplementation(async ({ context }: { context?: any }) => { + return `close:${context?.cumulativeAmountRaw}` + }) + let onChannelUpdate: ((entry: ChannelEntry) => void) | undefined + + vi.doMock('./Session.js', () => ({ + session: vi.fn((parameters: { onChannelUpdate?: (entry: ChannelEntry) => void }) => { + onChannelUpdate = parameters.onChannelUpdate + return { createCredential } + }), + UnrecoverableRestoreError, + })) + + vi.doMock('../../client/internal/Fetch.js', () => ({ + from: ({ + fetch, + onChallenge, + }: { + fetch: typeof globalThis.fetch + onChallenge: Function + }) => { + return async (input: RequestInfo | URL, init?: RequestInit) => { + await onChallenge(makeChallenge(), { createCredential }) + return fetch(input, init) + } + }, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const receipt: SessionReceipt = { + method: 'tempo', + intent: 'session', + status: 'success', + timestamp: '2025-01-01T00:00:00.000Z', + reference: channelId, + challengeId, + channelId, + acceptedCumulative: '4000000', + spent: '4000000', + units: 4, + } + + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + new Response('paid', { + status: 200, + headers: { 'Payment-Receipt': serializeSessionReceipt(receipt) }, + }), + ) + .mockResolvedValueOnce(makeOkResponse()) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + }) + + onChannelUpdate?.({ + channelId, + salt: '0x01' as Hex, + cumulativeAmount: 4_000_000n, + escrowContract: '0x0000000000000000000000000000000000000001' as Address, + chainId: 4217, + opened: true, + }) + + await s.fetch('https://api.example.com/data') + await s.close() + + expect(mockFetch).toHaveBeenNthCalledWith(2, 'https://api.example.com/data', { + method: 'POST', + headers: { Authorization: 'close:4000000' }, + }) + } finally { + vi.doUnmock('./Session.js') + vi.doUnmock('../../client/internal/Fetch.js') + vi.resetModules() + } + }) }) }) diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index 0445b434..9d21ae35 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -9,7 +9,7 @@ import { deserializeSessionReceipt } from '../session/Receipt.js' import { parseEvent } from '../session/Sse.js' import type { SessionReceipt } from '../session/Types.js' import type { ChannelEntry } from './ChannelOps.js' -import { session as sessionPlugin } from './Session.js' +import { session as sessionPlugin, UnrecoverableRestoreError } from './Session.js' export type SessionManager = { readonly channelId: Hex.Hex | undefined @@ -45,22 +45,59 @@ export type PaymentResponse = Response & { * * ## Session resumption * - * All channel state is held **in memory**. If the client process restarts, - * the session is lost and a new on-chain channel will be opened on the next - * request — the previous channel's deposit is orphaned until manually closed. + * All channel state is held **in memory** by default. Persistence is + * **caller-owned**: if you want to survive process restarts, save the current + * `channelId`, cumulative amount, and optionally `spent`, then pass them back + * via `restore` when constructing a new manager. * * When the server includes a `channelId` in the 402 challenge `methodDetails`, * the client will attempt to recover the channel by reading its on-chain state * via `getOnChainChannel()`. If the channel has a positive deposit and is not * finalized, it resumes from the on-chain settled amount. + * + * Restored sessions are treated as already open for getters, fetch, SSE, and + * voucher continuation. However, `.close()` still depends on a fresh request + * after restart because the manager must first receive a new 402 challenge and + * remember the request URL (`lastChallenge` / `lastUrl`) before it can create + * and submit a close credential. */ export function sessionManager(parameters: sessionManager.Parameters): SessionManager { const fetchFn = parameters.fetch ?? globalThis.fetch + const restore = parameters.restore + + if (restore) { + if (restore.cumulativeAmount < 0n) { + throw new Error('restore.cumulativeAmount must be >= 0n') + } + if (restore.spent !== undefined && restore.spent < 0n) { + throw new Error('restore.spent must be >= 0n') + } + if (restore.spent !== undefined && restore.spent > restore.cumulativeAmount) { + throw new Error('restore.spent must be <= restore.cumulativeAmount') + } + } let channel: ChannelEntry | null = null + let restored: sessionManager.Restore | null = restore ?? null let lastChallenge: Challenge.Challenge | null = null let lastUrl: RequestInfo | URL | null = null - let spent = 0n + let spent = restore?.spent ?? restore?.cumulativeAmount ?? 0n + + function restoreContext() { + if (!restored || channel) return undefined + return { + channelId: restored.channelId, + cumulativeAmountRaw: restored.cumulativeAmount.toString(), + } + } + + function activeChannelId() { + return channel?.channelId ?? restored?.channelId + } + + function activeCumulative() { + return channel?.cumulativeAmount ?? restored?.cumulativeAmount + } const method = sessionPlugin({ account: parameters.account, @@ -70,22 +107,40 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa decimals: parameters.decimals, maxDeposit: parameters.maxDeposit, onChannelUpdate(entry) { - if (entry.channelId !== channel?.channelId) spent = 0n + const previousChannelId = channel?.channelId ?? restored?.channelId + if (entry.channelId !== previousChannelId) { + spent = 0n + } else if (restored) { + spent = spent < entry.cumulativeAmount ? spent : entry.cumulativeAmount + } channel = entry + restored = null }, }) const wrappedFetch = Fetch.from({ fetch: fetchFn, methods: [method], - onChallenge: async (challenge, _helpers) => { + onChallenge: async (challenge, helpers) => { lastChallenge = challenge + const context = restoreContext() + if (context) { + try { + return await helpers.createCredential(context) + } catch (error) { + if (error instanceof UnrecoverableRestoreError) { + restored = null + } + throw error + } + } return undefined }, }) function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) { - if (!receipt || receipt.channelId !== channel?.channelId) return + const activeChannelId = channel?.channelId ?? restored?.channelId + if (!receipt || receipt.channelId !== activeChannelId) return const next = BigInt(receipt.spent) spent = spent > next ? spent : next } @@ -97,8 +152,8 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa return Object.assign(response, { receipt, challenge: lastChallenge, - channelId: channel?.channelId ?? null, - cumulative: channel?.cumulativeAmount ?? 0n, + channelId: channel?.channelId ?? restored?.channelId ?? null, + cumulative: channel?.cumulativeAmount ?? restored?.cumulativeAmount ?? 0n, }) } @@ -110,17 +165,17 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa const self: SessionManager = { get channelId() { - return channel?.channelId + return channel?.channelId ?? restored?.channelId }, get cumulative() { - return channel?.cumulativeAmount ?? 0n + return channel?.cumulativeAmount ?? restored?.cumulativeAmount ?? 0n }, get opened() { - return channel?.opened ?? false + return channel?.opened ?? !!restored }, async open(options) { - if (channel?.opened) return + if (channel?.opened || restored) return if (!lastChallenge) { throw new Error( @@ -202,17 +257,21 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa break case 'payment-need-voucher': { - if (!channel || !sseChallenge) break + const channelId = activeChannelId() + const currentCumulative = activeCumulative() + if (!channelId || currentCumulative === undefined || !sseChallenge) break const required = BigInt(event.data.requiredCumulative) - channel.cumulativeAmount = - channel.cumulativeAmount > required ? channel.cumulativeAmount : required + const nextCumulative = currentCumulative > required ? currentCumulative : required + + if (channel) channel.cumulativeAmount = nextCumulative + else if (restored) restored.cumulativeAmount = nextCumulative const credential = await method.createCredential({ challenge: sseChallenge as never, context: { action: 'voucher', - channelId: channel.channelId, - cumulativeAmountRaw: channel.cumulativeAmount.toString(), + channelId, + cumulativeAmountRaw: nextCumulative.toString(), }, }) const voucherResponse = await fetchFn(input, { @@ -241,13 +300,16 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa }, async close() { - if (!channel?.opened || !lastChallenge) return undefined + if ((!channel?.opened && !restored) || !lastChallenge) return undefined + + const activeChannelId = channel?.channelId ?? restored?.channelId + if (!activeChannelId) return undefined const credential = await method.createCredential({ challenge: lastChallenge as never, context: { action: 'close', - channelId: channel.channelId, + channelId: activeChannelId, cumulativeAmountRaw: spent.toString(), }, }) @@ -262,6 +324,10 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa if (receiptHeader) receipt = deserializeSessionReceipt(receiptHeader) } + if (!channel && restored && activeChannelId === restored.channelId) { + restored = null + } + return receipt }, } @@ -283,5 +349,23 @@ export declare namespace sessionManager { fetch?: typeof globalThis.fetch | undefined /** Maximum deposit in human-readable units (e.g. `'10'` for 10 tokens). Converted to raw units via `decimals`. */ maxDeposit?: string | undefined + /** + * Restores an already-open session channel after process restart. + * Persistence remains caller-owned: save these values externally and pass + * them back into a new manager instance when resuming. + * + * Note: `.close()` is still unavailable immediately after restart until a + * fresh request provides a new challenge and request URL. + */ + restore?: Restore | undefined } + + type Restore = { + /** Previously opened channel to resume. */ + channelId: Hex.Hex + /** Latest known cumulative voucher amount in raw units. */ + cumulativeAmount: bigint + /** Latest known spent amount in raw units. Defaults to `cumulativeAmount`. */ + spent?: bigint | undefined + } } From 72027700e6995b542d449c0e9dd0347b09992d33 Mon Sep 17 00:00:00 2001 From: caso Date: Sun, 22 Mar 2026 17:41:16 +0100 Subject: [PATCH 3/6] feat: export tempo client entrypoint --- package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package.json b/package.json index 9ab7ed3a..7b719178 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,11 @@ "src": "./src/tempo/index.ts", "default": "./dist/tempo/index.js" }, + "./tempo/client": { + "types": "./dist/tempo/client/index.d.ts", + "src": "./src/tempo/client/index.ts", + "default": "./dist/tempo/client/index.js" + }, "./hono": { "types": "./dist/middlewares/hono.d.ts", "src": "./src/middlewares/hono.ts", From e384abbe828c03c7c67eb36ff36a04e0adaa93af Mon Sep 17 00:00:00 2001 From: caso Date: Tue, 24 Mar 2026 14:44:11 +0100 Subject: [PATCH 4/6] fix: harden restored session manager sse flows --- src/tempo/client/SessionManager.test.ts | 85 +++++++++++++++++++++++++ src/tempo/client/SessionManager.ts | 20 +++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index 3a073d59..ac07e6bc 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -53,6 +53,13 @@ function makeSseResponse(events: string[]): Response { }) } +function makeProblemResponse(status: number, body: Record): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/problem+json' }, + }) +} + describe('Session', () => { describe('parseEvent round-trip via SSE', () => { test('parses message events from SSE stream', () => { @@ -530,6 +537,84 @@ describe('Session', () => { }) describe('.sse() event parsing', () => { + test('preserves headers instances while adding SSE accept header', async () => { + const mockFetch = vi.fn().mockResolvedValue(makeSseResponse([])) + + const s = sessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch as typeof globalThis.fetch, + }) + + const body = new TextEncoder().encode('{"stream":true}').buffer + await s.sse('https://api.example.com/stream', { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body, + }) + + const requestInit = mockFetch.mock.calls[0]?.[1] + const headers = new Headers(requestInit?.headers) + + expect(headers.get('Accept')).toBe('text/event-stream') + expect(headers.get('Content-Type')).toBe('application/json') + expect(requestInit?.body).toBe(body) + }) + + test('rejects restored SSE when paid response remains a 402 problem response', async () => { + vi.resetModules() + + const createCredential = vi.fn().mockResolvedValue('voucher') + const helperCreateCredential = vi.fn().mockResolvedValue('restore:5000000') + + vi.doMock('./Session.js', () => ({ + session: vi.fn(() => ({ + createCredential, + })), + UnrecoverableRestoreError, + })) + + vi.doMock('../../client/internal/Fetch.js', () => ({ + from: ({ + fetch, + onChallenge, + }: { + fetch: typeof globalThis.fetch + onChallenge: Function + }) => { + return async (input: RequestInfo | URL, init?: RequestInit) => { + await onChallenge(makeChallenge(), { createCredential: helperCreateCredential }) + return fetch(input, init) + } + }, + })) + + try { + const { sessionManager: isolatedSessionManager } = await import('./SessionManager.js') + const mockFetch = vi.fn().mockResolvedValue( + makeProblemResponse(402, { + type: 'https://example.com/problems/payment-required', + title: 'Payment Required', + detail: 'Session restore voucher was rejected', + }), + ) + + const s = isolatedSessionManager({ + account: '0x0000000000000000000000000000000000000001', + fetch: mockFetch, + restore: { + channelId, + cumulativeAmount: 5_000_000n, + }, + }) + + await expect(s.sse('https://api.example.com/stream')).rejects.toThrow(/status 402/i) + } finally { + vi.doUnmock('./Session.js') + vi.doUnmock('../../client/internal/Fetch.js') + vi.resetModules() + } + }) + test('restored sse resumes same channel when required cumulative exceeds current', async () => { vi.resetModules() diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index 9d21ae35..7566d999 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -157,6 +157,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa }) } + async function throwForBadSseResponse(response: PaymentResponse): Promise { + const contentType = response.headers.get('Content-Type') ?? '' + const body = await response.text().catch(() => '') + throw new Error( + `SSE request failed with status ${response.status}${contentType ? ` (${contentType})` : ''}${body ? `: ${body}` : ''}`, + ) + } + async function doFetch(input: RequestInfo | URL, init?: RequestInit): Promise { lastUrl = input const response = await wrappedFetch(input, init) @@ -209,7 +217,6 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa async sse(input, init) { const { onReceipt, signal, ...fetchInit } = init ?? {} - const sseInit = { ...fetchInit, headers: { @@ -221,6 +228,10 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa const response = await doFetch(input, sseInit) + if (!response.ok) { + await throwForBadSseResponse(response) + } + // Snapshot the challenge at SSE open time so concurrent // calls don't overwrite it. const sseChallenge = lastChallenge @@ -324,6 +335,13 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa if (receiptHeader) receipt = deserializeSessionReceipt(receiptHeader) } + if (channel && activeChannelId === channel.channelId) { + channel = { + ...channel, + opened: false, + } + } + if (!channel && restored && activeChannelId === restored.channelId) { restored = null } From 2be9636cb99b26f49f062ff33693114bbec5ad73 Mon Sep 17 00:00:00 2001 From: caso Date: Tue, 24 Mar 2026 14:57:31 +0100 Subject: [PATCH 5/6] chore: rebase restore support onto latest main --- src/tempo/client/SessionManager.test.ts | 20 ++++---------------- src/tempo/client/SessionManager.ts | 8 ++++---- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index ac07e6bc..bd2aa440 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -938,13 +938,10 @@ describe('Session', () => { // drain } - const calledHeaders = (mockFetch.mock.calls[0]![1] as RequestInit).headers as Record< - string, - string - > - expect(calledHeaders['content-type']).toBe('application/json') - expect(calledHeaders['x-custom']).toBe('value') - expect(calledHeaders.Accept).toBe('text/event-stream') + const calledHeaders = new Headers((mockFetch.mock.calls[0]![1] as RequestInit).headers) + expect(calledHeaders.get('content-type')).toBe('application/json') + expect(calledHeaders.get('x-custom')).toBe('value') + expect(calledHeaders.get('accept')).toBe('text/event-stream') }) }) @@ -1085,15 +1082,6 @@ describe('Session', () => { }, }) - onChannelUpdate?.({ - channelId, - salt: '0x01' as Hex, - cumulativeAmount: 5_000_000n, - escrowContract: '0x0000000000000000000000000000000000000001' as Address, - chainId: 4217, - opened: true, - }) - await s.fetch('https://api.example.com/data') await s.close() diff --git a/src/tempo/client/SessionManager.ts b/src/tempo/client/SessionManager.ts index 7566d999..c5e8b796 100644 --- a/src/tempo/client/SessionManager.ts +++ b/src/tempo/client/SessionManager.ts @@ -217,12 +217,12 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa async sse(input, init) { const { onReceipt, signal, ...fetchInit } = init ?? {} + const headers = new Headers(fetchInit.headers) + headers.set('Accept', 'text/event-stream') + const sseInit = { ...fetchInit, - headers: { - ...Fetch.normalizeHeaders(fetchInit.headers), - Accept: 'text/event-stream', - }, + headers, ...(signal ? { signal } : {}), } From 9ea4a1228f15e46dfe99e8bbde74d2df2969f58a Mon Sep 17 00:00:00 2001 From: caso Date: Wed, 25 Mar 2026 09:38:49 +0100 Subject: [PATCH 6/6] chore: align restore branch with vp test runner --- src/tempo/client/Session.test.ts | 6 +++--- src/tempo/client/SessionManager.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index 4deb5946..8ee617d2 100644 --- a/src/tempo/client/Session.test.ts +++ b/src/tempo/client/Session.test.ts @@ -1,7 +1,7 @@ import { type Address, createClient, type Hex, http } from 'viem' import { privateKeyToAccount } from 'viem/accounts' import { Addresses } from 'viem/tempo' -import { beforeAll, describe, expect, test, vi } from 'vitest' +import { beforeAll, describe, expect, test, vi } from 'vp/test' import { nodeEnv } from '~test/config.js' import { deployEscrow, openChannel } from '~test/tempo/session.js' import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js' @@ -210,7 +210,7 @@ describe('session (pure)', () => { vi.resetModules() const createVoucherPayload = vi.fn( - async (_client, _account, channelId, cumulativeAmount) => ({ + async (_client: unknown, _account: unknown, channelId: Hex, cumulativeAmount: bigint) => ({ action: 'voucher' as const, channelId, cumulativeAmount: cumulativeAmount.toString(), @@ -266,7 +266,7 @@ describe('session (pure)', () => { vi.resetModules() const createVoucherPayload = vi.fn( - async (_client, _account, channelId, cumulativeAmount) => ({ + async (_client: unknown, _account: unknown, channelId: Hex, cumulativeAmount: bigint) => ({ action: 'voucher' as const, channelId, cumulativeAmount: cumulativeAmount.toString(), diff --git a/src/tempo/client/SessionManager.test.ts b/src/tempo/client/SessionManager.test.ts index bd2aa440..78ea790b 100644 --- a/src/tempo/client/SessionManager.test.ts +++ b/src/tempo/client/SessionManager.test.ts @@ -1,5 +1,5 @@ import type { Address, Hex } from 'viem' -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, test, vi } from 'vp/test' import * as Challenge from '../../Challenge.js' import { serializeSessionReceipt } from '../session/Receipt.js'