Skip to content

fix(rest): the direct-mount routes read the ONE API base, so apiPath moves all 92 (#6306) - #6822

Merged
os-project-manager merged 7 commits into
mainfrom
claude/issue-6306-direct-mount-follows-apipath
Aug 8, 2026
Merged

fix(rest): the direct-mount routes read the ONE API base, so apiPath moves all 92 (#6306)#6822
os-project-manager merged 7 commits into
mainfrom
claude/issue-6306-direct-mount-follows-apipath

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #6306

Implements the 2026-08-07 maintainer ruling (Option 1 — single source of truth), unblocked by comment 5226405484 now that #6633 / PR #6712 has landed.

The defect

RestServer.getApiBasePath() answers api.apiPath ?? `${basePath}/${version}` , and everything the RouteManager registers mounts under it. rest-api-plugin.ts built its own `${basePath}/${version}` for the two direct-mount registrars and never read apiPath. The two expressions agree only while apiPath is unset — so a deployment that set it served two API prefixes at once.

Re-measured on this branch after merging origin/main (real createRestApiPlugin(config).start(ctx) composition over a recording host server whose handler table is the mounted surface; apiPath: '/backend/api/v9'):

before after
routes mounted 92 92
under {apiPath} 83 92
left at /api/v1 9 0
{apiPath}/openapi.json 71 paths, no packages/external 79 paths, all nine
/discovery routes.packages /api/v1/packages {apiPath}/packages

The nine are packages.* ×4 and datasources/:name/external/* ×5 — exactly the ledger's source: 'direct-mount' rows. They were also absent from the served OpenAPI, because that document is filtered to this server's base; that filter (#5822 / PR #6303) is what made the split visible in the first place.

The fix

One line of behaviour: the registrars consume restServer.getApiBasePath()the same value, asked of the server that owns the surface, rather than a second copy of the ?? expression. Copying the expression is how the divergence happened, so the pin is on there being one. getApiBasePath() moves private → public and its doc comment now states that responsibility.

No advertising code was touched, by design. /discovery's routes.packages / routes.datasources are projections of the recorded direct-mount arrays via getDirectMountRouteBases() (#6633), so the advertisement followed the move by construction. Editing it would have meant the single-source property broke.

A second divergence from the same cause is also gone: the plugin defaulted with || (empty basePath/api) while RestServer normalizes with ?? (empty string kept), so basePath: '' split the surface 83/9 without any apiPath at all. A value that is read cannot disagree with itself.

Verification

Default config is byte-identical. The 92-row mount table, the 79-path OpenAPI document and the /discovery advertisement all diff clean before vs after — the two expressions are the same string when apiPath is unset, which is why this hid so long.

Reverse verification — predictions written to disk before the revert, then the deleted limb restored:

prediction measured
new file: 4 of 6 red 4 of 6 red
1 mounts-under-{apiPath} → RED RED — expected [...] to include 'POST /backend/api/v9/packages/publish'
2 openapi documents nine → RED RED — is mounted but not documented: expected false to be true
3 discovery advertises moved base → RED RED — expected '/api/v1/packages' to be '/backend/api/v9/packages'
4 empty-basePath (?? vs ||) → RED RED — expected [...] to include 'POST /v1/packages/publish'
5–6 default / /gateway/v3green both directions, honestly excluded green both directions — regression floor, not evidence
discovery-advertised-direct-mounts.parity.test.tsgreen both directions green both directions ✓
direct-mount-introspection.test.ts → green both directions green both directions ✓

The parity prediction contradicted the dispatch brief, which expected its non-default-base case to go red, and the prediction was right: that file calls mountAndRecordDirectRoutes directly with its own versionedBase, so it pins mounted ⇒ advertised for whatever base it is handed — orthogonal to who chooses the base. It stayed green across the move, which is the #6633 property working rather than the pin missing. The plugin-level wiring — config in, mounted + documented + advertised URLs out — is what the new file pins, and its comments record that division.

Gates (real runs, this tree):

  • pnpm lint — clean
  • pnpm check:type-check-debt — OK, none above ceiling; @objectstack/rest TEST_DEBT 152 vs 163 recorded (did not rise)
  • full check:* battery from lint.yml43/43 pass, plus check-changeset-no-major / check-empty-changeset / check-changeset-fixed / check:adr-0087-registration
  • @objectstack/rest — 70 files, 1103 tests pass; @objectstack/client — 21 files, 271 tests pass

One extra test file

rest-api-plugin-slot-lookups.test.ts mocked RestServer with a hand-written class carrying only a constructor and a no-op registerRoutes. Threading one more collaborator call through the composition root killed all five of its cases with TypeError: restServer.getApiBasePath is not a function — a file about slot wiring failing on a question it does not ask. The double now extends the real class and overrides only registerRoutes (the one expensive thing it existed to suppress), so the contract is inherited and the next collaborator call lands on the production method. The real constructor is field assignment plus new RouteManager(server), so nothing is paid for it.

Bump: minor

Not patch — beyond fixing the defect this changes an observable URL surface under a real config key, and it adds public API (getApiBasePath() is now part of the exported class). Not major — nothing authorable is removed or renamed, there is no metadata to migrate (ADR-0087 has nothing to register), default deployments are byte-identical, and affected clients follow by construction since PR #6712. The only FROM → TO lands on the operator's own proxy config, and those deployments are split-brain today: this makes apiPath honoured in full rather than withdrawing a promise that was ever kept.

apiPath remains reachable only by programmatic embedders composing createRestApiPlugindefineStack({server:{api:…}}) is rejected loudly (#4910), api:{apiPath} is silently stripped, os serve forwards only the two scoping keys, and no in-repo example sets it.

Out of scope, tracked separately: @objectstack/client's hard-coded bases (#6714).

🤖 Generated with Claude Code

https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx


Generated by Claude Code

claude added 7 commits August 8, 2026 14:12
…listing it (#6306)

`rest-api-plugin-slot-lookups.test.ts` replaced `RestServer` with a
hand-written class carrying exactly two members — the constructor it
captures args from, and a no-op `registerRoutes`. That is a contract
restated by hand, and it goes stale the moment the composition root calls
one more method on the instance: #6306 threads `getApiBasePath()` into the
direct-mount registrars, and all five cases here died with
`TypeError: restServer.getApiBasePath is not a function` — a file about
slot WIRING failing on a question it does not ask.

Extend the real class and override only `registerRoutes`, which is the
one expensive thing the double existed to suppress. The rest of the
contract is inherited, so the next collaborator call lands on the
production method. Nothing is paid for it: the real constructor is field
assignment plus `new RouteManager(server)` (a `Map`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx
…on the merged tree (#6306)

The numbers in the body are re-measured on this branch after merging
`origin/main` (the earlier ones were taken before ~77 commits landed and
are void): with `apiPath: '/backend/api/v9'`, 92 routes mount, 83 under
`{apiPath}` and exactly 9 at `/api/v1`; `{apiPath}/openapi.json` carries
71 paths before and 79 after; the default mount table, its OpenAPI
document and its `/discovery` advertisement diff clean before vs after.

Bump judged `minor`. Not `patch`: beyond fixing the defect this changes an
observable URL surface under a real config key, and it adds public API —
`RestServer.getApiBasePath()` goes `private` → public, which is the thing
that carries the single source of truth. Not `major`: nothing authorable is
removed or renamed, there is no metadata an author must migrate (so
ADR-0087 has nothing to register), default deployments are byte-identical,
and affected deployments' clients follow the moved base by construction
since PR #6712. The only FROM → TO lands on the operator's own proxy
config, and those deployments are split-brain today — this makes `apiPath`
honoured in full rather than withdrawing a promise that was ever kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 11:10pm

Request Review

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest.

11 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/releases/implementation-status.mdx (via @objectstack/rest)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

配置了 api.apiPath 时,rest 的 9 条 direct-mount 路由挂在 {basePath}/{version} 而不是 {apiPath}

2 participants