@routedock/routedock 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +305 -0
- package/dist/MppSessionHandler-1EL8QBAn.d.cts +215 -0
- package/dist/MppSessionHandler-Bq4S-JTE.d.ts +215 -0
- package/dist/{ProviderRegistry-C50nyhaF.d.ts → ProviderRegistry-CA_TdREG.d.ts} +2 -2
- package/dist/{ProviderRegistry-BJ9oJLVz.d.cts → ProviderRegistry-D-0rqK0N.d.cts} +2 -2
- package/dist/{RouteDockClient-B1roCa8h.d.cts → RouteDockClient-Bh86bVly.d.ts} +23 -9
- package/dist/{RouteDockClient-BbkqjhMG.d.ts → RouteDockClient-go9aopfF.d.cts} +23 -9
- package/dist/{SessionReconciler-Dscw43F3.d.ts → SessionReconciler-Bw-PoBVl.d.ts} +14 -5
- package/dist/{SessionReconciler-bi0z2qtN.d.cts → SessionReconciler-Cz2nuOlJ.d.cts} +14 -5
- package/dist/{SessionStore-DvkZv8-E.d.ts → SessionStore-B4-Hi4H8.d.cts} +34 -2
- package/dist/{SessionStore-BpK8vv0L.d.cts → SessionStore-BaYiegP0.d.ts} +34 -2
- package/dist/client.cjs +429 -117
- package/dist/client.d.cts +9 -8
- package/dist/client.d.ts +9 -8
- package/dist/client.js +421 -119
- package/dist/index.cjs +652 -220
- package/dist/index.d.cts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +643 -221
- package/dist/{types-DmhmfLzP.d.cts → logger-D8_Q2ytp.d.cts} +58 -1
- package/dist/{types-DmhmfLzP.d.ts → logger-D8_Q2ytp.d.ts} +58 -1
- package/dist/provider/fastify.cjs +306 -89
- package/dist/provider/fastify.d.cts +9 -4
- package/dist/provider/fastify.d.ts +9 -4
- package/dist/provider/fastify.js +305 -88
- package/dist/provider/hono.cjs +286 -82
- package/dist/provider/hono.d.cts +9 -7
- package/dist/provider/hono.d.ts +9 -7
- package/dist/provider/hono.js +282 -78
- package/dist/provider.cjs +326 -98
- package/dist/provider.d.cts +14 -10
- package/dist/provider.d.ts +14 -10
- package/dist/provider.js +318 -98
- package/dist/react.cjs +407 -117
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +408 -118
- package/dist/{routedockMiddleware-BlDsHuPl.d.ts → routedockMiddleware-57gUaEMf.d.ts} +9 -4
- package/dist/{routedockMiddleware-D97A5ptQ.d.cts → routedockMiddleware-DlyLrxp7.d.cts} +9 -4
- package/dist/schema.cjs +40 -0
- package/dist/schema.js +40 -0
- package/dist/{sign-Dpj2Mp7k.d.ts → sign-AKH67sft.d.cts} +1 -1
- package/dist/{sign-CUf3rhTx.d.cts → sign-BEq-OsSq.d.ts} +1 -1
- package/dist/testing.cjs +6 -4
- package/dist/testing.d.cts +32 -13
- package/dist/testing.d.ts +32 -13
- package/dist/testing.js +6 -4
- package/package.json +2 -2
- package/dist/MppSessionHandler-B4TyD7Xg.d.cts +0 -121
- package/dist/MppSessionHandler-CLB5SzgN.d.ts +0 -121
package/README.md
CHANGED
|
@@ -136,6 +136,311 @@ app.use('/stream', routedock({
|
|
|
136
136
|
| `onVoucher(index, cumulativeAmount)` | Each verified ed25519 commitment | No |
|
|
137
137
|
| `onSettled(txHash, amount, mode)` | Channel close transaction confirmed | Yes |
|
|
138
138
|
|
|
139
|
+
## Running a provider in production
|
|
140
|
+
|
|
141
|
+
The `routedockHono` defaults — `InMemorySeenTxStore` for `seenTxStore` and
|
|
142
|
+
`Store.memory()` for `sessionStore` — are only safe in a single long-lived
|
|
143
|
+
process. Any runtime that can route requests to different isolates or
|
|
144
|
+
processes (Cloudflare Workers, Deno Deploy, Lambda@Edge, or even a
|
|
145
|
+
multi-instance Node deployment) needs the durable stores below, or an agent
|
|
146
|
+
retry can settle a payment twice or lose voucher tracking mid-session.
|
|
147
|
+
|
|
148
|
+
### Settlement idempotency (`seenTxStore`)
|
|
149
|
+
|
|
150
|
+
`seenTxStore` guards against double-settlement when an agent retries the same
|
|
151
|
+
payment after a timeout. The in-memory default is per-handler and per-process,
|
|
152
|
+
so a retry landing in a fresh isolate misses the cache and settles again,
|
|
153
|
+
charging twice on-chain.
|
|
154
|
+
|
|
155
|
+
`SupabaseSeenTxStore` backs it with Postgres, which gives the read-your-writes
|
|
156
|
+
consistency this needs. **Don't use Workers KV here** — it's eventually
|
|
157
|
+
consistent, so a retry can outrun propagation and reintroduce the double
|
|
158
|
+
settle.
|
|
159
|
+
|
|
160
|
+
Requires the `settlements` table from migration `003_settlement_idempotency`
|
|
161
|
+
(see `supabase/migrations/003_settlement_idempotency.sql`; the follow-up
|
|
162
|
+
migration `004_settlement_retention.sql` adds cleanup for old rows). The store
|
|
163
|
+
needs two required methods, `get`/`set`, plus an optional `claimReplay`;
|
|
164
|
+
`SupabaseSeenTxStore` also needs `006_settlement_replay_limit.sql`. The
|
|
165
|
+
idempotency key is scoped to the method, path, amount and payee, and a cached
|
|
166
|
+
settlement is only replayed within a 60s window and a bounded number of times:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { createClient } from '@supabase/supabase-js'
|
|
170
|
+
import { routedockHono, SupabaseSeenTxStore } from '@routedock/routedock/provider/hono'
|
|
171
|
+
|
|
172
|
+
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_KEY)
|
|
173
|
+
const seenTxStore = new SupabaseSeenTxStore(supabase)
|
|
174
|
+
|
|
175
|
+
app.use('*', routedockHono({
|
|
176
|
+
modes: ['x402', 'mpp-charge'],
|
|
177
|
+
pricing: { x402: '0.001', 'mpp-charge': '0.0008' },
|
|
178
|
+
asset: 'USDC',
|
|
179
|
+
assetContract: env.USDC_ASSET_CONTRACT,
|
|
180
|
+
payee: env.STELLAR_PAYEE_ADDRESS,
|
|
181
|
+
payeeSecretKey: env.STELLAR_PAYEE_SECRET,
|
|
182
|
+
network: 'testnet',
|
|
183
|
+
manifest,
|
|
184
|
+
seenTxStore,
|
|
185
|
+
}))
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
On Cloudflare Workers, `InMemorySeenTxStore` logs a `console.warn` when
|
|
189
|
+
constructed so this isn't missed silently — but that check only detects the
|
|
190
|
+
Workers user agent, so Bun and Deno operators get no warning today. Don't rely
|
|
191
|
+
on it; wire a durable store explicitly.
|
|
192
|
+
|
|
193
|
+
### Session state (`sessionStore`)
|
|
194
|
+
|
|
195
|
+
`sessionStore` backs the `mpp-session` channel store (voucher monotonicity
|
|
196
|
+
tracking). The in-memory default doesn't survive isolate eviction, and
|
|
197
|
+
`MppSessionClient` issues one HTTP request per voucher — so voucher N and
|
|
198
|
+
voucher N+1 can land in different isolates entirely.
|
|
199
|
+
|
|
200
|
+
> **Note:** this `sessionStore` option takes a `@stellar/mpp` `Store.Store`.
|
|
201
|
+
> That's a different thing from this SDK's own `SessionStore` /
|
|
202
|
+
> `SupabaseSessionStore` (in `packages/sdk/src/store/SessionStore.ts`), which
|
|
203
|
+
> is a separate interface over the Supabase `sessions` table used for
|
|
204
|
+
> dashboards and reconciliation. Don't confuse the two.
|
|
205
|
+
|
|
206
|
+
On Cloudflare Workers, back it with a Durable Object per channel so voucher
|
|
207
|
+
reads and writes serialize through one instance:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { DurableObject } from 'cloudflare:workers'
|
|
211
|
+
import { Hono } from 'hono'
|
|
212
|
+
import { routedockHono } from '@routedock/routedock/provider/hono'
|
|
213
|
+
import { Store } from '@stellar/mpp/channel/server'
|
|
214
|
+
|
|
215
|
+
// The provider needs @stellar/mpp as a direct dependency for Store.
|
|
216
|
+
|
|
217
|
+
export class ChannelSession extends DurableObject<Env> {
|
|
218
|
+
private app: Hono | null = null
|
|
219
|
+
|
|
220
|
+
private buildApp(): Hono {
|
|
221
|
+
const env = this.env
|
|
222
|
+
const sessionStore = Store.from({
|
|
223
|
+
get: (key: string) => this.ctx.storage.get(key),
|
|
224
|
+
put: (key: string, value: unknown) => this.ctx.storage.put(key, value),
|
|
225
|
+
delete: async (key: string) => {
|
|
226
|
+
await this.ctx.storage.delete(key)
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const app = new Hono()
|
|
231
|
+
app.use('*', routedockHono({
|
|
232
|
+
modes: ['mpp-session'],
|
|
233
|
+
pricing: {
|
|
234
|
+
'mpp-session': { rate: '0.0001', channelFactory: env.CHANNEL_CONTRACT_ID },
|
|
235
|
+
},
|
|
236
|
+
asset: 'USDC',
|
|
237
|
+
assetContract: env.USDC_ASSET_CONTRACT,
|
|
238
|
+
payee: env.STELLAR_PAYEE_ADDRESS,
|
|
239
|
+
payeeSecretKey: env.STELLAR_PAYEE_SECRET,
|
|
240
|
+
network: 'testnet',
|
|
241
|
+
commitmentPublicKey: env.COMMITMENT_PUBLIC_KEY!,
|
|
242
|
+
sessionStore,
|
|
243
|
+
manifest,
|
|
244
|
+
}))
|
|
245
|
+
return app
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
override async fetch(request: Request): Promise<Response> {
|
|
249
|
+
this.app ??= this.buildApp()
|
|
250
|
+
return this.app.fetch(request)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Route all requests for a given channel to the same object, keyed by channel
|
|
256
|
+
contract, so vouchers for one channel always serialize through one instance:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
export default {
|
|
260
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
261
|
+
const id = env.CHANNEL_SESSION.idFromName(env.CHANNEL_CONTRACT_ID)
|
|
262
|
+
return env.CHANNEL_SESSION.get(id).fetch(request)
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Wire the binding and migration in `wrangler.jsonc`:
|
|
268
|
+
|
|
269
|
+
```jsonc
|
|
270
|
+
{
|
|
271
|
+
"durable_objects": {
|
|
272
|
+
"bindings": [{ "name": "CHANNEL_SESSION", "class_name": "ChannelSession" }]
|
|
273
|
+
},
|
|
274
|
+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ChannelSession"] }]
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Limitation:** the Durable Object persists the `@stellar/mpp` channel store
|
|
279
|
+
only. `routedockHono` still tracks `lastCumulativeAmount`, `voucherCount`,
|
|
280
|
+
`lastSignatureHex` and `sessionPayerAddress` in closure scope, so if the
|
|
281
|
+
instance is evicted mid-session those reset, and the close path falls back to
|
|
282
|
+
the client-supplied amount and signature. Moving them into the same durable
|
|
283
|
+
store is tracked separately — check the current state of that work before
|
|
284
|
+
relying on it.
|
|
285
|
+
|
|
286
|
+
### Orphaned sessions and reconciliation
|
|
287
|
+
|
|
288
|
+
Without recovery wiring, a session abandoned mid-stream (client disconnects,
|
|
289
|
+
goes idle) just sits open forever with funds locked in the channel. Two pieces
|
|
290
|
+
are required together:
|
|
291
|
+
|
|
292
|
+
`onOrphaned` writes the session row as `status: 'closing'` (the `sessions`
|
|
293
|
+
table comes from migration `001_init.sql`) so the reconciler can find it.
|
|
294
|
+
|
|
295
|
+
`idleTimeoutMs` has to be set explicitly. Without it no idle timer is armed,
|
|
296
|
+
and an idle (as opposed to disconnected) session is never flagged orphaned.
|
|
297
|
+
|
|
298
|
+
On Cloudflare Workers it doesn't help yet. The idle timer is a `setTimeout`
|
|
299
|
+
inside the Durable Object, and Cloudflare evicts an idle object after roughly
|
|
300
|
+
70 to 140 seconds, so the timer never fires. provider-b has the same gap, see
|
|
301
|
+
[#314](https://github.com/winsznx/routedock/issues/314).
|
|
302
|
+
|
|
303
|
+
The status and cumulative-amount fields need to be written separately.
|
|
304
|
+
Writing `cumulative_amount` in the same update as `status: 'closing'` can
|
|
305
|
+
collide with the `monotonic_cumulative` trigger (`supabase/migrations/001_init.sql:40,48-50`),
|
|
306
|
+
which rejects any update where the new amount isn't strictly greater than the
|
|
307
|
+
stored one — and `onVoucher` has usually already stored that exact amount, so
|
|
308
|
+
the combined update silently fails and the row never reaches `closing`. This
|
|
309
|
+
is the same bug tracked as [#338](https://github.com/winsznx/routedock/issues/338):
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
app.use('*', routedockHono({
|
|
313
|
+
// ...
|
|
314
|
+
idleTimeoutMs: 5 * 60_000,
|
|
315
|
+
onOrphaned: async (channelId, info) => {
|
|
316
|
+
await supabase.from('sessions')
|
|
317
|
+
.update({ status: 'closing' })
|
|
318
|
+
.eq('channel_id', channelId)
|
|
319
|
+
await supabase.from('sessions')
|
|
320
|
+
.update({
|
|
321
|
+
cumulative_amount: info.cumulativeAmount,
|
|
322
|
+
last_signature: info.lastSignature || null,
|
|
323
|
+
voucher_count: info.voucherCount,
|
|
324
|
+
})
|
|
325
|
+
.eq('channel_id', channelId)
|
|
326
|
+
.lt('cumulative_amount', info.cumulativeAmount)
|
|
327
|
+
},
|
|
328
|
+
}))
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Then schedule `reconcileAbandonedSessions` to actually settle those `closing`
|
|
332
|
+
sessions with their latest voucher:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
import { reconcileAbandonedSessions } from '@routedock/routedock/provider/hono'
|
|
336
|
+
|
|
337
|
+
export default {
|
|
338
|
+
async scheduled(_controller: unknown, env: Env): Promise<void> {
|
|
339
|
+
await reconcileAbandonedSessions({
|
|
340
|
+
supabase,
|
|
341
|
+
network: 'testnet',
|
|
342
|
+
payeeSecretKey: env.STELLAR_PAYEE_SECRET,
|
|
343
|
+
onRecovered: async (channelId, txHash, totalPaid) => {
|
|
344
|
+
console.log(`[reconcile] recovered ${channelId}: ${txHash} (${totalPaid} USDC)`)
|
|
345
|
+
},
|
|
346
|
+
})
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Trigger it on a schedule in `wrangler.jsonc`:
|
|
352
|
+
|
|
353
|
+
```jsonc
|
|
354
|
+
{
|
|
355
|
+
"triggers": {
|
|
356
|
+
"crons": ["*/15 * * * *"]
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Registering in discovery
|
|
362
|
+
|
|
363
|
+
A provider doesn't show up in the discovery registry unless it registers
|
|
364
|
+
itself. Sign the manifest, then call `registerProvider` inside
|
|
365
|
+
`ctx.waitUntil(...)` with a `.catch` so a registry hiccup never blocks serving
|
|
366
|
+
a request:
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { registerProvider, signManifest } from '@routedock/routedock/provider/hono'
|
|
370
|
+
|
|
371
|
+
const signed = signManifest(manifest, env.STELLAR_PAYEE_SECRET)
|
|
372
|
+
ctx.waitUntil(
|
|
373
|
+
registerProvider({
|
|
374
|
+
supabase,
|
|
375
|
+
manifest: signed,
|
|
376
|
+
baseUrl: 'https://your-provider.example.com',
|
|
377
|
+
}).catch((err) => {
|
|
378
|
+
console.error('[registry] failed to register provider:', err)
|
|
379
|
+
}),
|
|
380
|
+
)
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
This writes to the `providers` table from migration `001_init.sql`.
|
|
384
|
+
|
|
385
|
+
### Serving `mpp-session-ws`
|
|
386
|
+
|
|
387
|
+
`mpp-session-ws` upgrades the HTTP connection to a WebSocket after the
|
|
388
|
+
handshake's payment is verified. Hono's `upgradeWebSocket` calls its callback
|
|
389
|
+
before it looks at the `Upgrade` header, so a guard placed only inside that
|
|
390
|
+
callback (throwing when `mppSessionWsVerified(c)` is false) also fires — and
|
|
391
|
+
throws — for an ordinary `mpp-session` GET on the same route, which has
|
|
392
|
+
nothing to do with a WebSocket handshake. Check the header in a separate
|
|
393
|
+
middleware ahead of the upgrade route instead, and let a second, plain route
|
|
394
|
+
serve `mpp-session` requests once the payment middleware has verified them:
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
import { upgradeWebSocket } from 'hono/cloudflare-workers'
|
|
398
|
+
import { routedockHono, mppSessionWsVerified } from '@routedock/routedock/provider/hono'
|
|
399
|
+
|
|
400
|
+
app.use('*', routedockHono({
|
|
401
|
+
modes: ['mpp-session', 'mpp-session-ws'],
|
|
402
|
+
pricing: {
|
|
403
|
+
'mpp-session': { rate: '0.0001', channelFactory: env.CHANNEL_CONTRACT_ID },
|
|
404
|
+
'mpp-session-ws': { rate: '0.0001', channelFactory: env.CHANNEL_CONTRACT_ID },
|
|
405
|
+
},
|
|
406
|
+
asset: 'USDC',
|
|
407
|
+
assetContract: env.USDC_ASSET_CONTRACT,
|
|
408
|
+
payee: env.STELLAR_PAYEE_ADDRESS,
|
|
409
|
+
payeeSecretKey: env.STELLAR_PAYEE_SECRET,
|
|
410
|
+
network: 'testnet',
|
|
411
|
+
commitmentPublicKey: env.COMMITMENT_PUBLIC_KEY!,
|
|
412
|
+
manifest,
|
|
413
|
+
}))
|
|
414
|
+
|
|
415
|
+
app.get(
|
|
416
|
+
'/stream',
|
|
417
|
+
async (c, next) => {
|
|
418
|
+
const isUpgrade = c.req.header('upgrade')?.toLowerCase() === 'websocket'
|
|
419
|
+
if (isUpgrade && !mppSessionWsVerified(c)) {
|
|
420
|
+
return c.json({ error: 'refusing unverified WebSocket handshake' }, 403)
|
|
421
|
+
}
|
|
422
|
+
await next()
|
|
423
|
+
},
|
|
424
|
+
upgradeWebSocket(() => ({
|
|
425
|
+
onMessage: (_evt, ws) => {
|
|
426
|
+
ws.send(JSON.stringify({ type: 'ack' }))
|
|
427
|
+
},
|
|
428
|
+
})),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
// mpp-session (HTTP) requests land here after the middleware verified the voucher.
|
|
432
|
+
app.get('/stream', (c) => c.json({ data: '...' }))
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Workers-safe imports
|
|
436
|
+
|
|
437
|
+
Every snippet above imports from `@routedock/routedock/provider/hono`, which
|
|
438
|
+
re-exports `routedockHono`, `mppSessionWsVerified`, `registerProvider`,
|
|
439
|
+
`signManifest`, `SupabaseSeenTxStore` and `reconcileAbandonedSessions`. Don't
|
|
440
|
+
import from `@routedock/routedock/provider` in a Workers snippet — that's the
|
|
441
|
+
Node-only Express entry point and pulls in dependencies (like `express`) that
|
|
442
|
+
don't run on Workers.
|
|
443
|
+
|
|
139
444
|
## React Integration
|
|
140
445
|
|
|
141
446
|
> **Warning:** These hooks create a RouteDockClient in the browser, so the wallet secret and any commitmentSecret are readable by anyone who loads the page.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { g as RouteDockLogger, h as RouteDockManifest } from './logger-D8_Q2ytp.cjs';
|
|
2
|
+
import { RequestHandler } from 'express';
|
|
3
|
+
import { Store } from 'mppx';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Idempotency store for payment settlement.
|
|
7
|
+
*
|
|
8
|
+
* `x402Handler` and `MppChargeHandler` settle on every request carrying a
|
|
9
|
+
* payment header. An agent that retries after a post-settlement network
|
|
10
|
+
* timeout resends the *same* signed payment, which would settle a second time
|
|
11
|
+
* and invoke `onSettled` twice — double-counting in billing.
|
|
12
|
+
*
|
|
13
|
+
* Before settling, a handler derives an idempotency key from the inbound
|
|
14
|
+
* payment header(s) *together with the resource being paid for* and checks
|
|
15
|
+
* this store. On a hit it replays the cached settlement response and skips
|
|
16
|
+
* both the on-chain settle and `onSettled`. A hit older than
|
|
17
|
+
* {@link SETTLEMENT_REPLAY_WINDOW_MS}, or one replayed more than
|
|
18
|
+
* {@link MAX_SETTLEMENT_REPLAYS} times, is rejected instead of replayed so a
|
|
19
|
+
* leaked header cannot buy unlimited requests.
|
|
20
|
+
*
|
|
21
|
+
* `claimReplay` and {@link SettlementRecord.createdAt} are optional so a
|
|
22
|
+
* custom store written against the original two-method (`get`/`set`) contract
|
|
23
|
+
* keeps compiling. See {@link checkSettlementReplay} for how a store that
|
|
24
|
+
* supplies neither bounds a replay.
|
|
25
|
+
*
|
|
26
|
+
* The default {@link InMemorySeenTxStore} is per-handler and per-process. For
|
|
27
|
+
* multi-instance deployments, supply a shared implementation backed by Redis,
|
|
28
|
+
* Supabase, etc.
|
|
29
|
+
*/
|
|
30
|
+
/** The resource a settlement idempotency key is scoped to. */
|
|
31
|
+
interface SettlementScope {
|
|
32
|
+
/** HTTP method of the paid request (e.g. `GET`). */
|
|
33
|
+
method: string;
|
|
34
|
+
/** Path of the paid request, without query string (e.g. `/price`). */
|
|
35
|
+
path: string;
|
|
36
|
+
/** Charge amount as declared by the route's requirements. */
|
|
37
|
+
amount: string;
|
|
38
|
+
/** Payee address the route settles to. */
|
|
39
|
+
payTo: string;
|
|
40
|
+
}
|
|
41
|
+
/** Cached outcome of a settlement, replayed on a duplicate payment. */
|
|
42
|
+
interface SettlementRecord {
|
|
43
|
+
/** On-chain transaction hash (or settlement reference), if known. */
|
|
44
|
+
txHash: string | null;
|
|
45
|
+
/** Response headers to re-apply on a replay (e.g. `X-Payment-Response`). */
|
|
46
|
+
headers?: Record<string, string>;
|
|
47
|
+
/**
|
|
48
|
+
* Epoch milliseconds at which the settlement was recorded. Optional for
|
|
49
|
+
* backwards compatibility with custom stores that predate the replay bound;
|
|
50
|
+
* a record without it can only be bounded by the replay count.
|
|
51
|
+
*/
|
|
52
|
+
createdAt?: number;
|
|
53
|
+
}
|
|
54
|
+
interface SeenTxStore {
|
|
55
|
+
/** Returns the cached settlement for a key, or `undefined` if unseen. */
|
|
56
|
+
get(key: string): Promise<SettlementRecord | undefined> | SettlementRecord | undefined;
|
|
57
|
+
/** Records the settlement outcome for a key. */
|
|
58
|
+
set(key: string, record: SettlementRecord): Promise<void> | void;
|
|
59
|
+
/**
|
|
60
|
+
* Optionally, atomically record one replay of `key` and return the new
|
|
61
|
+
* replay count. Returns `Infinity` when the key is unknown (e.g. evicted
|
|
62
|
+
* between a `get` and this call), so the caller treats the payment as spent
|
|
63
|
+
* rather than replaying an unbounded number of times.
|
|
64
|
+
*
|
|
65
|
+
* Optional so a custom store written against the original two-method
|
|
66
|
+
* (`get`/`set`) contract keeps compiling. A store that omits it is still
|
|
67
|
+
* bounded by {@link SETTLEMENT_REPLAY_WINDOW_MS} when its records carry
|
|
68
|
+
* {@link SettlementRecord.createdAt}.
|
|
69
|
+
*/
|
|
70
|
+
claimReplay?(key: string): Promise<number> | number;
|
|
71
|
+
}
|
|
72
|
+
interface InMemorySeenTxStoreOptions {
|
|
73
|
+
/** Maximum number of entries before FIFO eviction. Defaults to 10,000. */
|
|
74
|
+
maxEntries?: number;
|
|
75
|
+
/** Log a startup warning about non-durability. Defaults to true. */
|
|
76
|
+
warn?: boolean;
|
|
77
|
+
/** Log sink for the non-durability warning. Defaults to a console-backed logger. */
|
|
78
|
+
logger?: RouteDockLogger;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* How long after a settlement a byte-identical retry may still replay the
|
|
82
|
+
* cached response. Matches the `maxTimeoutSeconds: 60` both x402 requirement
|
|
83
|
+
* objects declare, so past this point the payment is expired anyway.
|
|
84
|
+
*/
|
|
85
|
+
declare const SETTLEMENT_REPLAY_WINDOW_MS = 60000;
|
|
86
|
+
/**
|
|
87
|
+
* How many times one settled payment may be replayed beyond the original
|
|
88
|
+
* settlement. The SDK's own clients never resend a byte-identical header —
|
|
89
|
+
* they re-sign on every retry — so a tight limit costs nothing while stopping
|
|
90
|
+
* a leaked header from buying unlimited requests.
|
|
91
|
+
*/
|
|
92
|
+
declare const MAX_SETTLEMENT_REPLAYS = 1;
|
|
93
|
+
/** Outcome of checking whether an idempotency key may replay a settlement. */
|
|
94
|
+
type SettlementReplayCheck = {
|
|
95
|
+
kind: 'miss';
|
|
96
|
+
} | {
|
|
97
|
+
kind: 'replay';
|
|
98
|
+
record: SettlementRecord;
|
|
99
|
+
} | {
|
|
100
|
+
kind: 'spent';
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Decide what a payment bearing `key` should do.
|
|
104
|
+
*
|
|
105
|
+
* - `miss` — no cached settlement; fall through to decode and settle.
|
|
106
|
+
* - `replay`— a fresh settlement within the replay window and under the replay
|
|
107
|
+
* limit; re-apply the recorded headers and skip settling.
|
|
108
|
+
* - `spent` — the settlement is older than the replay window, or has already
|
|
109
|
+
* been replayed the maximum number of times; respond 402 without
|
|
110
|
+
* decoding, settling, or invoking callbacks.
|
|
111
|
+
*
|
|
112
|
+
* The age check runs first so an expired record never touches the replay
|
|
113
|
+
* counter.
|
|
114
|
+
*
|
|
115
|
+
* Custom stores may predate this function and implement only `get`/`set`:
|
|
116
|
+
*
|
|
117
|
+
* - a record that carries `createdAt` but a store without `claimReplay` is
|
|
118
|
+
* bounded by {@link SETTLEMENT_REPLAY_WINDOW_MS} alone;
|
|
119
|
+
* - a record without `createdAt` offers no window to check, so a store's own
|
|
120
|
+
* `claimReplay` is the only bound available; a store that supplies neither
|
|
121
|
+
* replays as it did before this function existed.
|
|
122
|
+
*/
|
|
123
|
+
declare function checkSettlementReplay(store: SeenTxStore, key: string, now?: number): Promise<SettlementReplayCheck>;
|
|
124
|
+
/**
|
|
125
|
+
* Default in-memory {@link SeenTxStore}. Bounded by `maxEntries` with FIFO
|
|
126
|
+
* eviction so a long-running process cannot grow without limit.
|
|
127
|
+
*
|
|
128
|
+
* **Not durable.** On serverless/edge runtimes each request may land on a
|
|
129
|
+
* fresh isolate with an empty cache, which causes duplicate settlements.
|
|
130
|
+
* Supply a persistent implementation (Supabase, Redis, …) in production.
|
|
131
|
+
*/
|
|
132
|
+
declare class InMemorySeenTxStore implements SeenTxStore {
|
|
133
|
+
private readonly map;
|
|
134
|
+
private readonly replayCounts;
|
|
135
|
+
private readonly order;
|
|
136
|
+
constructor(maxEntriesOrOptions?: number | InMemorySeenTxStoreOptions);
|
|
137
|
+
private readonly maxEntries;
|
|
138
|
+
get(key: string): SettlementRecord | undefined;
|
|
139
|
+
set(key: string, record: SettlementRecord): void;
|
|
140
|
+
claimReplay(key: string): number;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Derive a collision-resistant idempotency key from the inbound
|
|
144
|
+
* payment-bearing headers, scoped to the resource being paid for. Uses
|
|
145
|
+
* SHA-256 instead of 32-bit FNV-1a (which has a 2^32 keyspace — birthday
|
|
146
|
+
* collisions become likely well before the 10_000-entry default store is
|
|
147
|
+
* full).
|
|
148
|
+
*
|
|
149
|
+
* x402 clients send the signed payment in `payment-signature` / `x-payment`;
|
|
150
|
+
* mppx clients send it in `authorization` (the `Payment` scheme). A retry
|
|
151
|
+
* resends byte-identical headers, so hashing the first present one yields a
|
|
152
|
+
* stable key.
|
|
153
|
+
*
|
|
154
|
+
* The `scope` (method, path, amount, payTo) is hashed alongside the header so
|
|
155
|
+
* a payment settled for one route can never replay against another route,
|
|
156
|
+
* even when the same store is shared across routes. Returns `null` when no
|
|
157
|
+
* payment header is present (nothing to dedupe — e.g. the initial 402
|
|
158
|
+
* challenge request).
|
|
159
|
+
*/
|
|
160
|
+
declare function paymentIdempotencyKey(getHeader: (name: string) => string | undefined, scope: SettlementScope): Promise<string | null>;
|
|
161
|
+
|
|
162
|
+
/** Store shape extended with optional atomic update operation. */
|
|
163
|
+
type ChannelStore = Store.Store & {
|
|
164
|
+
update?: (key: string, fn: (prev: unknown) => unknown) => Promise<unknown>;
|
|
165
|
+
};
|
|
166
|
+
/** Expected payload shape for voucher cumulative amount entries. */
|
|
167
|
+
interface VoucherStoreValue {
|
|
168
|
+
amount: string;
|
|
169
|
+
[key: string]: unknown;
|
|
170
|
+
}
|
|
171
|
+
/** Validates that a stored store value is an object containing a numeric string amount. */
|
|
172
|
+
declare function isVoucherStoreValue(value: unknown): value is VoucherStoreValue;
|
|
173
|
+
type Network = 'testnet' | 'mainnet';
|
|
174
|
+
/** Why a live session was flagged as orphaned (never cleanly closed). */
|
|
175
|
+
type OrphanReason = 'connection-closed' | 'idle-timeout';
|
|
176
|
+
/** Latest signed state of an orphaned session, enough for the reconciler to settle. */
|
|
177
|
+
interface OrphanedSessionInfo {
|
|
178
|
+
/** Cumulative amount of the highest voucher seen (human-readable, 7 dp). */
|
|
179
|
+
cumulativeAmount: string;
|
|
180
|
+
/** Hex-encoded signature of the highest voucher seen. */
|
|
181
|
+
lastSignature: string;
|
|
182
|
+
/** Number of vouchers received before teardown. */
|
|
183
|
+
voucherCount: number;
|
|
184
|
+
/** What triggered the orphan flag. */
|
|
185
|
+
reason: OrphanReason;
|
|
186
|
+
}
|
|
187
|
+
interface MppSessionHandlerOptions {
|
|
188
|
+
payeeSecretKey: string;
|
|
189
|
+
network: Network;
|
|
190
|
+
channelFactory: string;
|
|
191
|
+
rate: string;
|
|
192
|
+
assetContract?: string;
|
|
193
|
+
manifest: RouteDockManifest;
|
|
194
|
+
commitmentPublicKey: string;
|
|
195
|
+
onSettled?: (txHash: string, totalPaid: string, mode: string, payer: string | null) => Promise<void>;
|
|
196
|
+
onSessionOpen?: (channelId: string, payer: string | null) => Promise<void>;
|
|
197
|
+
onVoucher?: (channelId: string, voucherIndex: number, cumulativeAmount: string, signature: string) => Promise<void>;
|
|
198
|
+
onCallbackError?: (err: unknown, cb: string) => void;
|
|
199
|
+
/**
|
|
200
|
+
* Called when the client connection drops mid-session or the session goes
|
|
201
|
+
* idle, before a clean close. Persist the session as `closing` so the
|
|
202
|
+
* SessionReconciler can settle it with the latest signed voucher.
|
|
203
|
+
*/
|
|
204
|
+
onOrphaned?: (channelId: string, info: OrphanedSessionInfo) => Promise<void>;
|
|
205
|
+
/**
|
|
206
|
+
* Flag the session orphaned after this many milliseconds with no voucher
|
|
207
|
+
* activity. Disabled when unset.
|
|
208
|
+
*/
|
|
209
|
+
idleTimeoutMs?: number;
|
|
210
|
+
/** Log sink for internal error paths. Defaults to a console-backed logger. */
|
|
211
|
+
logger?: RouteDockLogger;
|
|
212
|
+
}
|
|
213
|
+
declare function createMppSessionHandler(opts: MppSessionHandlerOptions): RequestHandler;
|
|
214
|
+
|
|
215
|
+
export { type ChannelStore as C, InMemorySeenTxStore as I, MAX_SETTLEMENT_REPLAYS as M, type OrphanedSessionInfo as O, type SeenTxStore as S, type VoucherStoreValue as V, type SettlementRecord as a, type InMemorySeenTxStoreOptions as b, type MppSessionHandlerOptions as c, type OrphanReason as d, SETTLEMENT_REPLAY_WINDOW_MS as e, type SettlementReplayCheck as f, type SettlementScope as g, checkSettlementReplay as h, createMppSessionHandler as i, isVoucherStoreValue as j, paymentIdempotencyKey as p };
|