Repository navigation
Commit 8558668
fix(runtime): an exact POST /webhooks/redeliver dispatcher domain for kernels with no raw app (#22842)
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 for `plugin-webhooks` to mount the redeliver door on: on
that shape `POST /api/v1/webhooks/redeliver` answered `404
ROUTE_NOT_FOUND`, even though the plugin had registered the `webhooks`
slot whose `handleRedeliver` member (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; `patch` changeset)
- New dispatcher domain `packages/runtime/src/domains/webhooks.ts`,
registered in `HttpDispatcher` beside `/approvals/act` (ADR-0076 D11:
the dispatcher owns registration, one route bridges to one service
slot). It claims exactly `/webhooks/redeliver` (`match: 'exact'`) for
`POST` only, resolves the request kernel's `webhooks` slot **per
request**, and forwards `context.request` unread to
`IWebhookService.handleRedeliver`, returning that `Response` untouched.
- Absent slot, or a service without `handleRedeliver`: `501
NOT_IMPLEMENTED` (an existing standard code), with a message naming
which. Never `ROUTE_NOT_FOUND`.
- A member that throws: sanitised `500 INTERNAL_ERROR`, original error
logged. A request whose body a transport already consumed: also `500`,
logged, and the member is not asked (it would otherwise answer "Request
body must be JSON." for a well-formed request).
- No preamble-gate change. The domain adds no credential rule of its
own: the member judges the caller from the request through the `auth`
service, 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/core` is untouched.
- `route-ledger.ts`: one `server-only` row, `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`: declares `webhooks.ts` with
`handBuilt: 0`.
- `packages/qa/http-conformance`: devDependencies on
`@objectstack/plugin-webhooks` and `@objectstack/service-messaging`,
both aliased to source (anchored) in its vitest config, so one suite
boots the real plugin on both doors; `pnpm-lock.yaml` gains the 6-line
importer link. `scripts/engine-double-contract.pinned.json` records that
suite's pinned fake-engine `findOne`.
- `packages/qa/dogfood`: a new `enforced` authz 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 22 `covers` keys;
the showcase live-mount parity gate pins the row as served by
plugin-webhooks' raw-app mount on a self-hosted boot (the
`/approvals/act` reason).
## The PM's four mechanism assumptions, as measured
1. **Premise: TRUE.** Measured on `main` `b7cd1af9d` before any change,
with the real `WebhookOutboxPlugin` over the real `MessagingService` on
a `LiteKernel` with no `http.server`, under the real `createHonoApp`:
the `webhooks` slot held `handleRedeliver` (a function), and `POST
/api/v1/webhooks/redeliver` answered `404 ROUTE_NOT_FOUND` ("Route Not
Found: /webhooks/redeliver") for an authenticated and an anonymous
caller alike.
2. **Slot timing: held.** plugin-webhooks registers the slot at
`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 answered `501` and the next request reaches the
member.
3. **Anonymous: both doors answer the same bytes.** Self-hosted mount:
`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.
4. **Method.** Measured on both doors: the self-hosted door answers
every non-`POST` (`GET`, `PUT`, `PATCH`, `DELETE`, `HEAD`) with
plugin-hono-server's `404 ENDPOINT_NOT_FOUND`; the hosted door answers
the dispatcher's `404 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 stays `dead`).
## Pins
- `packages/runtime/src/domains/webhooks-redeliver.test.ts` (13): the
real `HttpDispatcher` on a kernel with no `http.server` and a stub
member that reads its own JSON body. Forwarding with the body unread;
the member's refusal passed through; the exact `POST` claim (no other
method, sub-path or sibling); `501` for an absent slot and for an absent
member; per-request slot resolution; the sanitised `500`s; and the
gates: a signed-in non-member refused `403 PROJECT_MEMBERSHIP_REQUIRED`
on both spellings, a gated session refused `403` by 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 (#22756 landed first): the real
plugin on both doors. Through `createHonoApp`, an authenticated `POST`
replays a finished delivery and the row moves `dead` to `pending` in the
real outbox (so the JSON body crossed the catch-all unread); ten
refusals (anonymous `401`, not-JSON and empty `400`, missing and blank
`400`, unknown and cross-organization `404`, not-finished, never-sent
and veto `409`) answer the same status, `code`, headers and body bytes
at both doors (row ids masked); non-`POST` as 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, each `501
NOT_IMPLEMENTED`, with `/webhooks/redeliverx` as the route-miss control;
and the CONTROL below.
- CONTROL, kernel with `http.server`: the route is mounted once, as
`POST`, on the raw app, and the mount answers with its own bytes (no
dispatcher `httpStatus` / `route` / `hint` keys), equal to the bytes
measured on the base commit. This PR adds no mount there:
`dispatcher-plugin.ts` mounts 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, empty `git diff HEAD` and a clean `git status --porcelain`)
- **Registration removed** (the
`register(createWebhooksRedeliverDomain(...))` call replaced by `void
createWebhooksRedeliverDomain;` so the DTS build still passes
`noUnusedLocals`), runtime `dist/` rebuilt, `ablation-dist-preflight
--absent` green (marker absent from all 6 built files; tree mutate leg
confirmed). Unit pins: 10 of 13 red, every one on `ROUTE_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 (`404` where `200` or
`501` was expected, and the anonymous parity row); the 2 that stay green
are the non-`POST` pin 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.
- **Typed-absence guards, each disabled on purpose** (unit level; the
runtime suite resolves the domain from source). Slot guard off: exactly
the two slot-absence pins red (`TypeError: Cannot read properties of
undefined (reading 'handleRedeliver')`). Member guard off: exactly the
member-absence pin red (`500 INTERNAL_ERROR` instead of `501
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).
- **Showcase parity pin renamed**: the gate reports `POST
/webhooks/redeliver — LEDGERED BUT NOT MOUNTED` plus 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 with `main` `efcbac73c` merged;
after the merge `pnpm install --frozen-lockfile`, a full `turbo run
build` of the workspace, 73 of 73 tasks, and `rm -rf
packages/runtime/.objectstack`).
- `@objectstack/runtime`: `local` project in three chunks, 104 + 123 +
123 files, 1985 + 1520 + 1546 tests passed (19 skipped); `repo` project
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; `typecheck` 0.
- `@objectstack/dogfood`: `authz-conformance.test.ts`,
`authz-probe-blind-spot.test.ts` and
`route-ledger-live-mount-parity.dogfood.test.ts`, 3 files, 100 passed;
`typecheck` 0. The rest of that suite is CI's.
- Gates: `dispatch-gates --commands` derives 100 families for this diff
(the dispatch list's 53 without `pnpm lint`, plus 47 the diff adds); all
100 exit 0 on `be4051ae2`, and `dispatch-gates --ran` with 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) and `check:engine-double-contract` (the new suite's fake
`findOne` now opens with `assertEngineFindOnePredicate`; the pinned
ledger records it). `check:dual-build-cjs-loads` first refused with its
prerequisite (8 packages without `dist/`); after the full build it
measured 107 entries across 66 packages.
- Lint, narrowed and proven: the population is `eslint.config.mjs`'s own
`files` globs minus `NEVER_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 json` over 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 full `pnpm lint` is CI's.
## Acceptance notes
- Two doors, two 404 codes for a non-`POST`: `ROUTE_NOT_FOUND` (hosted)
and `ENDPOINT_NOT_FOUND` (self-hosted). Pre-existing on the self-hosted
side and the same split `/approvals/act` recorded; out of this card's
scope.
- The hosted door is stricter than the mount in two measured ways, as
the contract states: a signed-in caller who is not a member of the
request's environment gets `403 PROJECT_MEMBERSHIP_REQUIRED`, and a
session owing an authentication step gets the ADR-0069 `403`. The
self-hosted mount has neither gate.
- The scoped spelling `/api/v1/environments/:id/webhooks/redeliver`
reaches the domain through the dispatcher's generic scope strip
(membership-checked like the unscoped one); the self-hosted mount has no
such spelling.
- AGENTS.md route rule 5 (closed query-parameter set) does not apply:
this is a `POST` whose input is its JSON body, and neither the domain
nor the member reads `req.query`.
- Other raw-app mounts seen while reading (metadata HMR,
cloud-connection's marketplace proxy, local install and runtime config,
the CLI console, trigger-api's inbound hooks) are the sweep's to
classify under the same ruling; trigger-api's is a sibling card on this
lane (#22773 is held behind this one). Named here, not acted on.
Carrier: the #22564 sweep owner.
- The error-code ledger's provenance note for plugin-webhooks'
`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 the `domain:spec` seat's surface);
noted only.
- The branch merged `main` once (`efcbac73c`) before this PR; the three
incoming commits touch none of these files.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d489318 commit 8558668
16 files changed
Lines changed: 980 additions & 24 deletions
File tree
- .changeset
- packages
- qa
- dogfood/test
- http-conformance
- src
- runtime/src
- domains
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
28 | | - | |
| 28 | + | |
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
| |||
67 | 67 | | |
68 | 68 | | |
69 | 69 | | |
70 | | - | |
71 | | - | |
72 | | - | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
73 | 74 | | |
74 | 75 | | |
75 | 76 | | |
| |||
99 | 100 | | |
100 | 101 | | |
101 | 102 | | |
102 | | - | |
103 | | - | |
| 103 | + | |
| 104 | + | |
104 | 105 | | |
105 | 106 | | |
106 | 107 | | |
107 | | - | |
| 108 | + | |
108 | 109 | | |
109 | 110 | | |
110 | 111 | | |
| |||
330 | 331 | | |
331 | 332 | | |
332 | 333 | | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
333 | 353 | | |
334 | 354 | | |
335 | 355 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
990 | 990 | | |
991 | 991 | | |
992 | 992 | | |
| 993 | + | |
| 994 | + | |
993 | 995 | | |
994 | 996 | | |
995 | 997 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
109 | | - | |
| 109 | + | |
110 | 110 | | |
111 | 111 | | |
112 | 112 | | |
113 | | - | |
| 113 | + | |
114 | 114 | | |
115 | 115 | | |
116 | 116 | | |
| |||
298 | 298 | | |
299 | 299 | | |
300 | 300 | | |
301 | | - | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
302 | 306 | | |
303 | 307 | | |
304 | 308 | | |
| |||
387 | 391 | | |
388 | 392 | | |
389 | 393 | | |
390 | | - | |
| 394 | + | |
391 | 395 | | |
392 | 396 | | |
393 | 397 | | |
| |||
418 | 422 | | |
419 | 423 | | |
420 | 424 | | |
421 | | - | |
422 | | - | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
423 | 435 | | |
424 | 436 | | |
425 | | - | |
| 437 | + | |
426 | 438 | | |
427 | 439 | | |
428 | 440 | | |
| |||
433 | 445 | | |
434 | 446 | | |
435 | 447 | | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
436 | 451 | | |
437 | 452 | | |
438 | 453 | | |
439 | 454 | | |
440 | 455 | | |
441 | | - | |
442 | | - | |
443 | | - | |
444 | | - | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
445 | 460 | | |
446 | 461 | | |
447 | 462 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
47 | | - | |
| 46 | + | |
| 47 | + | |
48 | 48 | | |
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
53 | | - | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
54 | 56 | | |
55 | 57 | | |
56 | 58 | | |
57 | 59 | | |
58 | | - | |
| 60 | + | |
59 | 61 | | |
60 | 62 | | |
61 | | - | |
| 63 | + | |
62 | 64 | | |
63 | 65 | | |
64 | 66 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
149 | 149 | | |
150 | 150 | | |
151 | 151 | | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
152 | 159 | | |
153 | 160 | | |
154 | 161 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
23 | 24 | | |
| 25 | + | |
24 | 26 | | |
25 | 27 | | |
26 | 28 | | |
| |||
0 commit comments