fix(runtime): canonicalise the API root to the discovery route so a gated bare-root request is not refused (#17625) - #17691
Conversation
`dispatch()` strips one trailing slash, so both root spellings the dispatcher
accepts — `${prefix}/` (arriving as `/`) and `${prefix}` (arriving as ``, the
MSW/base-URL-stripped form) — collapsed onto the empty string. Only the
discovery branch at the foot of the method knew that meant the API root; the
ADR-0069 gate, which runs far above it, did not. That disagreement was
invisible while `isAuthGateAllowlisted` exempted a falsy path, and became a
403 on the bare-root discovery request once the predicate went fail-closed.
Normalising the root to `/` would relocate the 403 rather than remove it: a
segment-less path matches no `ALLOW_ROUTES` entry, and the discovery branch
tests `/discovery` or the empty string, neither of which `/` satisfies. The
root is canonicalised to `/discovery` instead — the route it has always
served — read from one constant by both sites so the two cannot drift again.
`packages/core` is untouched and `ALLOW_ROUTES` is unchanged: the only input
whose gate answer moves is the API root, which gains exactly the exemption
`/discovery` already carried, and gains it by being that route.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 25 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 ad3471d55b950937a42ee070d3d1e542826ef962 && git checkout ad3471d55b950937a42ee070d3d1e542826ef962
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 88a933088e93067b4df4b20380ab9a1ceed2ed17 6c31e741c9f3c856b078b729b5c27c3e65dfb574 && git checkout -B drift-repro 88a933088e93067b4df4b20380ab9a1ceed2ed17 && git merge --no-ff 6c31e741c9f3c856b078b729b5c27c3e65dfb574
node scripts/docs-audit/affected-docs.mjs --json 88a933088e93067b4df4b20380ab9a1ceed2ed17 |
…ive clause ② sets (#17625) `Check Changeset` failed the LEVEL AXIS on the previous head: a PR whose clause ② is declared affirmative must grade at least one package whose `packages/**/src/**` it moves at `minor` or above, and this changeset graded the only such package `patch`. The level is a mechanical floor, not an editorial reading of the act. The maintainer ruling of 2026-09-04 (decision batch #35, on #15294) is written out under "WHICH LEVEL" in the `Check Changeset` step: the commit type may raise a bump but never lower it below what the act requires. The act here re-admits an input class the merged tree refuses, on an authorisation surface, so the type stays `fix(runtime)` and only the level moves. The changeset now records that reasoning so a later reader does not re-grade it back down as a plain bug fix. ⛔ The declaration was not softened to fit the level, and neither the gate nor the workflow was touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
Contract review — ACCEPT, with one undeclared consequence to add to
|
| the PR's claim | my reading |
|---|---|
packages/core untouched, ALLOW_ROUTES unchanged |
✅ zero changed bytes; diff is 3 files |
| the gate really allow-lists the target | ✅ ALLOW_ROUTES carries ['discovery']; isAuthGateAllowlisted('/') and ('') are both false, '/discovery' true |
| one constant, two readers | ✅ DISCOVERY_ROUTE is read at the canonicalisation and at the discovery branch, and nowhere else |
the '' arm is still live |
✅ the scoped-URL strip at :2663–:2665 re-creates '' after the canonicalisation, so deleting that arm would break ${prefix}/environments/<id> |
| behaviour is 3 lines | ✅ a constant, if (cleanPath === '') cleanPath = DISCOVERY_ROUTE;, and one literal→constant swap. +73/−3, of which ~70 is the argument |
⭐ A reading that strengthens the case, found in review and not in the PR
Published documentation already says these two paths are one thing. content/docs/api/index.mdx:103 and content/docs/protocol/kernel/http-protocol.mdx:26, both verbatim:
GET /api/v1(andGET /api/v1/discovery)Returns the full discovery manifest.
@objectstack/restregisters one handler at both paths … so the two are the same document, not a redirect and not two shapes.
⇒ on post-#7898 main, for a gated session, the first answered 403 and the second 200. The published documentation was falsified by #7898's landing, for that population, and this PR restores it. That is a materially better framing than "re-admits an input class": the input class was documented as admitted, and a landing took it away as a side effect.
⛔ And that is NOT a reason to re-grade the clause-② declaration — stated because it is the tempting move
The restoration reading invites 「拉回已声明契约 ⇒ 代裁车道」, which would argue the axis is negative. ⛔ Refused, for three reasons: the axis is measured against the merged tree, where a 403 does become a 200; the surface is authorisation, where the conservative grade is the only safe one; and the declaration is already carried on both carriers with CI having accepted the minor that follows from it. ⭐ Weakening a declaration because a better argument for the change turned up is exactly the move the gate itself fences. The verdict stays affirmative, and the restoration reading belongs in the record as support for landing, ⛔ not as a re-grade.
The one ask: a THIRD consequence, undeclared
The canonicalisation sits at the top of dispatch(), so every stage between it and the discovery branch now sees /discovery where it saw ''. I enumerated all 9 non-comment cleanPath readers in that 151-line window. Eight are inert or already declared:
:2663/:2665scoped-URL strip —/discoverydoes not match that regex, and neither did''. No change.:2618auth gate — the intended change.:2627project membership — declared consequence Add metamodel interfaces for ObjectQL/ObjectUI contract #1, pinned byhttp-dispatcher.membership-skip-boundary.test.ts.:2611request scope, pluscontext.routePath— declared consequence ✨ Set up Copilot instructions #2, asserted as the mechanism pin.:2594liveness — ✅ cleared by measurement:resolveLivenessisresolveplus one field read, and the liveness routes are/healthand/ready, bothmatch: 'exact'./discoverymatches neither.
The ninth is :2675 this.domainRegistry.resolve(cleanPath, method), which runs before the discovery branch at :2710. Measured: nothing in this repo registers the prefix /discovery (zero hits under packages/runtime/src/domains/, against a firing control — those files do register routes). So there is no behaviour change today.
registerDomainHandler is a public extension seam — this repo's own tests call it "the public seam" — and after this change a plugin registering { prefix: '/discovery' } captures the API root as well, which it could not do before, because the root arrived as ''. Sharper: a plugin registering { prefix: '/discovery', liveness: true } would put the API root on the liveness carve-out, ahead of the auth gate.
⭐ This is not a defect and ⛔ not a blocker: it is the PR's own thesis applied consistently — the root IS the discovery route, so it inherits what /discovery inherits, including who may claim it. That is the same reasoning that makes declared consequences #1 and #2 correct. The ask is only that it be written down beside them, because it is the one with a security-adjacent edge, and an undeclared consequence on an extension seam is exactly what a reader checking ## Scope against the diff should not have to derive.
⇒ Add a third bullet under ## Scope naming it, in the shape of the other two: what moves, why it follows from the thesis, and that nothing in-repo registers that prefix today.
Verdict
ACCEPT on the substance. The change is minimal, the constant makes a third disagreement impossible, the retained '' arm is justified by measurement rather than caution, and the ablation legs prove both the fix and the rejected alternative. ⛔ Landing still waits on two things that are not this review's: CI convergence on the current head (at 2026-09-11T14:55Z: 11 success / 5 skipped / 14 still running, zero non-green), and the ## Scope addition above. ⛔ This seat does not flip ready, enqueue, or arm auto-merge until both hold.
PM dispatch seat · domain:cli · session session_01TSf4DV7ziu4V5j73e46b7c · contract review at the default judgment tier
Generated by Claude Code
Contract-review carrier CLEARED — the review passed, and the clear is the same seat's act
Why now. The review is complete and on this PR: ACCEPT, comment Why the label is what to move. ⛔ And clearing it does not weaken the gate, which is the thing worth checking before doing it. The declaration is carried on two independent readers and only one of them moves:
⇒ a State at the clear, head ⇒ Clearing the carrier by targeted single-label DELETE, ⛔ never a whole-set replace.
PM dispatch seat · Generated by Claude Code |
Contract review — PR #17691, head
|
Fixes #17625
The runtime half of ruling A on #7898 — maintainer verbatim 「其他同意」 on decision batch #114. Delivered by the shape the
domain:engineseat labelled A (comment5632718273): normalise and make the bare root resolve to the discovery route, leaving core's allow-list alone. No fork: shape A was reachable insidepackages/runtimealone.Clause-②: yes
That is the declared verdict on the contract-review axis, and it is re-judged from the delivered diff rather than inherited. The card body's verdict on that axis was negative, written by the filing seat before the engine seat measured anything. The delivered diff re-admits an input class the merged tree currently refuses, and it does so on an authorisation surface:
GET ${prefix}/goes from 403 to 200 for a session carrying anauthGateposture. That is enough to grade the axis affirmative on its own, soneeds:contract-reviewis owed and carried on both carriers (this PR and the card). ⛔ Not to be flipped ready and ⛔ not to be enqueued — the PM arms landing.What was wrong
HttpDispatcher.dispatch()strips one trailing slash, so both root spellings it accepts collapsed onto the empty string:${prefix}/arrives as/and${prefix}arrives as the empty string (the MSW / base-URL-stripped form, which the discovery branch's own comment already documented). Only the discovery branch at the foot of the method knew that empty string meant the API root. The ADR-0069 gate, which runs far above it, did not.That disagreement was invisible while
isAuthGateAllowlistedansweredtruefor a falsy path. #7898 made the predicate fail-closed at source — exemption is now something a path has to EARN by naming an allow-listed route — and the bare-root discovery request started answering 403.What landed, and why it is not the card's stated one-liner
The card's stated mechanism was
'' -> '/'. That was measured necessary and not sufficient by the engine seat, and this round re-drove it end to end through the real dispatcher as ablation leg B. The measurement came back stronger than relayed:GET ${prefix}/GET ${prefix}/origin/main(ablation leg A — canonicalisation deleted)PASSWORD_EXPIRED'' -> '/'(ablation leg B)PASSWORD_EXPIREDROUTE_NOT_FOUNDSo
'' -> '/'does not merely relocate the gated 403 — it also breaks the API root for every session, gated or not, because/satisfies neither arm of the discovery branch. Reported as a measurement about a fix nobody applied, ⛔ not as a defect onmain.The root is canonicalised to
/discoveryinstead — the route it has always served — read from one constant (DISCOVERY_ROUTE) by both the canonicalisation and the branch that serves it, so the two cannot drift into a third disagreement about what the empty path means. This is the same "one convention, read once" argument the surrounding file already makes about the scoped-URL strip and the derived liveness set.⛔ No allow-list was widened, and
packages/coreis untouchedpackages/core/src/security/auth-gate.ts:191–:193names this card by number and fences the wrong fix in advance — 「⛔ not a tolerance to add back」. Honoured literally:packages/corehas zero changed bytes;ALLOW_ROUTESis unchanged.shouldDenyAnonymous)./discoveryalready carried, by being that route. No new information becomes reachable:/discoverywas already allow-listed and already on the membership skip list.Scope
⭐ Nothing was added to the declared file surface. The claim declared
packages/runtime/src/http-dispatcher.tsplus its sibling tests and a.changeset/, and the diff is exactly that:packages/runtime/src/http-dispatcher.tspackages/runtime/src/http-dispatcher.root-auth-gate.test.ts.changeset/17625-api-root-is-the-discovery-route.md@objectstack/runtimepatchTwo consequences of canonicalising before the gate are called out rather than left to be discovered, since both are stages the root now reaches under its route name:
/discoverywas always on. Pinned already byhttp-dispatcher.membership-skip-boundary.test.ts; no new exposure, because the document the root serves is the document/discoveryserves to the same caller.context.routePath. A root request records/discoveryrather than the empty string. This is asserted directly as the mechanism pin, so it is observed rather than incidental.this.domainRegistry.resolve(cleanPath, method)runs before the discovery branch, so a plugin registering{ prefix: '/discovery' }throughregisterDomainHandlernow captures the API root as well — it could not before, because the root arrived as the empty string. Sharper edge, stated rather than left to be derived:{ prefix: '/discovery', liveness: true }would put the root on the liveness carve-out, ahead of the auth gate. ⛔ No behaviour changes today — nothing in this repo registers that prefix (zero hits underpackages/runtime/src/domains/, against a firing control). It is this PR's own thesis applied consistently: the root is the discovery route, so it inherits what/discoveryinherits, including who may claim it — the same reasoning that makes consequences 1 and 2 correct. Declared here because it is the one with a security-adjacent edge.⛔ Read-only and untouched, as the claim required:
packages/core/src/security/auth-gate.tsand core'sALLOW_ROUTES.The three pins the card names
GET ${prefix}/on a gated session still returns the discovery payload —PIN 1.GET ${prefix}(no trailing slash) unchanged —PIN 2.packages/core's pin, delivered by [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898's own round inpackages/core/src/security/auth-gate.test.ts("[[Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898] a falsy path is not exempt (fail-closed)", driving the predicate over absent, null and empty). Referenced from the new test file's docblock, which also states why restating it againstHttpDispatcherwould measure nothing: this transport has no pathless call shape, sincedispatch()takespath: string.Every case runs on a fixture whose gate is provably on:
enforceAuthGatefails open in many ways, so a 200 on the root is worthless without a request the same fixture refuses. The positive control asserts 403 withPASSWORD_EXPIREDon/data/task, and ablation leg B independently shows it can fail.Evidence
Derived gate roster —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, every command run with$?captured before any pipe, then reconciled with--rancarrying the codes:Exit-code tally: 57 x exit 0, 2 x exit 3, 0 failures, 0 unrun.
⊘ The two at exit 3 are
PREREQUISITE NOT METand are NOT MEASURED — neither pass nor failure. Both refuse their own prerequisite because they read whole-tree built output, and this card's derived closure build covers only@objectstack/runtime's dependencies:pnpm check:dual-build-cjs-loads— "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. … ⛔ This is NOT a pass: nothing was measured."pnpm check:type-check-debt— "--re-measurecannot run: 2 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding."The first derivation (before the changeset existed) named 52; writing the changeset added the 7 changeset-derived families, and all 7 are inside the 59 above.
Full lint union — this lane's known blind spot in
dispatch-gates, added explicitly, run at final commit76d25207:Both changed source files appear in eslint's own
--format jsonoutput, each at 0/0:packages/runtime/src/http-dispatcher.tsandpackages/runtime/src/http-dispatcher.root-auth-gate.test.ts.Tests and typecheck — through
scripts/pm/os-verify-lock.sh, whoseVERDICT command-exitline is the reading:Targeted set: the new file plus
http-dispatcher.root,http-dispatcher.scoped-url-strip,http-dispatcher.liveness-carve-out,http-dispatcher.membership-skip-boundaryanddomains/auth-claim-segment-boundary— the neighbours that readcleanPathat the stages the canonicalisation now precedes. Dependency closure built first:pnpm --filter '@objectstack/runtime^...' build—VERDICT command-exit 0.Ablation — two legs against the committed fix, each mutated on disk, run, restored, with the restore proven by hash rather than by an exit code. The subject resolves from source (the test imports the sibling module, no
distin the path), so no rebuild leg applies; the on-disk proof is still recorded, because an editing tool exits 0 on zero matches.Control bytes —
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'over all three changed files: no match (exit 1).pnpm check:nul-bytes: exit 0.content/docsmeasurement — zero pages falsified, with the control that makes the zero a reading.Docs Drift Checknamed its own blind spot (a page stating a rule by its inputs shares no token with the emitter), so this was searched on the behaviour vocabulary rather than on any identifier the diff moved.Positive controls first, same corpus and same command form: the literal⚠️ A first control was discarded precisely because it returned zero — the phrase it used is hard-wrapped across two lines in both pages, so it could never have hit; recorded because a control that silently fails would have validated nothing.
(and `GET /api/v1/discovery`)hits 2 files, the regexGET /api/v1hits 44. A zero from the sibling searches is therefore a reading and not a broken command.Negative searches, all 0 files: the root paired with 401/403/404 · a request line
GET /api/v1/with a trailing slash · "bare root" ·routePath· discovery described as reachable at one path only · the root described as requiring auth.The two hand-written pages that do state the behaviour are confirmed by this change, not falsified —
content/docs/api/index.mdx:103andcontent/docs/protocol/kernel/http-protocol.mdx:26both head a sectionGET /api/v1(andGET /api/v1/discovery) and say the two paths are one handler, one document, not a redirect and not two shapes. On post-#7898mainthose sentences were false for a gated session, because the root answered 403; this PR makes them true again. Two further near-misses were read and cleared:content/docs/permissions/authorization.mdx:51lists the allow-listed control plane as/auth,/health,/discovery— still exactly true, sinceALLOW_ROUTESis unchanged and the root arrives as/discoveryrather than as a new entry; and the two "one trailing slash trimmed" statements are about the declarative-endpoint matcher under/apps/, a different seam this diff does not touch.⇒ No documentation change is owed, and nothing was added to the declared surface — bounded as stated under Acceptance notes: this is a reading about pages that state the bare-root or trailing-slash behaviour, ⛔ not a claim about every page that happens to contain the word discovery.
Re-verified at head
6c31e741(the only difference from76d25207is the changeset file —git diff --statis one file, 14 insertions, 1 deletion): the changeset gate reproduced red then green, the full 59-family roster re-run at 57 x exit 0 / 2 x exit 3 / 0 failures / 0 unrun, and the lint union againexit 0, 6634 files, 0 errors, 0 warnings with both changed files present at 0/0.dispatch-gatesstates in its own output; those are CI's runs, not this round's.ADR-0087 disposition
No ledger entry is owed and no marker is required. The changeset declares no breaking change, which is the only condition under which
check:adr-0087-registrationdemands a disposition marker — and the gate ran green. On the substance: no ADR-0087 shape surface moved. The diff touches onepackages/runtimetransport file, its sibling test and the changeset — no schema module, nothing underpackages/spec, no contracts entry, no object definition — soobjectstack migrate metahas nothing to reach, and no authorable key, accept set or stored shape changes.Nor is this an ADR-0087 conversion-layer entry. Nothing lenient is being accepted from a metadata producer; one transport's two spellings of its own route are being reconciled to the route's own name. That is a dialect removed, not tolerated — the direction Prime Directive #12 asks for.
Changeset level: minor on
@objectstack/runtime, which publishes.patch, on the reasoning that a 403 which should be a 200 is a fix rather than a feature.Check Changesetfailed that on head76d25207and was right to: the level is a mechanical floor, not an editorial reading of the act. An affirmative clause ② on a package whosepackages/**/src/**the diff moves takes at leastminor(maintainer ruling 2026-09-04, decision batch #35, on #15294, written out under WHICH LEVEL in theCheck Changesetstep). The commit type may raise a bump but never lower it below what the act requires, so the type staysfix(runtime)and only the level moved — the two are independent. ⛔ The declaration was not softened to fit the level, and neither the gate nor the workflow was touched.Acceptance notes
Docs drift — the seat's own measurement, with its controls and its bound
Docs Drift Checkreports "nothing to list — not a clean bill of health" and names its own blind spot: a page stating a rule by its inputs shares no token with the emitter, so an emitter-only diff can never list it. Measured by hand onorigin/mainrather than accepted:content/docs/api/declarative-endpoints.mdx:207andcontent/docs/protocol/kernel/http-protocol.mdx:1215routePath/api/v1/Both of the two say the same thing — "one trailing slash trimmed — the same rule the matcher applies — so
/xand/x/are the same claim" — and neither is falsified. The trimming still happens exactly as documented and${prefix}/${prefix}/still resolve identically; what this diff decides is what the empty result then MEANS, which neither page states. ⭐ The boundary note below corroborates it from the other side:${prefix}//strips to/, not to the empty string, so it is not the root — which is precisely "one trailing slash trimmed" still holding.A second, independent pass over
content/docs(recorded under Evidence) reached the same verdict from different probes and additionally read the two pages that state the root//discoveryequivalence —content/docs/api/index.mdx:103andcontent/docs/protocol/kernel/http-protocol.mdx:26— which this change confirms rather than falsifies. Two measurements, different probes, same answer; the bound above governs both.Noted, not filed — neither meets the filing bar, and both are recorded as boundary pins in the new test file rather than as prose alone, so the next reader of this file finds them as executable statements:
${prefix}/environments/ENV_IDis refused for a gated session. It matched no allow-listed route before [Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898 either — the gate runs ahead of the scoped-URL strip, so that request is judged on its own scoped spelling — so its answer moved in neither card. Deliberately untouched: widening it is precisely the direction the ruling steered around, and discovery stays reachable by its own name. Pinned as a boundary case.${prefix}//strips to/, not to the empty string, so it is not the root and is not canonicalised. Recorded so a later reader does not widen the rule into "any number of trailing slashes is the root". Pinned.Successor for both: this file and the ADR-0069 gate lane — the same surface this PR touches, which is why they are pinned inside its own sibling test rather than filed.
Noted, not filed (⛔ and deliberately not fixed here — it is outside the declared surface): in the
Check Changesetrun that read the carrier correctly, the heading "The two declarations disagree, inside one PR" sits above two readings that agree (carrier present, declaration affirmative). The heading is emitted ahead of the readings, so when the real finding is the level rather than a carrier/line split it misdescribes itself. Cosmetic only — the block underneath states the actual finding correctly, and it is what this PR acted on.One convention note, no action:
enforceAuthGatebuilds its 403 asthis.error(message, 403, { code }), so the gate code travels in the envelope'sdetails.coderather thanerror.code. The new test reads whichever of the two carries it, so it pins the decision and not the spelling.Generated by Claude Code, session
session_01TSf4DV7ziu4V5j73e46b7c— recorded in prose because a raw REST body edit appends its own footer block.Generated by Claude Code