From 9dcb38cf2c828a060f9129c31a403d3145080edb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 18:53:41 +0000 Subject: [PATCH 1/3] test(rest): pin routes.auth on a SCOPED discovery document (#16538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No test had ever touched `routes.auth` on a scoped `/discovery` document. The three existing pins live in `packages/objectql/src/protocol-discovery.test.ts` and measure the PRODUCER, which never sees a base path — so the REST projection's scoped branch was unmeasured, and that is why the defect was green. This commit adds the failing case only; the repair follows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- .../discovery-per-request-protocol.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/rest/src/discovery-per-request-protocol.test.ts b/packages/rest/src/discovery-per-request-protocol.test.ts index 4cc3288e1f..c5abcbb7f9 100644 --- a/packages/rest/src/discovery-per-request-protocol.test.ts +++ b/packages/rest/src/discovery-per-request-protocol.test.ts @@ -330,3 +330,65 @@ describe('[#9292] unscoped /discovery follows the shared resolution chain', () = expect(substance(served)).toEqual(substance(await tenantA.getDiscovery())); }); }); + +// --------------------------------------------------------------------------- +// [#16538] `routes.auth` is a CONTROL-PLANE concern, so a SCOPED document must +// still advertise it on the unscoped base. +// +// The handler states that contract two lines above the computation ("Auth is a +// control-plane concern, so use the unscoped base") and then strips only the +// retired `/projects/:environmentId` spelling — while `isScoped`, the very +// condition guarding the branch, is true only for `/environments/:environmentId`. +// So the strip could never match on the branch it guarded: `replace` returned +// the string unchanged and the advertised auth route kept both the scope and a +// literal, unsubstituted `:environmentId`. +// +// These pins live HERE and not beside the three `routes.auth` pins in +// `packages/objectql/src/protocol-discovery.test.ts` because those measure the +// PRODUCER (`ObjectStackProtocolImplementation.getDiscovery()`), which never +// sees a base path; the defect is in this file's REST projection over it, and +// `@objectstack/rest` is not reachable from `packages/objectql` (it would be a +// dependency cycle — `@objectstack/rest` devDepends on `@objectstack/objectql`). +// That is why no test had ever touched `routes.auth` on a scoped document, and +// why the defect was green. +// --------------------------------------------------------------------------- + +const AUTH_TENANT_SHAPE = { ...tenantAShape, services: [...tenantAShape.services, 'auth'] }; + +describe('[#16538] scoped /discovery advertises routes.auth on the UNSCOPED base', () => { + it('strips the environment scope, leaving no :environmentId in routes.auth', async () => { + const { serve } = boot({ + environments: { 'tenant-a': realProtocol(AUTH_TENANT_SHAPE) }, + host: realProtocol(hostShape), + }); + + const served = await serve(SCOPED, { environmentId: 'tenant-a' }); + + // Control, first: this document really is the scoped one, and the routes + // that SHOULD carry the environment do carry the resolved id. Without it a + // green assertion below could just mean the unscoped branch was taken. + expect(served.routes.data).toBe('/api/v1/environments/tenant-a/data'); + expect(served.scoping).toMatchObject({ scoped: true, environmentId: 'tenant-a' }); + + // Subject: auth is advertised on the unscoped base, as the comment above + // the computation says it is. + expect(served.routes.auth).toBe('/api/v1/auth'); + // And separately: whatever base it lands on, it never advertises an + // unsubstituted route parameter. This is the half a client cannot use at + // all — `GET /api/v1/environments/:environmentId/auth` is not a URL. + expect(served.routes.auth).not.toContain(':environmentId'); + }); + + it('keeps the unscoped document\'s auth route unchanged', async () => { + // The other branch of the same computation, pinned so the repair cannot be + // paid for out of the unscoped answer. Green before the fix and after — + // it is a regression guard, not the reproduction. + const { serve } = boot({ + environments: { 'tenant-a': realProtocol(AUTH_TENANT_SHAPE) }, + host: realProtocol(AUTH_TENANT_SHAPE), + withKernelManager: false, + }); + + expect((await serve(UNSCOPED)).routes.auth).toBe('/api/v1/auth'); + }); +}); From 3e13dc14a41bf882acd431c5c6bd6289d9af0532 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 19:08:16 +0000 Subject: [PATCH 2/3] fix(rest): strip the environment scope from the advertised auth route (#16538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `registerDiscoveryEndpoints`' auth branch stripped only the retired `/projects/:environmentId`, while `isScoped` — the condition guarding that branch — is true only for `/environments/:environmentId`. The replace could therefore never match where it ran: it returned `basePath` unchanged, so a scoped `/discovery` advertised `routes.auth` as `/api/v1/environments/:environmentId/auth` — keeping both the scope the comment two lines above says to drop ("Auth is a control-plane concern, so use the unscoped base") and a literal, unsubstituted route parameter. The repair is the sibling MCP block's own regex, in the same handler and for the same reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- packages/rest/src/rest-server.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index e1658a0e24..e546203be8 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -4525,9 +4525,19 @@ export class RestServer { // Align auth route with the versioned base path if present. // Auth is a control-plane concern, so use the unscoped base. + // + // [#16538] The strip names BOTH spellings, exactly as the MCP + // sibling above does. It used to name only the retired + // `/projects/:environmentId`, while `isScoped` — the condition + // guarding this very branch — keys on `/environments/:environmentId` + // alone. So the replace could never match where it ran: it returned + // `basePath` unchanged and a scoped `/discovery` advertised + // `/api/v1/environments/:environmentId/auth`, keeping both the scope + // this comment says to drop and a literal, unsubstituted route + // parameter. Pinned in `discovery-per-request-protocol.test.ts`. if (discovery.routes.auth) { const unscopedBase = isScoped - ? basePath.replace(/\/projects\/:environmentId$/, '') + ? basePath.replace(/\/(environments|projects)\/:environmentId$/, '') : basePath; discovery.routes.auth = `${unscopedBase}/auth`; } From d5d014784f861eade5c9a4fb8d1344a430b7e93a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 19:10:06 +0000 Subject: [PATCH 3/3] chore(changeset): patch @objectstack/rest for the scoped auth route repair (#16538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured rather than assumed: `@objectstack/rest` is public (npm 17.3.0) and ships `files: ["dist", ...]`; the changed regex is present twice in both `dist/index.js` and `dist/index.cjs` — the sibling MCP strip as the positive control, and the auth strip this change moved — while the retired-only spelling has zero occurrences there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- .changeset/discovery-auth-unscoped-base.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/discovery-auth-unscoped-base.md diff --git a/.changeset/discovery-auth-unscoped-base.md b/.changeset/discovery-auth-unscoped-base.md new file mode 100644 index 0000000000..b22e51f2df --- /dev/null +++ b/.changeset/discovery-auth-unscoped-base.md @@ -0,0 +1,14 @@ +--- +'@objectstack/rest': patch +--- + +Fix `GET /discovery` advertising an unusable `routes.auth` on a scoped deployment. + +`registerDiscoveryEndpoints` strips the environment scope off the advertised auth +route, because auth is a control-plane concern. That strip named only the retired +`/projects/:environmentId` spelling, while `isScoped` — the condition guarding the +branch — matches only `/environments/:environmentId`, so the strip could never match +where it ran. A scoped `/discovery` therefore advertised `routes.auth` as +`/api/v1/environments/:environmentId/auth`: still scoped, and carrying a literal, +unsubstituted route parameter. It now advertises `/api/v1/auth`, the same shape the +sibling `routes.mcp` already used. Unscoped deployments are unaffected.