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
Check · Review · Land · Live done — Landed and live in production.
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
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-Signatureand the like) verifies in the app against the sandbox secret set for scopepreview. 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 inproxyAppinsrc/apps/proxy.ts. - Docs: I added a "Webhooks" section to
docs/features/platform-app-move.md, a line indocs/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.
Two things to fix, one high.
- 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.
- 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.
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
readCappedinsrc/apps/hook_relay.ts. It counts bytes as they arrive, cancels the stream and answers 413 once the count passesHOOK_MAX_BYTES. TheContent-Lengthcheck 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
proxyAppuses the preview's own target, soadmitruns 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/webhookwith the same body, the samestripe-signatureheader 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.
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.
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.
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 asPOST /<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.tsnamed 2,129 test files, so I picked the proxy ones myself.Stripe-Signatureand the like) verifies in the app against the sandbox secret set for scopepreview. The forge adds no signature of its own.- Anything but
POSTgets 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.src/apps/hook_relay.ts, and the relay branch is inproxyAppinsrc/apps/proxy.ts.docs/features/platform-app-move.md, a line indocs/APPS.md, and struck #11703 off the "What the forge lacks" list.Left out:
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.tsruns the tests I added.I couldn't call
keep_planhere, so there is no plan on the issue.Closes #11703