Repository navigation
Commit cbfa5e4
fix(runtime): on the standalone server every /actions verb reaches the actions domain, so an anonymous caller gets 401, and no Allow lists a verb mounted only to refuse (#24244)
Fixes #24228
Clause-②: no
Basis for the Clause-② line: every request whose answer changes is
refused before and after. On the standalone server an anonymous
non-`POST` request to `/api/v1/actions/...` moves from the HTTP
adapter's `405` to the `actions` domain's `401`, and a request under a
verb no route takes on `/keys`, the `/notifications` faces or
`/mcp/skill` moves from the adapter's `405` (whose `Allow` listed
refusal-only verbs) to the domain's own refusal. No verb that was
refused is admitted, no route is added, and no accept set or export
widens.
## What changed
- `packages/runtime/src/dispatcher-plugin.ts`, three parts:
1. **A declaration of the paths whose domain decides every verb**
(`domainDecidesEveryVerb`). Each one is the serving route's verb and
pattern, made beside that route: the three `POST /actions` patterns in
`registerActionRoutes` (so both the unscoped and the
`/environments/:environmentId` base), `POST /keys`, the three
`/notifications` faces and `GET /mcp/skill`.
2. **The dispatcher's existing fallback seam answers those paths.**
`setFallbackHandler` runs only when the router matched no route, and
before the adapter's own unmatched answer. When the transport's router
resolves the request path, under a declared verb, to a declared pattern
(`IHttpServer.resolveMountedRoute`, the router's own verdict, no second
matcher), the request goes to `dispatch()` as `createHonoApp`'s
catch-all sends it: `req.method`, the whole sub-path after the prefix
(so `dispatch()` reads the environment off a scoped path, since an
unrouted request has no route params), `req.query`, and no body. The
domain answers. Its anonymous baseline is its first statement (ADR-0138
D2), and its `405` carries its own `Allow`. Any other unmatched request
is handled as before: the declarative-endpoint step, then the adapter's
404/405.
3. **Absence is loud.** A transport with the fallback seam but no
`resolveMountedRoute` keeps the adapter's answer on those paths, and the
boot says so once, at `warn`, with the consequence and the remedy. The
existing no-`setFallbackHandler` warning names the same consequence in
one more clause.
- Pins (real Hono socket, `HonoServerPlugin` + `createDispatcherPlugin`,
CORS off):
- new `packages/runtime/src/actions-every-verb.hono.integration.test.ts`
(79 cases): three path shapes on both prefixes. For `GET`, `PUT`,
`PATCH`, `DELETE` and `TRACE`, an anonymous call gets `401
UNAUTHENTICATED`, `error.httpStatus` 401, no `error.details` and no
`Allow`. A signed-in call gets `405 METHOD_NOT_ALLOWED`, `Allow: POST`,
`error.httpStatus` 405 and no `error.details`, which is the domain's
door and not the adapter's. `HEAD` is checked by status and header.
Anonymous `OPTIONS` gets the domain's `405` + `Allow: POST` (see
Acceptance notes). Controls: anonymous `POST` 401 unchanged, and a
four-segment path stays the adapter's 404.
- `keys-every-verb.hono.integration.test.ts`: `OPTIONS` and `TRACE`, 4
cases (anonymous 401 no `Allow`; signed-in domain 405 with `Allow:
POST`).
- `notifications-every-verb.hono.integration.test.ts`: `OPTIONS` and
`TRACE` on each face, 12 cases (anonymous 401; signed-in the
dispatcher's `404 ROUTE_NOT_FOUND`, no `Allow`).
- `mcp-skill-method-not-allowed.hono.integration.test.ts`: its `PUT`
boundary case pinned the adapter's `Allow` `['DELETE', 'GET', 'HEAD',
'POST']`, which is the defect. It is replaced by `PUT`, `PATCH`,
`OPTIONS` and `TRACE` getting the domain branch's `405` with `Allow:
GET`.
- `dispatcher-plugin.fallback-absence-warn.test.ts`: the new warning,
once at `warn`, and silence when the resolver exists.
- `domains/keys-actions-method-not-allowed-allow.test.ts` (PR #24227's
pin, the control): **assertions unchanged, still green**. Its docblock
and one title said the standalone `/actions` 405 is the adapter's
because `GET` is not mounted. After this change that is false, so the
wording now says it is the domain's, through the fallback seam.
- `.changeset/24228-actions-every-verb-anonymous-floor.md`:
`@objectstack/runtime` `patch`.
Not touched: `packages/spec` (the `IHttpServer` contract line included),
`packages/plugins/plugin-hono-server`, `domains/actions.ts`, the
`dispatcher.dispatch(… req.body …)` sites (no existing mount changed),
`readGatedSessionUser`, `route-ledger.ts` (no route is mounted, so no
row and no census move).
## The route, and why it is not refusal-only mounts (H2, measured)
- **The adapter cannot tell a refusal-only mount apart.** Its `Allow` is
`allowedMethodsForPath` over `registeredRoutes`, and only
`get`/`post`/`put`/`delete`/`patch(path, handler)` fill
`registeredRoutes`. Nothing in that signature marks a mount as
refusal-only. A marker would have to ride on the handler object, which
is a cross-package protocol the contract does not declare, or be a new
parameter, which is a `packages/spec` change. This PR takes neither.
- **The adapter does not need to tell them apart.** Its unmatched answer
is the only producer of the wrong `Allow`. That answer runs after the
fallback seam declines. So if the domain answers every request the
router cannot route on its paths, the adapter never builds an `Allow`
there. This is the first candidate in H2. It needs no `packages/spec`
change and no adapter change, and the contract line stays true where the
adapter does answer.
- **Why not the `/keys` shape for `/actions`.** With the five
`IHttpServer` verbs mounted, a verb `IHttpServer` cannot register still
reaches the adapter. That is `OPTIONS` when CORS is off. It is also
`TRACE` or an extension method such as `PROPFIND` **with CORS on too**,
because CORS answers only `OPTIONS`. The adapter's `Allow` there is
every registered verb. So refusal-only mounts on `/actions` would turn
today's accurate `Allow: POST` into `DELETE, GET, HEAD, PATCH, POST,
PUT`. With the fallback the four mounts are not needed. One mechanism
covers every verb on `/actions`, and it covers the verbs left over on
the families that already have the mounts.
## Measured
Standalone composition in process: `LiteKernel`, a fake `auth` that
answers a session only for a test header, `HonoServerPlugin` on a real
socket, `createDispatcherPlugin`, and `registerShareLinkRoutes` at
`kernel:ready`. Four compositions: CORS off or on, each with project
scoping off or on (`enableProjectScoping: true` mounts both prefixes).
For each, 14 paths × 9 verbs (`GET HEAD POST PUT PATCH DELETE OPTIONS
TRACE PROPFIND`) × anonymous and signed in, through raw `node:http`.
That is 1008 requests at base `2bcd406a2` and 1008 at head `d60989609`.
The implementation blob `24648a251fc8` has been the same from there to
the final head `6c8625968`. 368 rows changed. Every changed row was the
adapter's `405` at base and is the domain's answer at head: 148 now
`401`, 172 now the domain's `405`, 30 now the dispatcher's `404`, and 18
`HEAD` rows went from `405` to `401`. No served answer moved.
**H1, confirmed.** At base, every anonymous
`GET`/`HEAD`/`PUT`/`PATCH`/`DELETE`/`TRACE`/`PROPFIND` on all three
`/actions` shapes and both prefixes got `405` + `Allow: POST` with the
adapter's `error.details`, CORS on and off. `OPTIONS` got the same with
CORS off and `204` with CORS on.
| `/api/v1/actions/task/close` (all shapes, both prefixes alike) | base
| head | `createHonoApp` |
|---|---|---|---|
| anonymous `GET` `PUT` `PATCH` `DELETE` `TRACE`/`PROPFIND` | `405`,
`Allow: POST`, adapter | `401 UNAUTHENTICATED`, no `Allow` | `401`, no
`Allow` |
| anonymous `HEAD` | `405`, `Allow: POST` | `401`, no `Allow` | `401` |
| signed-in non-`POST` | `405`, `Allow: POST`, adapter envelope | `405`,
`Allow: POST`, domain envelope | `405`, `Allow: POST` |
| anonymous `OPTIONS`, CORS off | `405`, `Allow: POST`, adapter | `405`,
`Allow: POST`, domain | `405`, `Allow: POST` |
| `OPTIONS`, CORS on | `204` | `204` | `204` |
The `createHonoApp` column: `createHonoApp({ kernel, prefix: '/api/v1'
})` over the same fake `auth`, driven with `app.request`, CORS off and
on. `PROPFIND` stands in for `TRACE` there, because the fetch `Request`
refuses `TRACE`.
**H3, the `Allow` each refusal-only family answers** (a verb no route
takes: `OPTIONS` with CORS off, `TRACE`/`PROPFIND` with CORS either way,
and on `/mcp/skill` also `PUT`/`PATCH`):
| family | served | `Allow` at base | at head |
|---|---|---|---|
| `/keys` | `POST` | `DELETE, GET, HEAD, PATCH, POST, PUT` | anonymous
`401` no `Allow`; signed-in domain `405` `Allow: POST` |
| `/notifications` | `GET` | `DELETE, GET, HEAD, PATCH, POST, PUT` |
anonymous `401`; signed-in `404 ROUTE_NOT_FOUND`; no `Allow` |
| `/notifications/read` | `POST` | same six | same as above |
| `/notifications/read/all` | `POST` | same six | same as above |
| `/mcp/skill` | `GET` | `DELETE, GET, HEAD, POST` | domain `405`
`Allow: GET` |
| `/actions` (3 shapes × 2 prefixes) | `POST` | `POST` (accurate; the
defect was the missing 401) | domain `Allow: POST` signed in; anonymous
`401` |
| `/share-links` (root) | `GET`, `POST` | `GET, HEAD, POST` | unchanged,
measured-clean |
| `/share-links/abc` | `DELETE` | `DELETE, GET, HEAD, PATCH, POST, PUT`
| **unchanged**, see Acceptance notes |
| `/share-links/abc/resolve` | `GET` | same six | **unchanged**, see
Acceptance notes |
The head column matches the `createHonoApp` answers row for row on
`/actions`, `/keys`, `/notifications/read` and `/mcp/skill` for
`GET`/`PUT`/`OPTIONS`/extension verbs. Two exceptions predate this PR
and are listed in Acceptance notes.
**H4, confirmed.** PR #24227's pin is green with its assertions
unchanged. Its standalone `/actions` row (`405`, `Allow: POST`) now
reads the domain's answer instead of the adapter's.
## Tests
- Targeted pins at `194b25dc2`. The runtime tree at `194b25dc2` equals
the final head for every file this PR touches, and the implementation
blob is the same (`24648a251fc8`). Command: `pnpm --filter
@objectstack/runtime exec vitest run --project local --maxWorkers=2`
over the six touched test files plus `dispatcher-plugin.routes`,
`share-links-every-path`, `dispatcher-plugin.endpoint-fallback` and
`dispatcher-plugin.streaming-fallback`. Result: `Test Files 10 passed
(10)`, `Tests 234 passed (234)`.
- Full suite at the final head `6c8625968`, after merging `origin/main`
and rebuilding (`pnpm install --frozen-lockfile`, `turbo run build
--filter='@objectstack/hono...'`): `pnpm --filter @objectstack/runtime
exec vitest run --project local --maxWorkers=2` gives `Test Files 413
passed (413)`, `Tests 6411 passed | 19 skipped (6430)`.
- `pnpm --filter @objectstack/runtime typecheck` at `6c8625968`: exit 0,
`check:test-typecheck: OK` with the ledger unchanged. All six touched
test files are in the `tsconfig.test.json` program (`--listFiles`).
- `pnpm exec eslint --no-inline-config` over the seven changed `.ts`
files: exit 0.
- `test:repo` (the `repo` vitest project) was not run locally. No file
of that project changed, and CI's `Test Core` runs it.
**Ablations** (one per member, not kept). Each ran after the fix was
committed, through `node scripts/ablation-replace.mjs` in WRAP mode,
inside a script that also carries an EXIT/INT/TERM trap restoring from
`HEAD`. Every pin imports `./dispatcher-plugin.js` from source, and
`@objectstack/plugin-hono-server` (read from its `dist/`) is not
mutated, so no `dist/` of the mutated module sits on the resolution
path.
- Member 1: the three `/actions` declarations replaced by a marker. The
anchor went 1 to 0 and the blob `24648a251fc8` to `4bcfc24a0ac7`.
Result: `Tests 72 failed | 27 passed (99)` over the actions pin, the
keys pin and PR #24227's control. The 72 are every anonymous, signed-in,
`HEAD` and `OPTIONS` row of the actions pin (`expected 405 to be 401`,
`expected undefined to be 405`). Green: its 6 `POST` controls, its 404
control, the keys pin and the control file. Restore: blob equals `HEAD`
(`24648a251fc8`), `git diff HEAD` is empty, and the marker count is 0.
- Member 2: the `/keys`, `/notifications` and `/mcp/skill` declarations,
three nested anchors, each 1 to 0 (blob `24648a251fc8`, then
`1bfef7b8cabc`, then `970cf8d3dba7`, then `258148332710`). Result:
`Tests 20 failed | 124 passed (144)`. The 20 are the new
`OPTIONS`/`TRACE` rows and the four `/mcp/skill` rows. Messages include
`expected 'DELETE, GET, HEAD, PATCH, POST, PUT' to be 'POST'`, `expected
'DELETE, GET, HEAD, POST' to be 'GET'`, `expected 405 to be 401` and
`expected 405 to be 404`. The actions pin stayed green. Restore was
proven at each of the three levels (blob equals `HEAD`, `git diff HEAD`
is empty).
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at the final head `6c8625968` derived 62
commands. Each was run with its exit code captured before any pipe and
recorded as `COMMAND :: exit N`. `--ran` reconciles: `62 derived, 61
run, 1 NOT-MEASURED, 0 UNRUN`. The 61 that ran exit 0,
`check:route-envelope` and `check:nul-bytes` among them.
- **NOT MEASURED: `pnpm check:dual-build-cjs-loads`.** Reason:
`PREREQUISITE NOT MET` (exit 3). It reads the built `dist/` of every
package, 39 have none in this worktree, and the whole repository was not
built locally. This diff changes no `exports`, no `package.json` and no
build config. CI's `Lint & Repo Gates` runs it.
- Not run: `node scripts/check-platform-checklist.mjs` (no exported
symbol renamed or removed), and the local-only `check:dev-prereqs` and
`check:skill-frame-freshness` (nothing under `packages/spec` or the
skill frame changed). The repo-wide `pnpm lint` is the PR's `Lint & Repo
Gates` check-run.
## Acceptance notes
- **`/share-links/*` still lists refusal-only verbs in `Allow`.** On the
standalone server `/share-links` is `plugin-sharing`'s family
(`registerShareLinkRoutes`), not the dispatcher's. Its `${base}/*`
catch-all is mounted for the five verbs. So `OPTIONS` with CORS off, and
`TRACE`/`PROPFIND` with CORS either way, on `/api/v1/share-links/abc`
(served: `DELETE`) and `/api/v1/share-links/abc/resolve` (served: `GET`)
get the adapter's `405` with `Allow: DELETE, GET, HEAD, PATCH, POST,
PUT`. This holds at base and at head. The route here does not reach it:
the fallback seam is one handler and the dispatcher's, and a dispatcher
answer on `/share-links` would be a second owner of that surface.
`plugin-sharing` is outside this claim's file surface. Reported to the
seat with the options.
- **Anonymous `OPTIONS` on `/actions` (CORS off only)** gets the
domain's `405` + `Allow: POST`. That is the same answer as on a
`createHonoApp` host. The shared anonymous decision lets every `OPTIONS`
through as a CORS preflight, and `domains/actions.ts` is read-only here.
This is PR #24227's open question, unchanged.
- **Answers the fallback writes skip the per-route instrumentation.**
There is no `X-Request-Id` echo, because the seam is installed on the
raw server. That is the same as the adapter answer it replaces, and the
transport's response observation still labels them unmatched.
- **Divergences that predate this PR and are unchanged by it:**
- `HEAD /api/v1/mcp/skill` gets `501` on the standalone server (Hono
serves `HEAD` from the `GET` route) and `405` + `Allow: GET` on
`createHonoApp`.
- The signed-in non-face `/notifications` 404 is `ROUTE_NOT_FOUND` on
the standalone server and `RESOURCE_NOT_FOUND` on `createHonoApp`, as
#24024's notes record.
- **The adapter's own matcher and the Hono router disagree on the
`/share-links` root.** Hono routes `PUT /api/v1/share-links` to the
`/share-links/*` catch-all, but the adapter's `matchesRoutePattern` does
not count that pattern for the root. So `Allow` there lists only `GET,
HEAD, POST`, which happens to be accurate. Read from the measurement, no
wrong answer. Carrier: none.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 9975c26 commit cbfa5e4
8 files changed
Lines changed: 559 additions & 29 deletions
File tree
- .changeset
- packages/runtime/src
- domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 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 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
166 | 166 | | |
167 | 167 | | |
168 | 168 | | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
0 commit comments