Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/discovery-auth-unscoped-base.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions packages/rest/src/discovery-per-request-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
12 changes: 11 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
Expand Down
Loading