You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 75b6d6a
Browse filesBrowse the repository at this point in the historyBrowse files
feat(trigger-api): the trigger-api service serves the inbound hook from a Request, and the self-hosted route is read from http.server first (trigger-api segment 3 of ruling A on #22757) (#22873)
Fixes#22774
Clause-②: yes
Dispatched by the `domain:services` seat 2 PM (claim `6109841597`,
branch `claude/issue-22774-inbound-hook-member`), as triage scoped the
card in `6109754072`: trigger-api segment 3 of ruling A on #22757
(director record `6105447950`). Disclosure: the card is `security`, so
this body names classes and positions only.
## What changes
- **The member.** `ApiTriggerPlugin` registers the `trigger-api` service
and implements the optional
`ITriggerApiService.handleInboundHook(request, hook)` declared by #22772
(`packages/triggers/trigger-api/src/plugin.ts`). It takes a web-standard
`Request` and returns a `Response`, with no Hono context, no raw app and
no `http.server`.
- **One door, two faces.** The self-hosted raw-app mount and the member
both call one private body, `serveInboundHook`. It does one body read,
reads one pair of header names, makes one call into
`ApiTrigger.handleRequest` and answers one way. `handleRequest` holds
the hook match, the per-hook secret read, the constant-time verifier
(both signature forms, the 300 s window) and every status, and
verification still runs before anything is enqueued. The mount hands the
body its real Hono context, so it makes exactly the calls it made before
(`c.req.text()`, `c.req.param` twice, `c.req.header` twice, then
`c.json(body, status)`). The member hands it a `Request` adapter that
answers the way Hono's `c.json` answers. The mount is not a caller of
the member, and the path is mounted once. This follows the landed
`webhooks` precedent (PR #22797).
- **The slot is registered wherever the trigger is installed**, in the
same `kernel:ready` step that registers the `api` trigger. Without an
automation service there is no trigger, so there is no slot and no
mount. The boot `warn` says so.
- **`http.server` is read before the `http-server` alias.** Each name is
read in its own `try`, and the first one present answers. A single
server registered under both names is mounted once. With two servers
under the two names, the mount goes on the `http.server` one.
- **Comment text the change made false**, kept true: `api-trigger.ts`
(the `handleRequest` docblock and the class docblock name both faces)
and `trigger-api-route-ledger.ts` (its header now says the server is
read as `http.server` first). The ledger row is unchanged.
- **The route census learns the canonical key.** `HOST_APP_REACH` in
`trigger-api-route-ledger.conformance.test.ts` now matches the canonical
service key `http.server` as well as the alias. `plugin.ts` reads the
canonical name now, so a census that knew only the alias would miss a
second module reaching the same server that way. A case for each
direction is added. This is an on-the-spot extension of a guard in this
package, in the same class as the card. It is declared here and in the
report.
- `hono` is added as a **devDependency** (`package.json` + 3 lockfile
lines). It is test-only, so the parity table reads the mount's real wire
answer, as in PR #22797.
- Changeset `.changeset/22774-inbound-hook-member.md`:
`@objectstack/trigger-api` minor, `Clause-②: yes`.
Zero `packages/spec` edits, no `ServiceSlotContracts` entry (#22848
remains open for it), no runtime dispatcher domain or allow-list row
(#22773 remains open; whichever of the pair lands second runs the
end-to-end pin), no replay-protection change.
## The PM's mechanism assumptions, measured
1. **Holds.** The member is declared on `origin/main` as
`handleInboundHook?(request, hook)` in
`packages/spec/src/contracts/trigger-api-service.ts`. Its docblock's
status list is the member's spec: `401 INVALID_SIGNATURE` for every
unverified post, `503 SERVICE_UNAVAILABLE` (secret unreadable, no
queue), `400 INVALID_REQUEST`, `503 ENQUEUE_FAILED`, `202 { accepted,
messageId }`.
2. **One sentence is stale.** One docblock bullet names only the
body-only signature form ("`sha256=` and the hex HMAC-SHA256 of the raw
body under that secret"). Since PR #22803, the raw mount also verifies
the timestamped form within its 300 s window. The member answers what
the mount answers, both forms included (pinned). The sentence is listed
under Acceptance notes, and `packages/spec` is not edited here.
3. **Holds.** The mount delegates to the shared body with its wire
answers unchanged. That is pinned by the existing 42 tests, which pass
unmodified, and by the member/mount parity table below.
4. **Holds.** `plugin.ts` read only the `http-server` alias. All four
registrations are pinned: canonical alone, alias alone, both on one
server, and two servers.
5. **Already done by PR #22822.** `git grep -n "no oracle" d489318 --
packages/triggers/trigger-api/src` returns 0 hits, against control
`HOOKS_PATH` in `plugin.ts` (3 hits). PR #22822 restated that comment,
so nothing is changed for it here.
## Evidence (head `f44f31a18`)
- `pnpm --filter @objectstack/trigger-api exec vitest run` passes `Test
Files 3 passed (3)` and `Tests 54 passed (54)`. That is 42 existing
tests plus 12 new ones in `inbound-hook-member.test.ts`. `pnpm --filter
@objectstack/trigger-api run typecheck` is clean, and `tsc --listFiles`
reaches all 7 `src/` files, the new test among them.
- **What the new pins cover.** The tests boot a real `LiteKernel` with
the real plugin, plus a real Hono app for the mount.
- The hosted shape (no HTTP server): the member answers 17 arms, each
with its `code` and `status`. They cover every unverified-post class,
the 503 residual, the 400s, both signature forms, the empty body, and a
hand-written body verified as its own bytes. Nothing is enqueued for a
refusal. It also answers the two queue faults, passes
`x-idempotency-key` through, and takes the flow and hook id from `hook`,
never from the URL.
- Parity: for every arm, the member and the mount on `http.server` give
equal status, headers and body bytes.
- Composition: four server shapes, plus a run with no automation
service.
- **Ablations.** Every negative pin was run with the forbidden behaviour
put back. Each mutation went through `scripts/ablation-replace.mjs`,
with the anchor hit and the write verified on disk. Each restore was
proven by the blob hash equal to `HEAD`'s and an empty `git diff HEAD`.
The subject resolves to `src` (relative imports, and `@objectstack/core`
aliased to source), so no `dist/` rebuild applies.
| Behaviour put back | Pins that went red |
|---|---|
| main's alias-only read | 4: both parity tests, canonical alone, two
servers |
| the alias read first | 1: two servers |
| the path mounted twice | 4: every mounted-once pin |
| the slot filled where no trigger is installed | 1: no automation |
| the verifier skipped | 2: the hosted-shape arm table, parity |
| path parameters re-parsed from the URL | 1: hook, not URL |
| a member header the mount does not send | 3: the arm table, both
parity tests |
| a re-serialised body verified instead of the sender's bytes | 2: the
arm table, parity |
| the census forgetting the canonical key | 2: both census probes |
The first double-mount attempt was a no-op. Its replacement contained
its own anchor, so the anchor count rose from 1 to 2. The tool refused
the write, restored the file and ran no test. It was re-run on a
different anchor, with the result above.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at this head derives 75 commands (no
stale-tree warning). 74 were run, all exit 0. `check:lean-entry-closure`
was re-run after building `@objectstack/objectql` and measured green.
**NOT MEASURED: `check:dual-build-cjs-loads`**, reason: `PREREQUISITE
NOT MET`. It reads every package's `dist/` (82 absent locally), so it
would need a full `pnpm build`. It is declared to CI. Reconciled with
`--ran` and exit codes recorded: `75 derived, 74 run, 1 NOT-MEASURED, 0
UNRUN`.
- **Lint, a proven narrowing** (the repo-wide `pnpm lint` run is CI's).
`pnpm exec eslint --no-inline-config --format json` over the 5 changed
TypeScript files gives 0 errors and 0 warnings. ESLint's own JSON
reports `files 5`. The population comes from `eslint.config.mjs`: its
`packages/**/*.{ts,tsx,mts,cts}` objects cover all five, and
`isPathIgnored` is false for each. Invariance: the config never enables
type-aware linting (no `parserOptions.project`, no typed rules), so this
diff cannot move any verdict on an untouched file.
## Acceptance notes
- **Stale spec docblock sentence** (not edited, zero-`packages/spec`
scope). In `packages/spec/src/contracts/trigger-api-service.ts`, the
third bullet under "What it serves" names only the `sha256=` body-only
form. Today's mount, and so the member, also verifies the timestamped
form within its tolerance window. The docblock's own governing line
("exactly as the plugin's self-hosted mount serves it today") stays
true. No open PR is known to touch this file.
- **For the dispatcher-domain author (#22773).** If the forwarded
`Request`'s body was already read, the member rejects with the
platform's body-already-used `TypeError` rather than answering. That
matches the contract, which says the caller answers that case as a
server fault, so the caller should check before forwarding.
---
_Generated by [Claude
Code](https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(trigger-api): the `trigger-api` service serves the inbound hook from a web-standard `Request` (`ITriggerApiService.handleInboundHook`), and the self-hosted route is found under `http.server` too (#22774)
6
+
7
+
Clause-②: yes
8
+
9
+
-**What is new.** Wherever `ApiTriggerPlugin` installs its `api` trigger, it now registers the `trigger-api` service and implements its optional `ITriggerApiService.handleInboundHook(request, hook)` member. That member is the inbound hook, `POST /api/v1/automation/hooks/:flowName/:hookId`, taking a `Request` and returning a `Response`, with no raw app, no Hono context and no `http.server`. A hosted tenant kernel owns no socket, so this is how the door reaches it. Its one caller is the runtime HTTP dispatcher's exact `POST /automation/hooks/:flowName/:hookId` domain, which lands separately. The flow name and the hook id come from `hook`, the route the caller matched, and are never parsed again from the URL. The body is read once, from the request itself, and verified as the sender's own bytes.
10
+
-**One door, two faces.** The member and the self-hosted route run the same code: the same hook match, the same per-hook secret, read at that moment, the same constant-time verification of `x-objectstack-signature` (the timestamped form and the deprecated body-only form alike), done before anything is enqueued, and the same answer for every outcome. For the same request they answer the same status, the same `Content-Type: application/json` and the same bytes: `401 INVALID_SIGNATURE` for every post that does not verify, `503 SERVICE_UNAVAILABLE` when the hook's secret cannot be read or no queue service is registered, `400 INVALID_REQUEST` for a body that is not a JSON object, `503 ENQUEUE_FAILED`, or `202` with `{ accepted: true, messageId }`.
11
+
-**No trigger, no service.** Where the automation service is absent, no trigger is installed, so the `trigger-api` slot stays empty (as no route is mounted). The member's caller then answers its typed absence. The boot `warn` line says so.
12
+
-**The route is found under `http.server` too.** The plugin reads `http.server` first, then the deprecated `http-server` alias. A host that registers its server only as `http.server` (the runtime's `config.server` path) now gets the self-hosted route. One that registers a single server under both names still gets it once. Where the two names hold two servers, the route goes on the `http.server` one.
13
+
-**The self-hosted route is otherwise unchanged.** Where it was mounted before, it answers the same status, headers and bytes for the same request, and the path is never mounted twice.
0 commit comments