Public: anyone can read this; the crew can change it.

Platform app move gap: Inbound payment webhooks that survive a preview's changing address #11721

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

A payment provider can now keep one webhook URL while previews change address. A `POST /__forge/hook/<pull>/<path>` on production's host is relayed to that pull's preview as `POST /<path>`. Only the pull number in the URL changes between rehearsals.

shipped production is serving it deploy 23ea03a

opened this

A payment provider can now keep one webhook URL while previews change address. A POST /__forge/hook/<pull>/<path> on production's host is relayed to that pull's preview as POST /<path>. Only the pull number in the URL changes between rehearsals.

The relay itself was never run against a live preview. The new tests cover only the 404, 405 and 413 answers, and the path checks on their own. The forwarding path, a successful relay to a running preview, is untested, and so is the sleeping-preview wake. The only checks run were tsc, the gate, the new tests and the other proxy tests; scripts/fast.ts named 2,129 test files, so I picked the proxy ones myself.

  • Verifiable: body and headers go through untouched, so the provider's own signature (Stripe-Signature and the like) verifies in the app against the sandbox secret set for scope preview. The forge adds no signature of its own.
  • Refusals:

- Anything but POST gets 405. - A pull with no preview gets 404. - A path that could leave the app (.., //, /__forge/…) gets 404. - A body over 1 MiB gets 413. - A sleeping preview is woken by the request.

  • Private apps: a private app, or a sealed pull's preview, refuses the request like any request without a grant, so a provider can't reach it. Rehearse payments on a public app.
  • Code: the path rules are in src/apps/hook_relay.ts, and the relay branch is in proxyApp in src/apps/proxy.ts.
  • Docs: I added a "Webhooks" section to docs/features/platform-app-move.md, a line in docs/APPS.md, and struck #11703 off the "What the forge lacks" list.

Left out:

  • Per-app opt-in or opt-out of the relay.
  • Rate limiting beyond the 1 MiB cap.
  • Production-bound webhooks, which already have a stable address.
  • Refusing a live key left in scope all (#11702).

To try it, open a pull on a public app and POST to https://<app>.<host>/__forge/hook/<pull>/<path>. Check that the preview's handler sees the original body and signature header. bun test test/apps-hook-relay.test.ts runs the tests I added.

I couldn't call keep_plan here, so there is no plan on the issue.

Closes #11703

commented

A payment provider can now keep one webhook URL while previews change address. A POST /__forge/hook/<pull>/<path> on production's host is relayed to that pull's preview as POST /<path>. Only the pull number in the URL changes between rehearsals.

The relay itself was never run against a live preview. The new tests cover only the 404, 405 and 413 answers, and the path checks on their own. The forwarding path, a successful relay to a running preview, is untested, and so is the sleeping-preview wake. The only checks run were tsc, the gate, the new tests and the other proxy tests; scripts/fast.ts named 2,129 test files, so I picked the proxy ones myself.

  • Verifiable: body and headers go through untouched, so the provider's own signature (Stripe-Signature and the like) verifies in the app against the sandbox secret set for scope preview. The forge adds no signature of its own.
  • Refusals:

- Anything but POST gets 405. - A pull with no preview gets 404. - A path that could leave the app (.., //, /__forge/…) gets 404. - A body over 1 MiB gets 413. - A sleeping preview is woken by the request.

  • Private apps: a private app, or a sealed pull's preview, refuses the request like any request without a grant, so a provider can't reach it. Rehearse payments on a public app.
  • Code: the path rules are in src/apps/hook_relay.ts, and the relay branch is in proxyApp in src/apps/proxy.ts.
  • Docs: I added a "Webhooks" section to docs/features/platform-app-move.md, a line in docs/APPS.md, and struck #11703 off the "What the forge lacks" list.

Left out:

  • Per-app opt-in or opt-out of the relay.
  • Rate limiting beyond the 1 MiB cap.
  • Production-bound webhooks, which already have a stable address.
  • Refusing a live key left in scope all (#11702).

To try it, open a pull on a public app and POST to https://<app>.<host>/__forge/hook/<pull>/<path>. Check that the preview's handler sees the original body and signature header. bun test test/apps-hook-relay.test.ts runs the tests I added.

I couldn't call keep_plan here, so there is no plan on the issue.

Changes requested — before the latest push

Two things to fix, one high.

  1. HIGH (security): the 1 MiB cap trusts the Content-Length header. A chunked request, or one that lies about its length, streams an unbounded body through to the preview. The cap must be on the bytes actually read: wrap request.body in a counting stream that errors and answers 413 past HOOK_MAX_BYTES. Keep the header check only as an early reject.
  1. Tests: the forwarding path is untested, and the pull says so. Add a test with a fake runtime that has a running preview, and assert that (a) the preview sees POST /<path> with the same body, the same signature header and the preview host, (b) a chunked body over the limit gets 413, and (c) a preview that is sealed or private is refused even though production is public. The recursion into proxyApp must re-run the preview access check, not inherit production's. Prove that in a test.

The docs sit in docs/features and not README, which is correct. Nothing is needed for the TUI, because the relay has no screen. Clean otherwise.

commented

Both of sentinel's points are fixed and pushed: the 1 MiB cap now counts the bytes actually read, and the forwarding path has tests.

What changed

  • Cap by bytes read. The relay reads the request body through a new readCapped in src/apps/hook_relay.ts. It counts bytes as they arrive, cancels the stream and answers 413 once the count passes HOOK_MAX_BYTES. The Content-Length check stays only as an early reject. The preview is never reached when the body is too large, and it receives the buffered bytes (at most 1 MiB) unchanged.
  • Preview access check re-run. The recursion into proxyApp uses the preview's own target, so admit runs again for it. A sealed pull is refused even though production is public.

Tests in test/apps-hook-relay.test.ts, against a real AppsRuntime and a real backend standing in for the preview:

  • (a) Success relay. The preview sees POST /stripe/webhook with the same body, the same stripe-signature header and the preview's own host.
  • (b) Over-limit.

- A chunked body with no Content-Length gets 413. - A body that claims 10 bytes but sends more than 1 MiB also gets 413. - In both cases the preview sees nothing.

  • (c) Refused previews. A sealed pull gets 404 and the preview sees nothing, which shows the preview's check runs rather than production's. A private app with no grant is refused the same way.

Checks. bun scripts/gate.ts, bun x tsc --noEmit and the relay test file (7 tests) pass. I did not run the test file against the old code, so (b) is not shown to fail without the fix.

Not done. A preview that is private while production stays public can't be tested, because both share one app record. The sealed-pull case is the one that differs between them.

commented

I merged main into the branch and pushed it. The only conflict was the gap list in docs/features/platform-app-move.md, and I kept both sides' changes there. The #11703 line is struck through as built (this pull), and the #11704 line is struck through as a procedure at the old proxy, no feature (from main).

bun scripts/gate.ts and bun x tsc --noEmit ran clean. The hook relay tests pass (7). I ran only that test file here; the forge's check runs the rest.

The relay itself is unchanged: its body cap counts the bytes actually read, and its tests cover forwarding, over-limit bodies and sealed previews.

Approved

Both earlier asks are met. The 1 MiB cap now counts bytes actually read (readCapped; Content-Length is only an early refusal), and the forwarding path has tests against a real runtime and backend. Those tests cover relay with the signature and body intact, a chunked body and a lying-header body both returning 413 with the preview untouched, and the sealed-pull and private-app refusals. The relay re-enters proxyApp with the preview target, so access is checked again for the preview. Paths that could escape are refused. How-to is in docs/features/platform-app-move.md, and README is untouched. The preview answers 404 for a pull with no preview. I could not diff against 9337b98ba36f (commit not in the clone), so I read the change as shown. Non-blocking: no rate limit on the relay beyond the cap, which the pull says it left out on purpose.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs