Repository navigation
fix(runtime): an exact POST /webhooks/redeliver dispatcher domain for kernels with no raw app - #22842
Conversation
… kernels with no raw app The hosted door for plugin-webhooks' redeliver endpoint: the dispatcher forwards the request, body unread, to the request kernel's `webhooks` slot (`IWebhookService.handleRedeliver`), resolved per request, and returns the member's Response as it is. An absent slot or member answers 501 NOT_IMPLEMENTED, never ROUTE_NOT_FOUND. One server-only ledger row, the route-envelope declaration, and the conformance package's devDeps for the end-to-end pin. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
…patcher Forwarding with the body unread, the exact POST claim, the typed 501s, per-request slot resolution, the sanitised 500s, and that both preamble gates (membership, ADR-0069) apply to this path. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
…al plugin-webhooks member on both doors The end-to-end pin: createHonoApp over a kernel with no http.server reaches the real member (a replay lands in the outbox), every refusal, anonymous first, equals the self-hosted mount's bytes, no non-POST reaches the member, the three absence shapes answer 501, and the self-hosted kernel keeps its single raw-app mount. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
… pin it unobservable on the showcase boot One enforced authz row (`webhook-redeliver-session`) covers the new dispatcher-domain key; the probe census and its prose move 85 -> 86 rows, 22 -> 23 domains; the live-mount parity gate pins the row as served by plugin-webhooks' raw-app mount on a self-hosted boot. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
…r door Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
…e with the engine predicate check:engine-double-contract pins every engine double's findOne to assertEngineFindOnePredicate; the pinned ledger records the new file. Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
…r-domain Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1bfea7ddf95f9a01666cbc0d678ce8bab5e8240f && git checkout 1bfea7ddf95f9a01666cbc0d678ce8bab5e8240f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a6f3a13f2aea4275e2984709461e7f4b1201e17e be4051ae272fa47a65001568c788f58d3a183317 && git checkout -B drift-repro a6f3a13f2aea4275e2984709461e7f4b1201e17e && git merge --no-ff be4051ae272fa47a65001568c788f58d3a183317
node scripts/docs-audit/affected-docs.mjs --json a6f3a13f2aea4275e2984709461e7f4b1201e17e
|
Fixes #22755
Clause-②: no
Segment 2 of the webhooks half of #22564's stage 2, under the maintainer ruling recorded verbatim as 「A + 扫类」 on #22438 (director record
6079645593, item 2). A hosted tenant kernel owns no socket, so it has no raw Hono app forplugin-webhooksto mount the redeliver door on: on that shapePOST /api/v1/webhooks/redeliveranswered404 ROUTE_NOT_FOUND, even though the plugin had registered thewebhooksslot whosehandleRedelivermember (segment 1's contract, #22756's member) serves it. This PR gives the dispatcher that route. Parent #22564 remains open.What changed
@objectstack/runtime(the one published change;patchchangeset)packages/runtime/src/domains/webhooks.ts, registered inHttpDispatcherbeside/approvals/act(ADR-0076 D11: the dispatcher owns registration, one route bridges to one service slot). It claims exactly/webhooks/redeliver(match: 'exact') forPOSTonly, resolves the request kernel'swebhooksslot per request, and forwardscontext.requestunread toIWebhookService.handleRedeliver, returning thatResponseuntouched.handleRedeliver:501 NOT_IMPLEMENTED(an existing standard code), with a message naming which. NeverROUTE_NOT_FOUND.500 INTERNAL_ERROR, original error logged. A request whose body a transport already consumed: also500, logged, and the member is not asked (it would otherwise answer "Request body must be JSON." for a well-formed request).authservice, as the self-hosted mount does. Unlike/approvals/act, this path is NOT exempted from either dispatcher gate (no ADR-0069 allow-list entry, no membership skip), so the hosted door is stricter than the mount, never looser.packages/coreis untouched.route-ledger.ts: oneserver-onlyrow,POST /webhooks/redeliver(no SDK method exists and none is declared); census 85 to 86.Pins and bookkeeping (no published surface)
scripts/check-route-envelope.mjs: declareswebhooks.tswithhandBuilt: 0.packages/qa/http-conformance: devDependencies on@objectstack/plugin-webhooksand@objectstack/service-messaging, both aliased to source (anchored) in its vitest config, so one suite boots the real plugin on both doors;pnpm-lock.yamlgains the 6-line importer link.scripts/engine-double-contract.pinned.jsonrecords that suite's pinned fake-enginefindOne.packages/qa/dogfood: a newenforcedauthz row,webhook-redeliver-session, classifies the new dispatcher-domain key (the baseline is untouched, still 30); the probe census and both matrix headers move 85 to 86 rows, 22 to 23 domains, 21 to 22coverskeys; the showcase live-mount parity gate pins the row as served by plugin-webhooks' raw-app mount on a self-hosted boot (the/approvals/actreason).The PM's four mechanism assumptions, as measured
mainb7cd1af9dbefore any change, with the realWebhookOutboxPluginover the realMessagingServiceon aLiteKernelwith nohttp.server, under the realcreateHonoApp: thewebhooksslot heldhandleRedeliver(a function), andPOST /api/v1/webhooks/redeliveranswered404 ROUTE_NOT_FOUND("Route Not Found: /webhooks/redeliver") for an authenticated and an anonymous caller alike.kernel:ready, only once its veto installs (bootRedeliverDoor). The domain reads it per request and records nothing; a pin registers the slot after a request was answered501and the next request reaches the member.401 UNAUTHENTICATED, body{"success":false,"error":{"code":"UNAUTHENTICATED","message":"Sign in to redeliver webhook deliveries."}}. Hosted door after this change: the same status, headers and body. Neither dispatcher gate decides anything for an anonymous caller (membership returns early with no user; the auth gate needs a session), so the member's own refusal is the answer. Nothing needed an allow-list or gate change, and none was made.POST(GET,PUT,PATCH,DELETE,HEAD) with plugin-hono-server's404 ENDPOINT_NOT_FOUND; the hosted door answers the dispatcher's404 ROUTE_NOT_FOUND. The hosted answer is pinned, and the member never runs for a non-POST(spied on the real service; the outbox row staysdead).Pins
packages/runtime/src/domains/webhooks-redeliver.test.ts(13): the realHttpDispatcheron a kernel with nohttp.serverand a stub member that reads its own JSON body. Forwarding with the body unread; the member's refusal passed through; the exactPOSTclaim (no other method, sub-path or sibling);501for an absent slot and for an absent member; per-request slot resolution; the sanitised500s; and the gates: a signed-in non-member refused403 PROJECT_MEMBERSHIP_REQUIREDon both spellings, a gated session refused403by ADR-0069, while a member of the environment and an anonymous caller reach the member.packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts(7), the end-to-end pin this card owes (plugin-webhooks: implement the declared redeliver member, readhttp.serverbefore thehttp-serveralias, and keep the self-hosted raw-app mount byte-unchanged (webhooks segment 3 of #22564's stage 2) #22756 landed first): the real plugin on both doors. ThroughcreateHonoApp, an authenticatedPOSTreplays a finished delivery and the row movesdeadtopendingin the real outbox (so the JSON body crossed the catch-all unread); ten refusals (anonymous401, not-JSON and empty400, missing and blank400, unknown and cross-organization404, not-finished, never-sent and veto409) answer the same status,code, headers and body bytes at both doors (row ids masked); non-POSTas above; the typed absence across the segment boundary with the member removed from the real service, with the slot left empty by the plugin's own boot (a messaging service that cannot take the veto), and with no plugin at all, each501 NOT_IMPLEMENTED, with/webhooks/redeliverxas the route-miss control; and the CONTROL below.http.server: the route is mounted once, asPOST, on the raw app, and the mount answers with its own bytes (no dispatcherhttpStatus/route/hintkeys), equal to the bytes measured on the base commit. This PR adds no mount there:dispatcher-plugin.tsmounts explicit routes and is untouched, and the showcase parity gate (a real self-hosted composition) still sees no port mount for the path.Ablations (one-off, from committed state, through
scripts/ablation-replace.mjs, restore proven by blob hash equal to HEAD, emptygit diff HEADand a cleangit status --porcelain)register(createWebhooksRedeliverDomain(...))call replaced byvoid createWebhooksRedeliverDomain;so the DTS build still passesnoUnusedLocals), runtimedist/rebuilt,ablation-dist-preflight --absentgreen (marker absent from all 6 built files; tree mutate leg confirmed). Unit pins: 10 of 13 red, every one onROUTE_NOT_FOUND/ "Route Not Found: /webhooks/redeliver"; the 3 that stay green are the three gate pins (both gates run before route resolution, so they refuse with or without the domain). Wire pins: 5 of 7 red (404where200or501was expected, and the anonymous parity row); the 2 that stay green are the non-POSTpin and the self-hosted CONTROL, neither of which depends on the domain. Restore leg: blob equal to HEAD, tree clean, rebuilt, marker present in 2 built files, 13 of 13 and 7 of 7 green.TypeError: Cannot read properties of undefined (reading 'handleRedeliver')). Member guard off: exactly the member-absence pin red (500 INTERNAL_ERRORinstead of501 NOT_IMPLEMENTED). Restored, 13 of 13 green. The wire suite's three absence cases remove the member and the slot on the real composition instead (see Pins).POST /webhooks/redeliver — LEDGERED BUT NOT MOUNTEDplus the stale pin (2 of 8 red), and the boot log shows[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver, so the pin is load-bearing and its reason is the measured one. Restored, 8 of 8.Verification
Measured on
be4051ae2(this branch withmainefcbac73cmerged; after the mergepnpm install --frozen-lockfile, a fullturbo run buildof the workspace, 73 of 73 tasks, andrm -rf packages/runtime/.objectstack).@objectstack/runtime:localproject in three chunks, 104 + 123 + 123 files, 1985 + 1520 + 1546 tests passed (19 skipped);repoproject 3 files, 751 passed;typecheck(src + test layer) 0, the new test file counted in the test-layer program with--listFiles.@objectstack/http-conformance: 11 files, 117 passed;typecheck0.@objectstack/dogfood:authz-conformance.test.ts,authz-probe-blind-spot.test.tsandroute-ledger-live-mount-parity.dogfood.test.ts, 3 files, 100 passed;typecheck0. The rest of that suite is CI's.dispatch-gates --commandsderives 100 families for this diff (the dispatch list's 53 withoutpnpm lint, plus 47 the diff adds); all 100 exit 0 onbe4051ae2, anddispatch-gates --ranwith every exit recorded reads 100 derived, 100 run, 0 NOT-MEASURED, 0 UNRUN. Two were red on the way and fixed in this diff:check:route-ledger-census(85 to 86) andcheck:engine-double-contract(the new suite's fakefindOnenow opens withassertEngineFindOnePredicate; the pinned ledger records it).check:dual-build-cjs-loadsfirst refused with its prerequisite (8 packages withoutdist/); after the full build it measured 107 entries across 66 packages.eslint.config.mjs's ownfilesglobs minusNEVER_LINTED, which takes 12 of this diff's 16 files (the changeset, two JSON files and the lockfile are outside it);eslint --no-inline-config --format jsonover those 12 reports 12 files, 0 errors, 0 warnings, 0 fatal, none ignored. Invariance: the config enables no type-aware linting (stated in the config itself) and the diff touches none of its inputs, so it cannot move the verdict of an untouched file. The fullpnpm lintis CI's.Acceptance notes
POST:ROUTE_NOT_FOUND(hosted) andENDPOINT_NOT_FOUND(self-hosted). Pre-existing on the self-hosted side and the same split/approvals/actrecorded; out of this card's scope.403 PROJECT_MEMBERSHIP_REQUIRED, and a session owing an authentication step gets the ADR-0069403. The self-hosted mount has neither gate./api/v1/environments/:id/webhooks/redeliverreaches the domain through the dispatcher's generic scope strip (membership-checked like the unscoped one); the self-hosted mount has no such spelling.POSTwhose input is its JSON body, and neither the domain nor the member readsreq.query.POST /automation/hooks/:flowName/:hookIddispatcher domain and its parameterised ADR-0069 allow-list row, anonymous through to the trigger's HMAC verifier only (trigger-api segment 2 of ruling A on #22757) #22773 is held behind this one). Named here, not acted on. Carrier: the Sweep (ruling A-2 of #22438): plugin-webhooks' redeliver endpoint and trigger-api's inbound hooks endpoint mount only on http.server's raw app; measure each on a dispatcher-only kernel and bridge each 404 the way #22438 is bridged #22564 sweep owner.INVALID_REQUEST(packages/spec/src/api/error-code-ledger.zod.ts) describes the raw-app mount and stays true; the same code now also leaves through the dispatcher door, which is the member's own envelope passed through. No spec edit (that is thedomain:specseat's surface); noted only.mainonce (efcbac73c) before this PR; the three incoming commits touch none of these files.Generated by Claude Code