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. 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/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", diff --git a/src/tempo/client/Session.test.ts b/src/tempo/client/Session.test.ts index b5ffcce6..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 } from 'vp/test' +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' @@ -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: unknown, _account: unknown, channelId: Hex, cumulativeAmount: bigint) => ({ + 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: unknown, _account: unknown, channelId: Hex, cumulativeAmount: bigint) => ({ + 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..78ea790b 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 type { Address, Hex } from 'viem' import { describe, expect, test, vi } from 'vp/test' 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 @@ -50,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', () => { @@ -82,6 +92,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 +174,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 +446,319 @@ 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('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() + + 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 +789,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 +803,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', () => { @@ -225,17 +938,245 @@ 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') }) }) 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, + }, + }) + + 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 +1188,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..c5e8b796 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,11 +152,19 @@ 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, }) } + 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) @@ -110,17 +173,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( @@ -154,18 +217,21 @@ 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 } : {}), } 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 @@ -202,17 +268,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 +311,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 +335,17 @@ 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 + } + return receipt }, } @@ -283,5 +367,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 + } }