From ac8fb762f6edbee1a492762d979e8bf022e8a424 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 08:59:44 +0000 Subject: [PATCH 1/2] test(runtime): measure what an unscoped /packages reaches under projectResolution 'required' The escalation condition on this card asks whether the unconditionally mounted unscoped `/packages*` crosses an environment boundary on a `required` host. It does not, and this is the measurement. The unscoped mount supplies neither of `urlEnvironmentId`'s two sources, so the request names no environment of its own and is bound by the host resolver's documented order 2-6; the scoped URL is order 1, the stronger addressing primitive. Both mounts are the same handler behind the same `dispatch()` preamble, so the tenancy gate answers them identically: a non-member is refused `PROJECT_MEMBERSHIP_REQUIRED` before the domain on either, and a member reaches one byte-identical door. The fixture gives the two environments data planes that answer differently (registry-bearing vs registry-less), so the response itself names which environment served it -- and the positive control drives the same door, the same assertions and the same spies for a request the host DOES bind to another environment, so the no-leak legs are readings rather than a probe that could not have seen a crossing. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude Co-Authored-By: Claude Opus 5 --- ...kages-unscoped-environment-binding.test.ts | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 packages/runtime/src/packages-unscoped-environment-binding.test.ts diff --git a/packages/runtime/src/packages-unscoped-environment-binding.test.ts b/packages/runtime/src/packages-unscoped-environment-binding.test.ts new file mode 100644 index 0000000000..3cfebaf30a --- /dev/null +++ b/packages/runtime/src/packages-unscoped-environment-binding.test.ts @@ -0,0 +1,305 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #17432 — WHAT an unscoped `/packages*` reaches under + * `projectResolution: 'required'`. + * + * ## Why this measurement exists, ahead of any repair + * + * The card is a doc/code disagreement: `content/docs/api/environment-routing.mdx` + * says `required` registers ONLY environment-scoped routes for + * data/meta/AI/automation/package handlers, while `dispatcher-plugin.ts` mounts + * `/packages*` unscoped unconditionally — deliberately, and recorded at the + * mount site. Which side is wrong cannot be decided from the prose, because the + * sentence asserts an ISOLATION property: if the unscoped door lets a caller + * reach ANOTHER environment's package data, the finding is a tenancy leak and + * not drift, and the remedy is not a doc edit. So the property is measured + * here, and the file stays as the pin on the answer. + * + * ## The mechanism, stated so the assertions can be read against it + * + * The dispatcher owns no environment resolution (ADR-0006 Phase 5). It + * contributes two parsing HINTS — `routePath` and `urlEnvironmentId` — and the + * host's `KernelResolver` resolves the environment and returns the kernel the + * request is served from. `urlEnvironmentId` has exactly two sources + * (`prepareResolverHints`): an `/environments/:id` segment in the path, and + * `req.params.environmentId`. The unscoped mount supplies NEITHER, so an + * unscoped `/packages` request reaches the resolver naming no environment of + * its own and is bound by the host's documented order 2-6 (hostname / + * `X-Environment-Id` / session / configured default / sole environment). The + * scoped URL is order 1 — the STRONGER addressing primitive, not the weaker + * one. + * + * Both mounts call `dispatcher.dispatch()` with the same pre-stripped subpath + * (`/packages…`), the scoped one carrying `:environmentId` on `req.params`, so + * the two calls below are the two real mounts rather than lookalikes. + * + * ## The observable, and why it is a RESPONSE rather than only a spy + * + * "Reached environment E's package data" is mechanically "the door read E's + * `objectql`": `handlePackagesRequest` resolves its registry through + * `deps.getObjectQL(_context)`, which reads the REQUEST's kernel and nothing + * else, BEFORE the capability gate. So the fixture gives the two environments + * data planes that answer differently — one registry-bearing, one not — and the + * door's own status then names which environment it was bound to: `503` + * ("Package service not available", the registry-less host default) versus the + * `403 PERMISSION_DENIED` capability refusal that only a registry-bearing + * environment can produce. Spies on `objectql` resolution and on + * `sys_environment_member` corroborate it. + * + * ⚠️ Read the 403s for their CODE, never as "some refusal": three different + * gates answer 403 on these paths and only one of them is about tenancy. + * `PROJECT_MEMBERSHIP_REQUIRED` is the tenancy gate; `PERMISSION_DENIED` here + * is the ADR-0106 D4 capability gate INSIDE the domain, i.e. proof of arrival. + * + * ## The positive control + * + * ⛔ "No leak" may not be asserted by a probe that could not have seen one. The + * POSITIVE CONTROL drives the same door, the same assertion and the same + * observables for a request the host's resolver does bind to `env_beta`: the + * probe then reports `env_beta`'s data plane, through the identical channel the + * no-leak legs read as the host default. The membership gate is armed the same + * way (`sys_environment_member` answers with no row for a non-member), so a + * path that REACHES it comes back `PROJECT_MEMBERSHIP_REQUIRED` and a path that + * skips it does not — the two classes separated by the answer itself, which is + * the `http-dispatcher.membership-skip-boundary` fixture's argument reused. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher, type HttpProtocolContext, type KernelResolver } from './http-dispatcher.js'; + +const USER_ID = 'user-tenant-alpha'; +const TENANT_ORG = 'org-tenant'; +const ENV_ALPHA = 'env_alpha'; +const ENV_BETA = 'env_beta'; + +interface FakeKernel { + label: string; + kernel: any; + /** How often this kernel was asked for `objectql` — the binding record. */ + objectqlAsked: () => number; + /** Every `sys_environment_member` probe this kernel served. */ + memberQueries: () => any[]; + /** Every `registry.getAllPackages()` read — package rows actually served. */ + packageRowsRead: () => number; +} + +/** + * One environment's kernel: its own `objectql` and its own `auth`. + * + * - `memberOf` — the environments whose `sys_environment_member` row this + * kernel finds, so "not a member" is a fixture decision rather than an + * accident of an unwired service (the gate fails open in many ways). + * - `withRegistry` — whether its `objectql` carries a `registry`. + * `getObjectQLService` returns null without one, so the packages door + * answers 503 instead of reaching its capability gate. That difference is + * what makes "which environment answered" readable off the response. + */ +function makeKernel(label: string, opts: { memberOf?: string[]; withRegistry?: boolean } = {}): FakeKernel { + const memberOf = new Set(opts.memberOf ?? []); + const memberQueries: any[] = []; + let objectqlAsked = 0; + let packageRowsRead = 0; + + const registry = { + getAllPackages: vi.fn(() => { + packageRowsRead++; + return [{ manifest: { id: `pkg.of.${label}` }, status: 'installed', enabled: true }]; + }), + getPackage: vi.fn(() => undefined), + getObject: vi.fn(() => null), + getRegisteredTypes: vi.fn(() => []), + }; + + const objectql: Record = { + find: vi.fn(async (object: string, q: any) => { + if (object === 'sys_environment_member') { + memberQueries.push(q?.where); + return memberOf.has(q?.where?.environment_id) ? [{ id: 'row' }] : []; + } + return []; + }), + getObjects: vi.fn(() => ({})), + }; + if (opts.withRegistry !== false) objectql.registry = registry; + + const auth = { + getApi: async () => ({ + getSession: async () => ({ + user: { id: USER_ID }, + session: { userId: USER_ID, activeOrganizationId: TENANT_ORG }, + }), + }), + }; + + const services: Record = { objectql, auth }; + + const resolve = (name: string, scopeId?: string) => { + // A non-shared-kernel host: nothing here is a SCOPED service, so a + // scoped lookup declines and the dispatcher's `resolveService` chain + // falls through to the request's own kernel. + if (scopeId) return null; + if (name === 'objectql') objectqlAsked++; + return services[name] ?? null; + }; + + const kernel: any = { + getState: () => 'running', + getService: (name: string, scopeId?: string) => resolve(name, scopeId), + getServiceAsync: async (name: string, scopeId?: string) => resolve(name, scopeId), + context: { getService: (name: string) => resolve(name) }, + }; + + return { + label, + kernel, + objectqlAsked: () => objectqlAsked, + memberQueries: () => memberQueries, + packageRowsRead: () => packageRowsRead, + }; +} + +/** + * A multi-environment host: two tenant environments with registry-bearing data + * planes and a registry-LESS default kernel, plus a resolver implementing the + * documented order restricted to the two steps the open-source dispatcher can + * influence — the scoped URL (order 1) and `X-Environment-Id` (order 3). Steps + * 2/4/5/6 are host strategy and are modelled by their outcome: "no environment + * resolved", which routes to the default kernel. + */ +function makeHost(opts: { memberOf?: string[] } = {}) { + const host = makeKernel('host-default', { withRegistry: false }); + const alpha = makeKernel(ENV_ALPHA, { memberOf: opts.memberOf }); + const beta = makeKernel(ENV_BETA, { memberOf: opts.memberOf }); + const byId: Record = { [ENV_ALPHA]: alpha, [ENV_BETA]: beta }; + const seen: Array<{ routePath?: string; urlEnvironmentId?: string; header?: string; resolved?: string }> = []; + + const resolver: KernelResolver = { + resolveKernel: (context: HttpProtocolContext, defaultKernel: any) => { + const headers: any = context.request?.headers; + const header: string | undefined = typeof headers?.get === 'function' + ? (headers.get('x-environment-id') ?? undefined) + : headers?.['x-environment-id']; + const resolved = context.urlEnvironmentId ?? header; + seen.push({ + routePath: context.routePath, + urlEnvironmentId: context.urlEnvironmentId, + header, + resolved, + }); + if (!resolved) return undefined; // unscoped / single-environment + context.environmentId = resolved; + return byId[resolved]?.kernel ?? defaultKernel; + }, + }; + + const dispatcher = new HttpDispatcher(host.kernel, undefined, { + enforceProjectMembership: true, + kernelResolver: resolver, + }); + + /** The UNSCOPED mount — `registerPackageRoutes(prefix)`, no env anywhere. */ + const unscoped = (headers: Record = {}) => dispatcher.dispatch( + 'GET', '/packages', undefined, {}, + { request: { headers, params: {} } } as any, + ); + + /** The SCOPED mount — same handler, `:environmentId` on `req.params`. */ + const scoped = (environmentId: string, headers: Record = {}) => dispatcher.dispatch( + 'GET', '/packages', undefined, {}, + { request: { headers, params: { environmentId } } } as any, + ); + + return { host, alpha, beta, seen, unscoped, scoped }; +} + +/** The tenancy gate's refusal — the ONE 403 on these paths that is about isolation. */ +const refusedForTenancy = (r: any) => + r?.response?.status === 403 && r?.response?.body?.error?.code === 'PROJECT_MEMBERSHIP_REQUIRED'; + +/** Arrival INSIDE the packages domain, on a registry-bearing environment. */ +const reachedPackagesDoor = (r: any) => + r?.response?.status === 403 + && r?.response?.body?.error?.code === 'PERMISSION_DENIED' + && /Reading packages requires/.test(String(r?.response?.body?.error?.message ?? '')); + +/** Arrival on the registry-LESS host default. */ +const servedByHostDefault = (r: any) => + r?.response?.status === 503 + && /Package service not available/.test(String(r?.response?.body?.error?.message ?? '')); + +describe('#17432 — the unscoped /packages door names no environment of its own', () => { + it('the resolver sees NO environment hint from the unscoped mount, and one from the scoped mount', async () => { + const h = makeHost({ memberOf: [ENV_BETA] }); + + await h.unscoped(); + expect(h.seen.at(-1)).toMatchObject({ + routePath: '/packages', urlEnvironmentId: undefined, resolved: undefined, + }); + + // CONTROL for that `undefined`: the same field is populated the moment + // the caller does name an environment, so it is a reading rather than + // an unwired hint. + await h.scoped(ENV_BETA); + expect(h.seen.at(-1)).toMatchObject({ urlEnvironmentId: ENV_BETA, resolved: ENV_BETA }); + }); + + it('an unscoped request with no environment context is served by the HOST default, not by a tenant environment', async () => { + const h = makeHost({ memberOf: [ENV_ALPHA, ENV_BETA] }); + + const r = await h.unscoped(); + + expect(servedByHostDefault(r)).toBe(true); + expect(h.host.objectqlAsked()).toBeGreaterThan(0); + expect(h.alpha.objectqlAsked()).toBe(0); + expect(h.beta.objectqlAsked()).toBe(0); + expect(h.alpha.packageRowsRead()).toBe(0); + expect(h.beta.packageRowsRead()).toBe(0); + }); + + it('POSITIVE CONTROL: the same probe DOES report a tenant environment when the host binds the request to one', async () => { + // Same door, same assertions, same spies — only the host's resolver + // differs in what it resolves (header, documented order 3). Were the + // leg above vacuous, this one could not separate the two. + const h = makeHost({ memberOf: [ENV_ALPHA, ENV_BETA] }); + + const r = await h.unscoped({ 'x-environment-id': ENV_BETA }); + + expect(reachedPackagesDoor(r)).toBe(true); + expect(servedByHostDefault(r)).toBe(false); + expect(h.beta.objectqlAsked()).toBeGreaterThan(0); + expect(h.seen.at(-1)).toMatchObject({ urlEnvironmentId: undefined, resolved: ENV_BETA }); + }); +}); + +describe('#17432 — the unscoped door runs the SAME isolation gates as the scoped one', () => { + it('a non-member is refused for TENANCY on both mounts, before the domain, with no package row served', async () => { + const h = makeHost({ memberOf: [ENV_ALPHA] }); // NOT a member of env_beta + + const viaHeader = await h.unscoped({ 'x-environment-id': ENV_BETA }); + const viaUrl = await h.scoped(ENV_BETA); + + expect(refusedForTenancy(viaHeader)).toBe(true); + expect(refusedForTenancy(viaUrl)).toBe(true); + // Refused BEFORE the door: no arrival, no rows. + expect(reachedPackagesDoor(viaHeader)).toBe(false); + expect(reachedPackagesDoor(viaUrl)).toBe(false); + expect(h.beta.packageRowsRead()).toBe(0); + // …and the control plane really was asked, once per request. + expect(h.beta.memberQueries()).toEqual([ + { environment_id: ENV_BETA, user_id: USER_ID }, + { environment_id: ENV_BETA, user_id: USER_ID }, + ]); + }); + + it('a member reaches the SAME door through either mount — the two answers are identical', async () => { + const h = makeHost({ memberOf: [ENV_ALPHA, ENV_BETA] }); + + const viaHeader = await h.unscoped({ 'x-environment-id': ENV_BETA }); + const viaUrl = await h.scoped(ENV_BETA); + + expect(reachedPackagesDoor(viaHeader)).toBe(true); + expect(viaUrl.response.status).toBe(viaHeader.response.status); + expect(viaUrl.response.body).toEqual(viaHeader.response.body); + }); +}); From 45b2eee86c09f331abb06ff50d307168c447a46a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 09:06:19 +0000 Subject: [PATCH 2/2] docs(api): stop claiming `required` scopes package routes, and pin the ruled mount shape `environment-routing.mdx` said `projectResolution: 'required'` registers only environment-scoped routes for data/meta/AI/automation/package handlers. Three of those four families are true of the dispatcher, and the REST `POST /packages/publish` registrar honours it too, but the dispatcher's `/packages*` bridge is mounted unscoped unconditionally and is ruled to stay that way -- the mount site records why, and a doc that over-claims is what set the operator's mental model. So the doc now states what the code does, with the isolation half stated beside it: an unscoped request names no environment, so it is resolved by the same order 2-6 and passes the same membership and capability gates as a scoped URL, and what `required` does not deliver for package routes is the URL guarantee rather than isolation. The mount pin turns the comment's "stays that way" into something a future tidy-up trips over. Reverse-verified: moving the unscoped call into the `required` branch reds exactly the three unscoped package cases (3 failed / 11 passed) while the scoped cases, the dropped-sibling controls and the binding measurement stay green; restore reproduces the HEAD blob byte-for-byte. Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c Co-authored-by: Claude Co-Authored-By: Claude Opus 5 --- content/docs/api/environment-routing.mdx | 21 ++- ...equired-scoping-mounts.integration.test.ts | 153 ++++++++++++++++++ ...kages-unscoped-environment-binding.test.ts | 7 +- 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 packages/runtime/src/dispatcher-plugin.required-scoping-mounts.integration.test.ts diff --git a/content/docs/api/environment-routing.mdx b/content/docs/api/environment-routing.mdx index 42e922d9fd..57a195fff5 100644 --- a/content/docs/api/environment-routing.mdx +++ b/content/docs/api/environment-routing.mdx @@ -89,7 +89,26 @@ surface: |:---|:---|:---| | `auto` | Registers both unscoped `/api/v1/...` and scoped `/api/v1/environments/:environmentId/...` routes. | Default migration mode. | | `optional` | Same route surface as `auto`; resolution may come from URL, hostname, header, session, or default. | Multi-environment hosts that still accept unscoped callers. | -| `required` | Registers only environment-scoped routes for data/meta/AI/automation/package handlers. | Hardened clients that always pass an environment id. | +| `required` | Registers only environment-scoped routes for data/meta/AI/automation handlers, and for the REST `POST /packages/publish` route. The dispatcher's own `/packages*` bridge is the exception — it stays mounted unscoped too. | Hardened clients that always pass an environment id. | + + +**`required` does not close the unscoped `/packages*` surface.** The dispatcher +plugin mounts the package routes at the unscoped prefix unconditionally and adds +the scoped mount beside it, so `GET`/`POST`/`PATCH`/`DELETE` +`/api/v1/packages...` — the destructive lifecycle verbs included — stay served +on a `required` host. The asymmetry with the automation / action / AI families +is deliberate and recorded at the mount site +(`packages/runtime/src/dispatcher-plugin.ts`): removing a mounted route surface +is a different change, with a different blast radius, from adding a missing +door. + +It is not an isolation hole. An unscoped request names no environment, so it is +resolved by steps 2–6 of the order below and is served by whichever environment +that resolves to, behind the same project-membership and capability gates as the +scoped URL — the scoped URL is step 1, the *stronger* way to address an +environment, not a weaker one. What `required` does not give you for package +routes is the guarantee that every caller has named an environment in the URL. + --- diff --git a/packages/runtime/src/dispatcher-plugin.required-scoping-mounts.integration.test.ts b/packages/runtime/src/dispatcher-plugin.required-scoping-mounts.integration.test.ts new file mode 100644 index 0000000000..e6cb7da70a --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.required-scoping-mounts.integration.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #17432 — the route surface `projectResolution: 'required'` actually serves. + * + * ## What is pinned, and why a pin rather than a repair + * + * Under `required` the dispatcher plugin drops the UNSCOPED mounts of + * `registerAutomationRoutes` / `registerActionRoutes` / `registerAIRoutes`, and + * keeps `/packages*` mounted unscoped as well as scoped. That asymmetry is + * RULED, not an oversight: the mount site records it in full + * (`dispatcher-plugin.ts`, beside the scoped package mount), because taking a + * mounted route surface away is a different change with a different blast + * radius than adding a missing door — #16781's residue under ruling C' on + * #14503 step 2. + * + * A recorded decision that lives only in a comment is one a future "tidy-up" + * has to NOTICE. This file is that decision expressed as a test: moving + * `registerPackageRoutes(prefix)` into the `required` branch turns the first + * case below red, and the reader is sent to the comment rather than to a + * paragraph nobody read. The isolation half — that the unscoped door reaches no + * other environment's package data, which is what makes keeping it safe — is + * measured in `packages-unscoped-environment-binding.test.ts`. + * + * ## The composition, and the discriminator + * + * `plugin-hono-server` + the dispatcher, scoping on, `required`. No + * `createHonoApp`, no `@objectstack/rest`, no service plugins — so nothing may + * supply a second door and a mount is the only way in, exactly as in + * `dispatcher-plugin.scoped-packages-door.integration.test.ts`, whose + * discriminator this file reuses: a credential-less request that REACHES the + * dispatcher is answered from the anonymous-deny floor (`ANONYMOUS_DENY_STATUS` + * / `ANONYMOUS_DENY_CODE`, imported rather than spelled), a verdict no + * transport-level sink emits, while a path no mount claims is answered by the + * transport's own `notFound`. Both directions are measured below rather than + * assumed, and the dropped sibling mounts are the second direction: they are + * the reason "404 means unmounted" is a reading here. + * + * ⚠️ The `/ai` family is deliberately NOT among the dropped-mount cases. Its + * dynamic routes arrive from `AIServicePlugin` through the `ai:routes` hook, + * which this composition never fires, so an `/ai` 404 here would be silent + * about `projectResolution` — it would be about the absent service plugin. + * `registerAIRoutes`' own mounts sit on the same branch as automation and + * actions; those two carry the case. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_STATUS, LiteKernel } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const PREFIX = '/api/v1'; +const ENV_ID = 'env_alpha'; +const PKG_ID = 'com.acme.crm'; + +let kernel: LiteKernel | undefined; +let baseUrl = ''; + +beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(createDispatcherPlugin({ + prefix: PREFIX, + scoping: { enableProjectScoping: true, projectResolution: 'required' }, + enforceProjectMembership: false, + securityHeaders: false, + })); + await kernel.bootstrap(); + const httpServer = kernel.getService('http.server'); + baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; +}, 60_000); + +afterAll(async () => { + if (!kernel) return; + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); +}, 60_000); + +async function probe(method: string, path: string): Promise<{ status: number; body: any }> { + const res = await fetch(`${baseUrl}${path}`, { method }); + let body: any; + try { body = await res.json(); } catch { body = undefined; } + return { status: res.status, body }; +} + +/** Did the DISPATCHER answer? Minted inside `dispatch()`, by nothing in the transport. */ +function dispatcherAnswered(r: { status: number; body: any }): boolean { + return r.status === ANONYMOUS_DENY_STATUS && r.body?.error?.code === ANONYMOUS_DENY_CODE; +} + +/** The shape "no door answered" takes on this transport. */ +function transportRefused(r: { status: number; body: any }): boolean { + const code = r.body?.error?.code; + return r.status === 404 && (code === undefined || code === 'ROUTE_NOT_FOUND' || code === 'ENDPOINT_NOT_FOUND'); +} + +const UNSCOPED = `${PREFIX}/packages`; +const SCOPED = `${PREFIX}/environments/${ENV_ID}/packages`; + +/** The package routes the card names, destructive verb included. */ +const PACKAGE_ROUTES: Array<[string, string]> = [ + ['GET', ''], + ['GET', `/${PKG_ID}`], + ['DELETE', `/${PKG_ID}`], +]; + +describe("#17432 — under `required`, /packages* stays mounted unscoped (ruled, not an oversight)", () => { + for (const [method, sub] of PACKAGE_ROUTES) { + it(`${method} ${UNSCOPED}${sub} is still served`, async () => { + const r = await probe(method, `${UNSCOPED}${sub}`); + expect( + dispatcherAnswered(r), + `${method} ${UNSCOPED}${sub} -> ${r.status} ${JSON.stringify(r.body)} — ` + + 'the unscoped package mount is ruled to stay under `required`; see the mount-site ' + + 'comment in dispatcher-plugin.ts before changing this', + ).toBe(true); + }, 60_000); + } + + for (const [method, sub] of PACKAGE_ROUTES) { + it(`${method} ${SCOPED}${sub} is served too — the scoped door is additive`, async () => { + const r = await probe(method, `${SCOPED}${sub}`); + expect( + dispatcherAnswered(r), + `${method} ${SCOPED}${sub} -> ${r.status} ${JSON.stringify(r.body)}`, + ).toBe(true); + }, 60_000); + } +}); + +describe('#17432 — the siblings DO drop their unscoped mounts, which is what makes 404 a reading', () => { + const DROPPED: Array<[string, string, string]> = [ + ['GET', `${PREFIX}/automation/flows`, 'automation'], + ['POST', `${PREFIX}/actions/task/ping`, 'actions'], + ]; + + for (const [method, path, family] of DROPPED) { + it(`${method} ${path} (${family}, unscoped) is NOT mounted under \`required\``, async () => { + const r = await probe(method, path); + expect(dispatcherAnswered(r)).toBe(false); + expect(transportRefused(r), `${method} ${path} -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true); + }, 60_000); + } + + it('…while the same families ARE mounted scoped', async () => { + const r = await probe('GET', `${PREFIX}/environments/${ENV_ID}/automation/flows`); + expect(dispatcherAnswered(r), `scoped automation -> ${r.status} ${JSON.stringify(r.body)}`).toBe(true); + }, 60_000); +}); diff --git a/packages/runtime/src/packages-unscoped-environment-binding.test.ts b/packages/runtime/src/packages-unscoped-environment-binding.test.ts index 3cfebaf30a..8e75b6f2db 100644 --- a/packages/runtime/src/packages-unscoped-environment-binding.test.ts +++ b/packages/runtime/src/packages-unscoped-environment-binding.test.ts @@ -298,8 +298,11 @@ describe('#17432 — the unscoped door runs the SAME isolation gates as the scop const viaHeader = await h.unscoped({ 'x-environment-id': ENV_BETA }); const viaUrl = await h.scoped(ENV_BETA); + // Each side is pinned to the door's own answer FIRST, so the equality + // below cannot pass by both sides being absent. expect(reachedPackagesDoor(viaHeader)).toBe(true); - expect(viaUrl.response.status).toBe(viaHeader.response.status); - expect(viaUrl.response.body).toEqual(viaHeader.response.body); + expect(reachedPackagesDoor(viaUrl)).toBe(true); + expect(viaUrl.response?.status).toBe(viaHeader.response?.status); + expect(viaUrl.response?.body).toEqual(viaHeader.response?.body); }); });