Skip to content

feat(provider-b,sdk): automate session reconciliation via Cloudflare cron trigger (#209) - #261

Open
Olalolo22 wants to merge 3 commits into
winsznx:mainfrom
Olalolo22:fix/issue-209
Open

feat(provider-b,sdk): automate session reconciliation via Cloudflare cron trigger (#209)#261
Olalolo22 wants to merge 3 commits into
winsznx:mainfrom
Olalolo22:fix/issue-209

Conversation

@Olalolo22

Copy link
Copy Markdown
Contributor

Closes #209

What changed

Automated orphaned session recovery by adding a 15-minute Cloudflare cron trigger in apps/provider-b/wrangler.jsonc and exporting a scheduled() handler in apps/provider-b/src/worker.ts that forwards triggers directly to the ChannelSession Durable Object. Added a reconcileSessions() method and internal POST /__reconcile endpoint to ChannelSession to query abandoned closing sessions, settle them via reconcileAbandonedSessions with the latest signed commitment, and log settlements into tx_log. Re-exported reconcileAbandonedSessions and SessionReconcilerOptions from @routedock/routedock/provider/hono.

How I verified it

Added unit test suite in apps/provider-b/src/__tests__/worker.test.ts:

  • Tested that the scheduled() cron handler forwards executions directly to the ChannelSession Durable Object stub.
  • Tested that ChannelSession handles internal POST /__reconcile requests and returns status ok.
  • Tested that existing health, payment challenges, and signed manifest routes continue to pass.
  • Verified Workers build passes with pnpm --filter provider-b build.

Checklist

  • node --version is v22 or newer
  • Built the SDK first (pnpm --filter @routedock/nulth-sdk build && pnpm --filter @routedock/routedock build) — several packages import its built dist/
  • pnpm -r typecheck passes
  • Tests pass for every package I touched
  • pnpm --filter provider-a build / provider-b build pass if I touched a provider or added a dependency to one
  • No as any, @ts-ignore, or @ts-expect-error
  • Added a changeset (pnpm changeset) if I changed anything under packages/
  • No secrets in committed files — wrangler.jsonc is committed, .dev.vars is not

Anything a maintainer needs to finish

  • Deploy provider-b to Cloudflare so the Cron Trigger activates on live infrastructure.

@drips-wave

drips-wave Bot commented Aug 29, 2026

Copy link
Copy Markdown

@Olalolo22 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@winsznx

winsznx commented Aug 29, 2026

Copy link
Copy Markdown
Owner

The design is right — running reconciliation inside the Durable Object is exactly what #209 asked for, because it serializes against live voucher traffic instead of racing it, and reusing reconcileAbandonedSessions rather than reimplementing is the correct call. The scheduled() test is good too.

Holding on one thing: /__reconcile is reachable from the public internet, unauthenticated.

The edge worker forwards everything except /health to the object:

// worker.ts
const id = env.CHANNEL_SESSION.idFromName(env.CHANNEL_CONTRACT_ID)
return env.CHANNEL_SESSION.get(id).fetch(request)

and the DO handles the path before the payment middleware runs:

override async fetch(request: Request): Promise<Response> {
  const url = new URL(request.url)
  if (url.pathname === '/__reconcile' && request.method === 'POST') {
    const stats = await this.reconcileSessions()
    return Response.json({ status: 'ok', stats })
  }
  this.app ??= this.buildApp()      // routedockHono mounts at '*' below this
  return this.app.fetch(request)
}

So curl -X POST https://api-b.routedock.xyz/__reconcile triggers on-chain settlement attempts with no payment and no auth. Reconciliation is idempotent, so this cannot double-settle — but it can be hammered to drive Soroban RPC and Horizon load, it can race live sessions on the same channel, and the response body leaks operational counts.

An unauthenticated public endpoint that initiates on-chain transactions is not something to put in front of a payments provider.

Cleanest fix: drop the HTTP surface entirely. reconcileSessions() is already a public method on the DO class, so Cloudflare's RPC reaches it directly — no path, nothing routable:

// worker.ts scheduled()
const stub = env.CHANNEL_SESSION.get(id)
await stub.reconcileSessions()

Then delete the /__reconcile branch from fetch(). That also removes the as unknown as {...} cast you needed to call it.

If you would rather keep the HTTP path, it needs both a shared-secret header check and an explicit reject in worker.ts so it never forwards from outside — but RPC is simpler and has no secret to manage.

One smaller note: */15 * * * * is sensible. Worth confirming a reconciliation run comfortably finishes inside the Workers CPU budget when several sessions are pending, since each settlement is a Soroban call — otherwise a backlog could have runs overlapping.

@winsznx

winsznx commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Status update — the security point is still open, and the branch now conflicts.

1. /__reconcile is still publicly reachable

Unchanged since my review. The handler still sits in ChannelSession.fetch() before the payment middleware:

if (url.pathname === '/__reconcile' && request.method === 'POST') {
  const stats = await this.reconcileSessions()

and worker.ts still forwards everything except /health to the object. So curl -X POST https://api-b.routedock.xyz/__reconcile triggers on-chain settlement attempts with no payment and no auth. Idempotent, so it cannot double-settle — but it can be hammered to drive Soroban and Horizon load, it can race live sessions on the same channel, and the response body leaks operational counts.

The fix removes the HTTP surface entirely rather than guarding it. reconcileSessions() is already a public method on the DO class, so Durable Object RPC reaches it directly:

// worker.ts scheduled()
const stub = env.CHANNEL_SESSION.get(id)
await stub.reconcileSessions()

Then delete the /__reconcile branch from fetch(). That also removes the as unknown as {...} cast you currently need.

2. Conflicting — three PRs have touched these files

ChannelSession.ts and hono.ts have both moved since your last push:

git fetch origin && git rebase origin/main
git push --force-with-lease

Your cron handler and #236's store wiring should compose cleanly, but read the conflicts — the DO's constructor and fetch have both changed shape.

3. One thing to confirm after rebasing

Reconciliation now shares the object with a live WebSocket transport. Worth checking a scheduled run cannot fire while a WS session is mid-voucher on the same channel. Routing through the DO is what makes that safe in principle, since execution is serialized — but with the WS path added it is worth a deliberate look rather than an assumption.

The core design is right and I want to merge it. Just those two.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session reconciliation only runs via a manual CLI — nothing automated settles sessions left in 'closing'

2 participants