Skip to content
75 changes: 75 additions & 0 deletions .changeset/ui-view-environment-ownership.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
---
"@objectstack/rest": minor
---

fix(rest): require the resolved environment to belong to the caller at `GET /api/v1/ui/view/:object/:type` (#13214)

**Security floor.** This route was the one identity-touching route in
`RestServer`'s table that resolved no identity at all: it went from
`resolveProtocol` straight to `getUiView`, answering **200** to an anonymous
caller, byte-identical to an entitled one, with `resolveExecCtx` called **zero**
times — while the other 52 identity-touching routes answered 401 under an absent
context.

Because the unscoped mount lets the REQUEST name its environment (bound
hostname, else the `X-Environment-Id` header), that made it a cross-environment
disclosure rather than a single-tenant one. Driven on the real route table with
a real `envRegistry` + `kernelManager`: an anonymous request naming another
environment received **that environment's** UI view — object label plus every
field's `name` / `label` / `type` / `required` / `readonly` — through **both**
naming channels, with the foreign kernel acquired. The route was additionally an
object-existence oracle for whatever environment was named, and an
**environment-id** oracle: an unresolvable `X-Environment-Id` was not refused but
silently fell through to the default environment and answered 200 with *that*
environment's view, so two 200s with different bytes distinguished a real
environment id from an invented one.

Maintainer ruling 2026-08-30 (option C). Adding anonymous-deny alone was
explicitly measured **not** to be the repair — it stops the anonymous caller and
nothing else, because an authenticated caller could still name a foreign
environment and nothing downstream compared the environment that was *resolved*
with the environment the caller is *entitled to*.

What the seam does now, in order: resolve the environment once through the
shared entry point; resolve identity **in that environment**; refuse anonymity;
then compare. The comparison reads `__authEnvironmentId` — an internal key
`computeExecCtx` now stamps on every context it produces, naming the environment
whose auth service actually validated the caller. It differs from the resolved
environment in exactly the branch that crosses: when the resolved environment's
kernel carries no `auth` service, the lookup falls back to the **default**
environment's, and a session minted there authenticated a request naming another
one.

Both refusable shapes answer with the anonymous-deny envelope **verbatim**
(401 `UNAUTHENTICATED`), and that is deliberate rather than tidiness: a caller
naming a real foreign environment is already refused by the anonymous gate
(their credential is not valid there), so giving "you do not own this
environment" or "that environment id does not resolve" any *other* status would
rebuild the id oracle one layer up. One shape, byte for byte, for every way a
caller can fail to be entitled to the environment it named. The cost is
diagnosability: an operator whose environment genuinely lacks an `auth` service
sees the anonymous 401 rather than a wiring error.

**Migration.** The published route changes from "anonymous read" to
"authenticated **and** ownership-checked", so a caller that relied on the old
behaviour breaks:

- An **anonymous** consumer of `/ui/view/...` (for example a login screen
rendering a view before authentication) now receives 401. There is no opt-out;
the route is not on `isAuthGateAllowlisted` and was never a declared
control-plane exemption.
- A caller sending `X-Environment-Id` **while on a hostname bound to a different
environment** now receives 401 instead of being served the hostname's
environment. Drop the contradictory header; the bound hostname still decides.
- A caller sending an `X-Environment-Id` the registry cannot resolve now
receives 401 instead of the default environment's view.
- A deployment where an environment's kernel carries no `auth` service of its
own now refuses requests naming that environment, because the credential
would have been validated in the default environment instead. Wire the
environment's `auth` service.

Scoped (`/environments/:environmentId/ui/view/...`) and unscoped mounts are both
gated; naming an environment in the URL is no more of an entitlement than naming
it in a header. What the producer is *told* is unchanged — `getUiView` still
receives `{ object, type }` on the unscoped mount and the route-supplied
`environmentId` on the scoped one.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4284`, `:5647`, `:5895`, `:6258`, `:6451` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
18 changes: 17 additions & 1 deletion packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,7 +231,23 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
reachable: 19,
blindSpot: 61,
populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints',
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 61 },
// [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control
// exists to prove this census is still reading the file it thinks it is, and
// a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214
// was supposed to cause — `registerUiEndpoints` was the ONE route in this
// file that resolved no identity, and it is now guarded. The move is +3 over
// the whole file (`occurrences` counts the bare term, comments included):
// one new call site — `if (this.enforceAuth(req, res, context)) return;`,
// 52 -> 53 — plus two prose mentions in the new doc-comments. ⛔ Kept as an
// EXACT count rather than a range or a floor: a range would stop this row
// noticing the next move, which is the only thing it is for.
//
// ⚠️ The three sibling numbers were re-derived and did NOT move, which is
// what says this is a guard change and not a surface change: `population`
// 80, `reachable` 19, `private register*Endpoints(` 17 and
// `this.routeManager.register(` 80 are all unchanged — #13214 added no route
// and no registrar. `blindSpot` therefore stays 61 as well.
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 },
note:
'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' +
'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' +
Expand Down
51 changes: 39 additions & 12 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,15 @@ const ENTITLED = {
isSystem: false,
tenantId: 'org_census',
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
// [#13214] The internal key `computeExecCtx` stamps on every context it
// produces, naming the environment whose auth service actually validated
// the caller. `enforceEnvironmentOwnership` — the new guard on the UI-view
// site this census now counts — compares it against the environment the
// request resolved to, which under `makeServer` is `env_census`.
// `instrument()` replaces `resolveExecCtx` wholesale, so a synthetic
// context has to model the key or it is a caller anchored NOWHERE, which
// that seam refuses. Every other site in this census ignores it.
__authEnvironmentId: 'env_census',
};
const OBJECT_DOC = { name: 'acct', type: 'object', fields: {}, groups: [] };

Expand DownExpand Up@@ -300,54 +309,72 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('72 invocation sites, 89 mentions — the thread\'s two control numbers hold', () => {
expect(SITES.length).toBe(72);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(89);
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
// 2026-08-30 ruling closed it. It joins as a BARE site behind the
// shared floor, which is the family the next two cases describe.
//
// ⚠️ The two numbers moved by DIFFERENT amounts (+1 and +3) and that is
// the point of counting both: one is the call site, the other two are
// prose mentions in the new doc-comments (the registrar's, recording
// that this route used to call `resolveExecCtx` zero times, and the
// ownership guard's, recording that adding `resolveExecCtx` +
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
});

it('the split is 20 locally caught / 52 bare — NOT 16 / 52, which does not add to 72', () => {
it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
//
// [#13214] The new site is BARE, and that is a decision the next case
// enforces: a locally-caught site sitting behind the shared floor would
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(BARE.length).toBe(52);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 52 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
// next statement; the locally-caught ones carry a `.catch` because
// they are NOT behind that floor and each must decide for itself.
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(52);
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(53);
expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0);
});
});

// ---------------------------------------------------------------------------
// 3. The 52 bare sites, driven
// 3. The 53 bare sites, driven
// ---------------------------------------------------------------------------

describe('[#13160] §3 the 52 bare sites — driven, every one of them', () => {
it('all 52 are reached by the mounted route table, so none is classified by inference', async () => {
describe('[#13160] §3 the 53 bare sites — driven, every one of them', () => {
it('all 53 are reached by the mounted route table, so none is classified by inference', async () => {
const reached = sitesOf(await sweep(undefined, 'FULL'));
const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l));
// ⛔ A bare site that stopped being reachable must show up as a
// shrinking census, never as a row silently inherited from a neighbour.
expect(unreached).toEqual([]);
}, 120_000);

it('an absent context is the ANONYMOUS SUBJECT at all 52: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
it('an absent context is the ANONYMOUS SUBJECT at all 53: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
const fault = await sweep(undefined, 'FULL');
const control = await sweep(ENTITLED, 'FULL');
const bareLines = new Set(BARE.map((s) => s.line));
const controlByRoute = new Map(control.map((r) => [r.route, r]));

const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l)));
expect(rows.length).toBeGreaterThanOrEqual(52);
expect(rows.length).toBeGreaterThanOrEqual(53);

for (const row of rows) {
expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS);
Expand Down
16 changes: 16 additions & 0 deletions packages/rest/src/rest-exec-ctx-principal-kind.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -293,6 +293,22 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output
// it, so it is an assembled field now and absent for the same reason
// every other unset field is: this session carries no gate.
'__kernel',
// [#13214] The SECOND post-assembly internal key, added by the
// 2026-08-30 security ruling and named here rather than left to a
// subset check — this pin exists precisely to make a key ARRIVING
// as loud as a key going missing, and this one arrived.
//
// It carries the environment whose auth service actually validated
// the caller, which is the left-hand side of the ownership
// comparison `enforceEnvironmentOwnership` makes at
// `GET /ui/view/:object/:type`. ⚠️ Unlike `__kernel` it IS an
// authorization input, at exactly one reader inside `rest-server.ts`
// — ⛔ nothing downstream of this transport may branch on it, and
// it is deliberately NOT an `ExecutionContext` field because it
// describes how the context was OBTAINED, not what the principal
// may do. The assembled field set is unchanged; this sits beside it,
// in the same `as any` the class doc-comment already covers.
'__authEnvironmentId',
]));
});

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(rest): require the resolved environment to belong to the caller at GET /ui/view/:object/:type by claude[bot] · Pull Request #13625 · objectstack-ai/objectstack · GitHub
Skip to content
75 changes: 75 additions & 0 deletions .changeset/ui-view-environment-ownership.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
---
"@objectstack/rest": minor
---

fix(rest): require the resolved environment to belong to the caller at `GET /api/v1/ui/view/:object/:type` (#13214)

**Security floor.** This route was the one identity-touching route in
`RestServer`'s table that resolved no identity at all: it went from
`resolveProtocol` straight to `getUiView`, answering **200** to an anonymous
caller, byte-identical to an entitled one, with `resolveExecCtx` called **zero**
times — while the other 52 identity-touching routes answered 401 under an absent
context.

Because the unscoped mount lets the REQUEST name its environment (bound
hostname, else the `X-Environment-Id` header), that made it a cross-environment
disclosure rather than a single-tenant one. Driven on the real route table with
a real `envRegistry` + `kernelManager`: an anonymous request naming another
environment received **that environment's** UI view — object label plus every
field's `name` / `label` / `type` / `required` / `readonly` — through **both**
naming channels, with the foreign kernel acquired. The route was additionally an
object-existence oracle for whatever environment was named, and an
**environment-id** oracle: an unresolvable `X-Environment-Id` was not refused but
silently fell through to the default environment and answered 200 with *that*
environment's view, so two 200s with different bytes distinguished a real
environment id from an invented one.

Maintainer ruling 2026-08-30 (option C). Adding anonymous-deny alone was
explicitly measured **not** to be the repair — it stops the anonymous caller and
nothing else, because an authenticated caller could still name a foreign
environment and nothing downstream compared the environment that was *resolved*
with the environment the caller is *entitled to*.

What the seam does now, in order: resolve the environment once through the
shared entry point; resolve identity **in that environment**; refuse anonymity;
then compare. The comparison reads `__authEnvironmentId` — an internal key
`computeExecCtx` now stamps on every context it produces, naming the environment
whose auth service actually validated the caller. It differs from the resolved
environment in exactly the branch that crosses: when the resolved environment's
kernel carries no `auth` service, the lookup falls back to the **default**
environment's, and a session minted there authenticated a request naming another
one.

Both refusable shapes answer with the anonymous-deny envelope **verbatim**
(401 `UNAUTHENTICATED`), and that is deliberate rather than tidiness: a caller
naming a real foreign environment is already refused by the anonymous gate
(their credential is not valid there), so giving "you do not own this
environment" or "that environment id does not resolve" any *other* status would
rebuild the id oracle one layer up. One shape, byte for byte, for every way a
caller can fail to be entitled to the environment it named. The cost is
diagnosability: an operator whose environment genuinely lacks an `auth` service
sees the anonymous 401 rather than a wiring error.

**Migration.** The published route changes from "anonymous read" to
"authenticated **and** ownership-checked", so a caller that relied on the old
behaviour breaks:

- An **anonymous** consumer of `/ui/view/...` (for example a login screen
rendering a view before authentication) now receives 401. There is no opt-out;
the route is not on `isAuthGateAllowlisted` and was never a declared
control-plane exemption.
- A caller sending `X-Environment-Id` **while on a hostname bound to a different
environment** now receives 401 instead of being served the hostname's
environment. Drop the contradictory header; the bound hostname still decides.
- A caller sending an `X-Environment-Id` the registry cannot resolve now
receives 401 instead of the default environment's view.
- A deployment where an environment's kernel carries no `auth` service of its
own now refuses requests naming that environment, because the credential
would have been validated in the default environment instead. Wire the
environment's `auth` service.

Scoped (`/environments/:environmentId/ui/view/...`) and unscoped mounts are both
gated; naming an environment in the URL is no more of an entitlement than naming
it in a header. What the producer is *told* is unchanged — `getUiView` still
receives `{ object, type }` on the unscoped mount and the route-supplied
`environmentId` on the scoped one.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4284`, `:5647`, `:5895`, `:6258`, `:6451` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
18 changes: 17 additions & 1 deletion packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,7 +231,23 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
reachable: 19,
blindSpot: 61,
populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints',
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 61 },
// [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control
// exists to prove this census is still reading the file it thinks it is, and
// a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214
// was supposed to cause — `registerUiEndpoints` was the ONE route in this
// file that resolved no identity, and it is now guarded. The move is +3 over
// the whole file (`occurrences` counts the bare term, comments included):
// one new call site — `if (this.enforceAuth(req, res, context)) return;`,
// 52 -> 53 — plus two prose mentions in the new doc-comments. ⛔ Kept as an
// EXACT count rather than a range or a floor: a range would stop this row
// noticing the next move, which is the only thing it is for.
//
// ⚠️ The three sibling numbers were re-derived and did NOT move, which is
// what says this is a guard change and not a surface change: `population`
// 80, `reachable` 19, `private register*Endpoints(` 17 and
// `this.routeManager.register(` 80 are all unchanged — #13214 added no route
// and no registrar. `blindSpot` therefore stays 61 as well.
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 },
note:
'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' +
'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' +
Expand Down
51 changes: 39 additions & 12 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,15 @@ const ENTITLED = {
isSystem: false,
tenantId: 'org_census',
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
// [#13214] The internal key `computeExecCtx` stamps on every context it
// produces, naming the environment whose auth service actually validated
// the caller. `enforceEnvironmentOwnership` — the new guard on the UI-view
// site this census now counts — compares it against the environment the
// request resolved to, which under `makeServer` is `env_census`.
// `instrument()` replaces `resolveExecCtx` wholesale, so a synthetic
// context has to model the key or it is a caller anchored NOWHERE, which
// that seam refuses. Every other site in this census ignores it.
__authEnvironmentId: 'env_census',
};
const OBJECT_DOC = { name: 'acct', type: 'object', fields: {}, groups: [] };

Expand DownExpand Up@@ -300,54 +309,72 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('72 invocation sites, 89 mentions — the thread\'s two control numbers hold', () => {
expect(SITES.length).toBe(72);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(89);
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
// 2026-08-30 ruling closed it. It joins as a BARE site behind the
// shared floor, which is the family the next two cases describe.
//
// ⚠️ The two numbers moved by DIFFERENT amounts (+1 and +3) and that is
// the point of counting both: one is the call site, the other two are
// prose mentions in the new doc-comments (the registrar's, recording
// that this route used to call `resolveExecCtx` zero times, and the
// ownership guard's, recording that adding `resolveExecCtx` +
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
});

it('the split is 20 locally caught / 52 bare — NOT 16 / 52, which does not add to 72', () => {
it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
//
// [#13214] The new site is BARE, and that is a decision the next case
// enforces: a locally-caught site sitting behind the shared floor would
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(BARE.length).toBe(52);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 52 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
// next statement; the locally-caught ones carry a `.catch` because
// they are NOT behind that floor and each must decide for itself.
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(52);
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(53);
expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0);
});
});

// ---------------------------------------------------------------------------
// 3. The 52 bare sites, driven
// 3. The 53 bare sites, driven
// ---------------------------------------------------------------------------

describe('[#13160] §3 the 52 bare sites — driven, every one of them', () => {
it('all 52 are reached by the mounted route table, so none is classified by inference', async () => {
describe('[#13160] §3 the 53 bare sites — driven, every one of them', () => {
it('all 53 are reached by the mounted route table, so none is classified by inference', async () => {
const reached = sitesOf(await sweep(undefined, 'FULL'));
const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l));
// ⛔ A bare site that stopped being reachable must show up as a
// shrinking census, never as a row silently inherited from a neighbour.
expect(unreached).toEqual([]);
}, 120_000);

it('an absent context is the ANONYMOUS SUBJECT at all 52: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
it('an absent context is the ANONYMOUS SUBJECT at all 53: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
const fault = await sweep(undefined, 'FULL');
const control = await sweep(ENTITLED, 'FULL');
const bareLines = new Set(BARE.map((s) => s.line));
const controlByRoute = new Map(control.map((r) => [r.route, r]));

const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l)));
expect(rows.length).toBeGreaterThanOrEqual(52);
expect(rows.length).toBeGreaterThanOrEqual(53);

for (const row of rows) {
expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS);
Expand Down
16 changes: 16 additions & 0 deletions packages/rest/src/rest-exec-ctx-principal-kind.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -293,6 +293,22 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output
// it, so it is an assembled field now and absent for the same reason
// every other unset field is: this session carries no gate.
'__kernel',
// [#13214] The SECOND post-assembly internal key, added by the
// 2026-08-30 security ruling and named here rather than left to a
// subset check — this pin exists precisely to make a key ARRIVING
// as loud as a key going missing, and this one arrived.
//
// It carries the environment whose auth service actually validated
// the caller, which is the left-hand side of the ownership
// comparison `enforceEnvironmentOwnership` makes at
// `GET /ui/view/:object/:type`. ⚠️ Unlike `__kernel` it IS an
// authorization input, at exactly one reader inside `rest-server.ts`
// — ⛔ nothing downstream of this transport may branch on it, and
// it is deliberately NOT an `ExecutionContext` field because it
// describes how the context was OBTAINED, not what the principal
// may do. The assembled field set is unchanged; this sits beside it,
// in the same `as any` the class doc-comment already covers.
'__authEnvironmentId',
]));
});

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(rest): require the resolved environment to belong to the caller at GET /ui/view/:object/:type by claude[bot] · Pull Request #13625 · objectstack-ai/objectstack · GitHub
Skip to content
75 changes: 75 additions & 0 deletions .changeset/ui-view-environment-ownership.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
---
"@objectstack/rest": minor
---

fix(rest): require the resolved environment to belong to the caller at `GET /api/v1/ui/view/:object/:type` (#13214)

**Security floor.** This route was the one identity-touching route in
`RestServer`'s table that resolved no identity at all: it went from
`resolveProtocol` straight to `getUiView`, answering **200** to an anonymous
caller, byte-identical to an entitled one, with `resolveExecCtx` called **zero**
times — while the other 52 identity-touching routes answered 401 under an absent
context.

Because the unscoped mount lets the REQUEST name its environment (bound
hostname, else the `X-Environment-Id` header), that made it a cross-environment
disclosure rather than a single-tenant one. Driven on the real route table with
a real `envRegistry` + `kernelManager`: an anonymous request naming another
environment received **that environment's** UI view — object label plus every
field's `name` / `label` / `type` / `required` / `readonly` — through **both**
naming channels, with the foreign kernel acquired. The route was additionally an
object-existence oracle for whatever environment was named, and an
**environment-id** oracle: an unresolvable `X-Environment-Id` was not refused but
silently fell through to the default environment and answered 200 with *that*
environment's view, so two 200s with different bytes distinguished a real
environment id from an invented one.

Maintainer ruling 2026-08-30 (option C). Adding anonymous-deny alone was
explicitly measured **not** to be the repair — it stops the anonymous caller and
nothing else, because an authenticated caller could still name a foreign
environment and nothing downstream compared the environment that was *resolved*
with the environment the caller is *entitled to*.

What the seam does now, in order: resolve the environment once through the
shared entry point; resolve identity **in that environment**; refuse anonymity;
then compare. The comparison reads `__authEnvironmentId` — an internal key
`computeExecCtx` now stamps on every context it produces, naming the environment
whose auth service actually validated the caller. It differs from the resolved
environment in exactly the branch that crosses: when the resolved environment's
kernel carries no `auth` service, the lookup falls back to the **default**
environment's, and a session minted there authenticated a request naming another
one.

Both refusable shapes answer with the anonymous-deny envelope **verbatim**
(401 `UNAUTHENTICATED`), and that is deliberate rather than tidiness: a caller
naming a real foreign environment is already refused by the anonymous gate
(their credential is not valid there), so giving "you do not own this
environment" or "that environment id does not resolve" any *other* status would
rebuild the id oracle one layer up. One shape, byte for byte, for every way a
caller can fail to be entitled to the environment it named. The cost is
diagnosability: an operator whose environment genuinely lacks an `auth` service
sees the anonymous 401 rather than a wiring error.

**Migration.** The published route changes from "anonymous read" to
"authenticated **and** ownership-checked", so a caller that relied on the old
behaviour breaks:

- An **anonymous** consumer of `/ui/view/...` (for example a login screen
rendering a view before authentication) now receives 401. There is no opt-out;
the route is not on `isAuthGateAllowlisted` and was never a declared
control-plane exemption.
- A caller sending `X-Environment-Id` **while on a hostname bound to a different
environment** now receives 401 instead of being served the hostname's
environment. Drop the contradictory header; the bound hostname still decides.
- A caller sending an `X-Environment-Id` the registry cannot resolve now
receives 401 instead of the default environment's view.
- A deployment where an environment's kernel carries no `auth` service of its
own now refuses requests naming that environment, because the credential
would have been validated in the default environment instead. Wire the
environment's `auth` service.

Scoped (`/environments/:environmentId/ui/view/...`) and unscoped mounts are both
gated; naming an environment in the URL is no more of an entitlement than naming
it in a header. What the producer is *told* is unchanged — `getUiView` still
receives `{ object, type }` on the unscoped mount and the route-supplied
`environmentId` on the scoped one.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4284`, `:5647`, `:5895`, `:6258`, `:6451` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
18 changes: 17 additions & 1 deletion packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,7 +231,23 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
reachable: 19,
blindSpot: 61,
populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints',
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 61 },
// [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control
// exists to prove this census is still reading the file it thinks it is, and
// a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214
// was supposed to cause — `registerUiEndpoints` was the ONE route in this
// file that resolved no identity, and it is now guarded. The move is +3 over
// the whole file (`occurrences` counts the bare term, comments included):
// one new call site — `if (this.enforceAuth(req, res, context)) return;`,
// 52 -> 53 — plus two prose mentions in the new doc-comments. ⛔ Kept as an
// EXACT count rather than a range or a floor: a range would stop this row
// noticing the next move, which is the only thing it is for.
//
// ⚠️ The three sibling numbers were re-derived and did NOT move, which is
// what says this is a guard change and not a surface change: `population`
// 80, `reachable` 19, `private register*Endpoints(` 17 and
// `this.routeManager.register(` 80 are all unchanged — #13214 added no route
// and no registrar. `blindSpot` therefore stays 61 as well.
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 },
note:
'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' +
'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' +
Expand Down
51 changes: 39 additions & 12 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,15 @@ const ENTITLED = {
isSystem: false,
tenantId: 'org_census',
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
// [#13214] The internal key `computeExecCtx` stamps on every context it
// produces, naming the environment whose auth service actually validated
// the caller. `enforceEnvironmentOwnership` — the new guard on the UI-view
// site this census now counts — compares it against the environment the
// request resolved to, which under `makeServer` is `env_census`.
// `instrument()` replaces `resolveExecCtx` wholesale, so a synthetic
// context has to model the key or it is a caller anchored NOWHERE, which
// that seam refuses. Every other site in this census ignores it.
__authEnvironmentId: 'env_census',
};
const OBJECT_DOC = { name: 'acct', type: 'object', fields: {}, groups: [] };

Expand DownExpand Up@@ -300,54 +309,72 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('72 invocation sites, 89 mentions — the thread\'s two control numbers hold', () => {
expect(SITES.length).toBe(72);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(89);
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
// 2026-08-30 ruling closed it. It joins as a BARE site behind the
// shared floor, which is the family the next two cases describe.
//
// ⚠️ The two numbers moved by DIFFERENT amounts (+1 and +3) and that is
// the point of counting both: one is the call site, the other two are
// prose mentions in the new doc-comments (the registrar's, recording
// that this route used to call `resolveExecCtx` zero times, and the
// ownership guard's, recording that adding `resolveExecCtx` +
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
});

it('the split is 20 locally caught / 52 bare — NOT 16 / 52, which does not add to 72', () => {
it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
//
// [#13214] The new site is BARE, and that is a decision the next case
// enforces: a locally-caught site sitting behind the shared floor would
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(BARE.length).toBe(52);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 52 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
// next statement; the locally-caught ones carry a `.catch` because
// they are NOT behind that floor and each must decide for itself.
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(52);
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(53);
expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0);
});
});

// ---------------------------------------------------------------------------
// 3. The 52 bare sites, driven
// 3. The 53 bare sites, driven
// ---------------------------------------------------------------------------

describe('[#13160] §3 the 52 bare sites — driven, every one of them', () => {
it('all 52 are reached by the mounted route table, so none is classified by inference', async () => {
describe('[#13160] §3 the 53 bare sites — driven, every one of them', () => {
it('all 53 are reached by the mounted route table, so none is classified by inference', async () => {
const reached = sitesOf(await sweep(undefined, 'FULL'));
const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l));
// ⛔ A bare site that stopped being reachable must show up as a
// shrinking census, never as a row silently inherited from a neighbour.
expect(unreached).toEqual([]);
}, 120_000);

it('an absent context is the ANONYMOUS SUBJECT at all 52: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
it('an absent context is the ANONYMOUS SUBJECT at all 53: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
const fault = await sweep(undefined, 'FULL');
const control = await sweep(ENTITLED, 'FULL');
const bareLines = new Set(BARE.map((s) => s.line));
const controlByRoute = new Map(control.map((r) => [r.route, r]));

const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l)));
expect(rows.length).toBeGreaterThanOrEqual(52);
expect(rows.length).toBeGreaterThanOrEqual(53);

for (const row of rows) {
expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS);
Expand Down
16 changes: 16 additions & 0 deletions packages/rest/src/rest-exec-ctx-principal-kind.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -293,6 +293,22 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output
// it, so it is an assembled field now and absent for the same reason
// every other unset field is: this session carries no gate.
'__kernel',
// [#13214] The SECOND post-assembly internal key, added by the
// 2026-08-30 security ruling and named here rather than left to a
// subset check — this pin exists precisely to make a key ARRIVING
// as loud as a key going missing, and this one arrived.
//
// It carries the environment whose auth service actually validated
// the caller, which is the left-hand side of the ownership
// comparison `enforceEnvironmentOwnership` makes at
// `GET /ui/view/:object/:type`. ⚠️ Unlike `__kernel` it IS an
// authorization input, at exactly one reader inside `rest-server.ts`
// — ⛔ nothing downstream of this transport may branch on it, and
// it is deliberately NOT an `ExecutionContext` field because it
// describes how the context was OBTAINED, not what the principal
// may do. The assembled field set is unchanged; this sits beside it,
// in the same `as any` the class doc-comment already covers.
'__authEnvironmentId',
]));
});

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(rest): require the resolved environment to belong to the caller at GET /ui/view/:object/:type by claude[bot] · Pull Request #13625 · objectstack-ai/objectstack · GitHub
Skip to content
75 changes: 75 additions & 0 deletions .changeset/ui-view-environment-ownership.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
---
"@objectstack/rest": minor
---

fix(rest): require the resolved environment to belong to the caller at `GET /api/v1/ui/view/:object/:type` (#13214)

**Security floor.** This route was the one identity-touching route in
`RestServer`'s table that resolved no identity at all: it went from
`resolveProtocol` straight to `getUiView`, answering **200** to an anonymous
caller, byte-identical to an entitled one, with `resolveExecCtx` called **zero**
times — while the other 52 identity-touching routes answered 401 under an absent
context.

Because the unscoped mount lets the REQUEST name its environment (bound
hostname, else the `X-Environment-Id` header), that made it a cross-environment
disclosure rather than a single-tenant one. Driven on the real route table with
a real `envRegistry` + `kernelManager`: an anonymous request naming another
environment received **that environment's** UI view — object label plus every
field's `name` / `label` / `type` / `required` / `readonly` — through **both**
naming channels, with the foreign kernel acquired. The route was additionally an
object-existence oracle for whatever environment was named, and an
**environment-id** oracle: an unresolvable `X-Environment-Id` was not refused but
silently fell through to the default environment and answered 200 with *that*
environment's view, so two 200s with different bytes distinguished a real
environment id from an invented one.

Maintainer ruling 2026-08-30 (option C). Adding anonymous-deny alone was
explicitly measured **not** to be the repair — it stops the anonymous caller and
nothing else, because an authenticated caller could still name a foreign
environment and nothing downstream compared the environment that was *resolved*
with the environment the caller is *entitled to*.

What the seam does now, in order: resolve the environment once through the
shared entry point; resolve identity **in that environment**; refuse anonymity;
then compare. The comparison reads `__authEnvironmentId` — an internal key
`computeExecCtx` now stamps on every context it produces, naming the environment
whose auth service actually validated the caller. It differs from the resolved
environment in exactly the branch that crosses: when the resolved environment's
kernel carries no `auth` service, the lookup falls back to the **default**
environment's, and a session minted there authenticated a request naming another
one.

Both refusable shapes answer with the anonymous-deny envelope **verbatim**
(401 `UNAUTHENTICATED`), and that is deliberate rather than tidiness: a caller
naming a real foreign environment is already refused by the anonymous gate
(their credential is not valid there), so giving "you do not own this
environment" or "that environment id does not resolve" any *other* status would
rebuild the id oracle one layer up. One shape, byte for byte, for every way a
caller can fail to be entitled to the environment it named. The cost is
diagnosability: an operator whose environment genuinely lacks an `auth` service
sees the anonymous 401 rather than a wiring error.

**Migration.** The published route changes from "anonymous read" to
"authenticated **and** ownership-checked", so a caller that relied on the old
behaviour breaks:

- An **anonymous** consumer of `/ui/view/...` (for example a login screen
rendering a view before authentication) now receives 401. There is no opt-out;
the route is not on `isAuthGateAllowlisted` and was never a declared
control-plane exemption.
- A caller sending `X-Environment-Id` **while on a hostname bound to a different
environment** now receives 401 instead of being served the hostname's
environment. Drop the contradictory header; the bound hostname still decides.
- A caller sending an `X-Environment-Id` the registry cannot resolve now
receives 401 instead of the default environment's view.
- A deployment where an environment's kernel carries no `auth` service of its
own now refuses requests naming that environment, because the credential
would have been validated in the default environment instead. Wire the
environment's `auth` service.

Scoped (`/environments/:environmentId/ui/view/...`) and unscoped mounts are both
gated; naming an environment in the URL is no more of an entitlement than naming
it in a header. What the producer is *told* is unchanged — `getUiView` still
receives `{ object, type }` on the unscoped mount and the route-supplied
`environmentId` on the scoped one.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4284`, `:5647`, `:5895`, `:6258`, `:6451` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
18 changes: 17 additions & 1 deletion packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,7 +231,23 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
reachable: 19,
blindSpot: 61,
populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints',
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 61 },
// [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control
// exists to prove this census is still reading the file it thinks it is, and
// a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214
// was supposed to cause — `registerUiEndpoints` was the ONE route in this
// file that resolved no identity, and it is now guarded. The move is +3 over
// the whole file (`occurrences` counts the bare term, comments included):
// one new call site — `if (this.enforceAuth(req, res, context)) return;`,
// 52 -> 53 — plus two prose mentions in the new doc-comments. ⛔ Kept as an
// EXACT count rather than a range or a floor: a range would stop this row
// noticing the next move, which is the only thing it is for.
//
// ⚠️ The three sibling numbers were re-derived and did NOT move, which is
// what says this is a guard change and not a surface change: `population`
// 80, `reachable` 19, `private register*Endpoints(` 17 and
// `this.routeManager.register(` 80 are all unchanged — #13214 added no route
// and no registrar. `blindSpot` therefore stays 61 as well.
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 },
note:
'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' +
'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' +
Expand Down
51 changes: 39 additions & 12 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,15 @@ const ENTITLED = {
isSystem: false,
tenantId: 'org_census',
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
// [#13214] The internal key `computeExecCtx` stamps on every context it
// produces, naming the environment whose auth service actually validated
// the caller. `enforceEnvironmentOwnership` — the new guard on the UI-view
// site this census now counts — compares it against the environment the
// request resolved to, which under `makeServer` is `env_census`.
// `instrument()` replaces `resolveExecCtx` wholesale, so a synthetic
// context has to model the key or it is a caller anchored NOWHERE, which
// that seam refuses. Every other site in this census ignores it.
__authEnvironmentId: 'env_census',
};
const OBJECT_DOC = { name: 'acct', type: 'object', fields: {}, groups: [] };

Expand DownExpand Up@@ -300,54 +309,72 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('72 invocation sites, 89 mentions — the thread\'s two control numbers hold', () => {
expect(SITES.length).toBe(72);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(89);
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
// 2026-08-30 ruling closed it. It joins as a BARE site behind the
// shared floor, which is the family the next two cases describe.
//
// ⚠️ The two numbers moved by DIFFERENT amounts (+1 and +3) and that is
// the point of counting both: one is the call site, the other two are
// prose mentions in the new doc-comments (the registrar's, recording
// that this route used to call `resolveExecCtx` zero times, and the
// ownership guard's, recording that adding `resolveExecCtx` +
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
});

it('the split is 20 locally caught / 52 bare — NOT 16 / 52, which does not add to 72', () => {
it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
//
// [#13214] The new site is BARE, and that is a decision the next case
// enforces: a locally-caught site sitting behind the shared floor would
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(BARE.length).toBe(52);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 52 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
// next statement; the locally-caught ones carry a `.catch` because
// they are NOT behind that floor and each must decide for itself.
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(52);
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(53);
expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0);
});
});

// ---------------------------------------------------------------------------
// 3. The 52 bare sites, driven
// 3. The 53 bare sites, driven
// ---------------------------------------------------------------------------

describe('[#13160] §3 the 52 bare sites — driven, every one of them', () => {
it('all 52 are reached by the mounted route table, so none is classified by inference', async () => {
describe('[#13160] §3 the 53 bare sites — driven, every one of them', () => {
it('all 53 are reached by the mounted route table, so none is classified by inference', async () => {
const reached = sitesOf(await sweep(undefined, 'FULL'));
const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l));
// ⛔ A bare site that stopped being reachable must show up as a
// shrinking census, never as a row silently inherited from a neighbour.
expect(unreached).toEqual([]);
}, 120_000);

it('an absent context is the ANONYMOUS SUBJECT at all 52: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
it('an absent context is the ANONYMOUS SUBJECT at all 53: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
const fault = await sweep(undefined, 'FULL');
const control = await sweep(ENTITLED, 'FULL');
const bareLines = new Set(BARE.map((s) => s.line));
const controlByRoute = new Map(control.map((r) => [r.route, r]));

const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l)));
expect(rows.length).toBeGreaterThanOrEqual(52);
expect(rows.length).toBeGreaterThanOrEqual(53);

for (const row of rows) {
expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS);
Expand Down
16 changes: 16 additions & 0 deletions packages/rest/src/rest-exec-ctx-principal-kind.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -293,6 +293,22 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output
// it, so it is an assembled field now and absent for the same reason
// every other unset field is: this session carries no gate.
'__kernel',
// [#13214] The SECOND post-assembly internal key, added by the
// 2026-08-30 security ruling and named here rather than left to a
// subset check — this pin exists precisely to make a key ARRIVING
// as loud as a key going missing, and this one arrived.
//
// It carries the environment whose auth service actually validated
// the caller, which is the left-hand side of the ownership
// comparison `enforceEnvironmentOwnership` makes at
// `GET /ui/view/:object/:type`. ⚠️ Unlike `__kernel` it IS an
// authorization input, at exactly one reader inside `rest-server.ts`
// — ⛔ nothing downstream of this transport may branch on it, and
// it is deliberately NOT an `ExecutionContext` field because it
// describes how the context was OBTAINED, not what the principal
// may do. The assembled field set is unchanged; this sits beside it,
// in the same `as any` the class doc-comment already covers.
'__authEnvironmentId',
]));
});

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(rest): require the resolved environment to belong to the caller at GET /ui/view/:object/:type by claude[bot] · Pull Request #13625 · objectstack-ai/objectstack · GitHub
Skip to content
75 changes: 75 additions & 0 deletions .changeset/ui-view-environment-ownership.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
---
"@objectstack/rest": minor
---

fix(rest): require the resolved environment to belong to the caller at `GET /api/v1/ui/view/:object/:type` (#13214)

**Security floor.** This route was the one identity-touching route in
`RestServer`'s table that resolved no identity at all: it went from
`resolveProtocol` straight to `getUiView`, answering **200** to an anonymous
caller, byte-identical to an entitled one, with `resolveExecCtx` called **zero**
times — while the other 52 identity-touching routes answered 401 under an absent
context.

Because the unscoped mount lets the REQUEST name its environment (bound
hostname, else the `X-Environment-Id` header), that made it a cross-environment
disclosure rather than a single-tenant one. Driven on the real route table with
a real `envRegistry` + `kernelManager`: an anonymous request naming another
environment received **that environment's** UI view — object label plus every
field's `name` / `label` / `type` / `required` / `readonly` — through **both**
naming channels, with the foreign kernel acquired. The route was additionally an
object-existence oracle for whatever environment was named, and an
**environment-id** oracle: an unresolvable `X-Environment-Id` was not refused but
silently fell through to the default environment and answered 200 with *that*
environment's view, so two 200s with different bytes distinguished a real
environment id from an invented one.

Maintainer ruling 2026-08-30 (option C). Adding anonymous-deny alone was
explicitly measured **not** to be the repair — it stops the anonymous caller and
nothing else, because an authenticated caller could still name a foreign
environment and nothing downstream compared the environment that was *resolved*
with the environment the caller is *entitled to*.

What the seam does now, in order: resolve the environment once through the
shared entry point; resolve identity **in that environment**; refuse anonymity;
then compare. The comparison reads `__authEnvironmentId` — an internal key
`computeExecCtx` now stamps on every context it produces, naming the environment
whose auth service actually validated the caller. It differs from the resolved
environment in exactly the branch that crosses: when the resolved environment's
kernel carries no `auth` service, the lookup falls back to the **default**
environment's, and a session minted there authenticated a request naming another
one.

Both refusable shapes answer with the anonymous-deny envelope **verbatim**
(401 `UNAUTHENTICATED`), and that is deliberate rather than tidiness: a caller
naming a real foreign environment is already refused by the anonymous gate
(their credential is not valid there), so giving "you do not own this
environment" or "that environment id does not resolve" any *other* status would
rebuild the id oracle one layer up. One shape, byte for byte, for every way a
caller can fail to be entitled to the environment it named. The cost is
diagnosability: an operator whose environment genuinely lacks an `auth` service
sees the anonymous 401 rather than a wiring error.

**Migration.** The published route changes from "anonymous read" to
"authenticated **and** ownership-checked", so a caller that relied on the old
behaviour breaks:

- An **anonymous** consumer of `/ui/view/...` (for example a login screen
rendering a view before authentication) now receives 401. There is no opt-out;
the route is not on `isAuthGateAllowlisted` and was never a declared
control-plane exemption.
- A caller sending `X-Environment-Id` **while on a hostname bound to a different
environment** now receives 401 instead of being served the hostname's
environment. Drop the contradictory header; the bound hostname still decides.
- A caller sending an `X-Environment-Id` the registry cannot resolve now
receives 401 instead of the default environment's view.
- A deployment where an environment's kernel carries no `auth` service of its
own now refuses requests naming that environment, because the credential
would have been validated in the default environment instead. Wire the
environment's `auth` service.

Scoped (`/environments/:environmentId/ui/view/...`) and unscoped mounts are both
gated; naming an environment in the URL is no more of an entitlement than naming
it in a header. What the producer is *told* is unchanged — `getUiView` still
receives `{ object, type }` on the unscoped mount and the route-supplied
`environmentId` on the scoped one.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4284`, `:5647`, `:5895`, `:6258`, `:6451` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
18 changes: 17 additions & 1 deletion packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,7 +231,23 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
reachable: 19,
blindSpot: 61,
populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints',
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 61 },
// [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control
// exists to prove this census is still reading the file it thinks it is, and
// a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214
// was supposed to cause — `registerUiEndpoints` was the ONE route in this
// file that resolved no identity, and it is now guarded. The move is +3 over
// the whole file (`occurrences` counts the bare term, comments included):
// one new call site — `if (this.enforceAuth(req, res, context)) return;`,
// 52 -> 53 — plus two prose mentions in the new doc-comments. ⛔ Kept as an
// EXACT count rather than a range or a floor: a range would stop this row
// noticing the next move, which is the only thing it is for.
//
// ⚠️ The three sibling numbers were re-derived and did NOT move, which is
// what says this is a guard change and not a surface change: `population`
// 80, `reachable` 19, `private register*Endpoints(` 17 and
// `this.routeManager.register(` 80 are all unchanged — #13214 added no route
// and no registrar. `blindSpot` therefore stays 61 as well.
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 },
note:
'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' +
'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' +
Expand Down
51 changes: 39 additions & 12 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,15 @@ const ENTITLED = {
isSystem: false,
tenantId: 'org_census',
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
// [#13214] The internal key `computeExecCtx` stamps on every context it
// produces, naming the environment whose auth service actually validated
// the caller. `enforceEnvironmentOwnership` — the new guard on the UI-view
// site this census now counts — compares it against the environment the
// request resolved to, which under `makeServer` is `env_census`.
// `instrument()` replaces `resolveExecCtx` wholesale, so a synthetic
// context has to model the key or it is a caller anchored NOWHERE, which
// that seam refuses. Every other site in this census ignores it.
__authEnvironmentId: 'env_census',
};
const OBJECT_DOC = { name: 'acct', type: 'object', fields: {}, groups: [] };

Expand DownExpand Up@@ -300,54 +309,72 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('72 invocation sites, 89 mentions — the thread\'s two control numbers hold', () => {
expect(SITES.length).toBe(72);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(89);
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
// 2026-08-30 ruling closed it. It joins as a BARE site behind the
// shared floor, which is the family the next two cases describe.
//
// ⚠️ The two numbers moved by DIFFERENT amounts (+1 and +3) and that is
// the point of counting both: one is the call site, the other two are
// prose mentions in the new doc-comments (the registrar's, recording
// that this route used to call `resolveExecCtx` zero times, and the
// ownership guard's, recording that adding `resolveExecCtx` +
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
});

it('the split is 20 locally caught / 52 bare — NOT 16 / 52, which does not add to 72', () => {
it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
//
// [#13214] The new site is BARE, and that is a decision the next case
// enforces: a locally-caught site sitting behind the shared floor would
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(BARE.length).toBe(52);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 52 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
// next statement; the locally-caught ones carry a `.catch` because
// they are NOT behind that floor and each must decide for itself.
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(52);
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(53);
expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0);
});
});

// ---------------------------------------------------------------------------
// 3. The 52 bare sites, driven
// 3. The 53 bare sites, driven
// ---------------------------------------------------------------------------

describe('[#13160] §3 the 52 bare sites — driven, every one of them', () => {
it('all 52 are reached by the mounted route table, so none is classified by inference', async () => {
describe('[#13160] §3 the 53 bare sites — driven, every one of them', () => {
it('all 53 are reached by the mounted route table, so none is classified by inference', async () => {
const reached = sitesOf(await sweep(undefined, 'FULL'));
const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l));
// ⛔ A bare site that stopped being reachable must show up as a
// shrinking census, never as a row silently inherited from a neighbour.
expect(unreached).toEqual([]);
}, 120_000);

it('an absent context is the ANONYMOUS SUBJECT at all 52: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
it('an absent context is the ANONYMOUS SUBJECT at all 53: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
const fault = await sweep(undefined, 'FULL');
const control = await sweep(ENTITLED, 'FULL');
const bareLines = new Set(BARE.map((s) => s.line));
const controlByRoute = new Map(control.map((r) => [r.route, r]));

const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l)));
expect(rows.length).toBeGreaterThanOrEqual(52);
expect(rows.length).toBeGreaterThanOrEqual(53);

for (const row of rows) {
expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS);
Expand Down
16 changes: 16 additions & 0 deletions packages/rest/src/rest-exec-ctx-principal-kind.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -293,6 +293,22 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output
// it, so it is an assembled field now and absent for the same reason
// every other unset field is: this session carries no gate.
'__kernel',
// [#13214] The SECOND post-assembly internal key, added by the
// 2026-08-30 security ruling and named here rather than left to a
// subset check — this pin exists precisely to make a key ARRIVING
// as loud as a key going missing, and this one arrived.
//
// It carries the environment whose auth service actually validated
// the caller, which is the left-hand side of the ownership
// comparison `enforceEnvironmentOwnership` makes at
// `GET /ui/view/:object/:type`. ⚠️ Unlike `__kernel` it IS an
// authorization input, at exactly one reader inside `rest-server.ts`
// — ⛔ nothing downstream of this transport may branch on it, and
// it is deliberately NOT an `ExecutionContext` field because it
// describes how the context was OBTAINED, not what the principal
// may do. The assembled field set is unchanged; this sits beside it,
// in the same `as any` the class doc-comment already covers.
'__authEnvironmentId',
]));
});

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(rest): require the resolved environment to belong to the caller at GET /ui/view/:object/:type by claude[bot] · Pull Request #13625 · objectstack-ai/objectstack · GitHub
Skip to content
75 changes: 75 additions & 0 deletions .changeset/ui-view-environment-ownership.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
---
"@objectstack/rest": minor
---

fix(rest): require the resolved environment to belong to the caller at `GET /api/v1/ui/view/:object/:type` (#13214)

**Security floor.** This route was the one identity-touching route in
`RestServer`'s table that resolved no identity at all: it went from
`resolveProtocol` straight to `getUiView`, answering **200** to an anonymous
caller, byte-identical to an entitled one, with `resolveExecCtx` called **zero**
times — while the other 52 identity-touching routes answered 401 under an absent
context.

Because the unscoped mount lets the REQUEST name its environment (bound
hostname, else the `X-Environment-Id` header), that made it a cross-environment
disclosure rather than a single-tenant one. Driven on the real route table with
a real `envRegistry` + `kernelManager`: an anonymous request naming another
environment received **that environment's** UI view — object label plus every
field's `name` / `label` / `type` / `required` / `readonly` — through **both**
naming channels, with the foreign kernel acquired. The route was additionally an
object-existence oracle for whatever environment was named, and an
**environment-id** oracle: an unresolvable `X-Environment-Id` was not refused but
silently fell through to the default environment and answered 200 with *that*
environment's view, so two 200s with different bytes distinguished a real
environment id from an invented one.

Maintainer ruling 2026-08-30 (option C). Adding anonymous-deny alone was
explicitly measured **not** to be the repair — it stops the anonymous caller and
nothing else, because an authenticated caller could still name a foreign
environment and nothing downstream compared the environment that was *resolved*
with the environment the caller is *entitled to*.

What the seam does now, in order: resolve the environment once through the
shared entry point; resolve identity **in that environment**; refuse anonymity;
then compare. The comparison reads `__authEnvironmentId` — an internal key
`computeExecCtx` now stamps on every context it produces, naming the environment
whose auth service actually validated the caller. It differs from the resolved
environment in exactly the branch that crosses: when the resolved environment's
kernel carries no `auth` service, the lookup falls back to the **default**
environment's, and a session minted there authenticated a request naming another
one.

Both refusable shapes answer with the anonymous-deny envelope **verbatim**
(401 `UNAUTHENTICATED`), and that is deliberate rather than tidiness: a caller
naming a real foreign environment is already refused by the anonymous gate
(their credential is not valid there), so giving "you do not own this
environment" or "that environment id does not resolve" any *other* status would
rebuild the id oracle one layer up. One shape, byte for byte, for every way a
caller can fail to be entitled to the environment it named. The cost is
diagnosability: an operator whose environment genuinely lacks an `auth` service
sees the anonymous 401 rather than a wiring error.

**Migration.** The published route changes from "anonymous read" to
"authenticated **and** ownership-checked", so a caller that relied on the old
behaviour breaks:

- An **anonymous** consumer of `/ui/view/...` (for example a login screen
rendering a view before authentication) now receives 401. There is no opt-out;
the route is not on `isAuthGateAllowlisted` and was never a declared
control-plane exemption.
- A caller sending `X-Environment-Id` **while on a hostname bound to a different
environment** now receives 401 instead of being served the hostname's
environment. Drop the contradictory header; the bound hostname still decides.
- A caller sending an `X-Environment-Id` the registry cannot resolve now
receives 401 instead of the default environment's view.
- A deployment where an environment's kernel carries no `auth` service of its
own now refuses requests naming that environment, because the credential
would have been validated in the default environment instead. Wire the
environment's `auth` service.

Scoped (`/environments/:environmentId/ui/view/...`) and unscoped mounts are both
gated; naming an environment in the URL is no more of an entitlement than naming
it in a header. What the producer is *told* is unchanged — `getUiView` still
receives `{ object, type }` on the unscoped mount and the route-supplied
`environmentId` on the scoped one.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4284`, `:5647`, `:5895`, `:6258`, `:6451` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
18 changes: 17 additions & 1 deletion packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,7 +231,23 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
reachable: 19,
blindSpot: 61,
populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints',
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 61 },
// [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control
// exists to prove this census is still reading the file it thinks it is, and
// a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214
// was supposed to cause — `registerUiEndpoints` was the ONE route in this
// file that resolved no identity, and it is now guarded. The move is +3 over
// the whole file (`occurrences` counts the bare term, comments included):
// one new call site — `if (this.enforceAuth(req, res, context)) return;`,
// 52 -> 53 — plus two prose mentions in the new doc-comments. ⛔ Kept as an
// EXACT count rather than a range or a floor: a range would stop this row
// noticing the next move, which is the only thing it is for.
//
// ⚠️ The three sibling numbers were re-derived and did NOT move, which is
// what says this is a guard change and not a surface change: `population`
// 80, `reachable` 19, `private register*Endpoints(` 17 and
// `this.routeManager.register(` 80 are all unchanged — #13214 added no route
// and no registrar. `blindSpot` therefore stays 61 as well.
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 },
note:
'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' +
'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' +
Expand Down
51 changes: 39 additions & 12 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,15 @@ const ENTITLED = {
isSystem: false,
tenantId: 'org_census',
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
// [#13214] The internal key `computeExecCtx` stamps on every context it
// produces, naming the environment whose auth service actually validated
// the caller. `enforceEnvironmentOwnership` — the new guard on the UI-view
// site this census now counts — compares it against the environment the
// request resolved to, which under `makeServer` is `env_census`.
// `instrument()` replaces `resolveExecCtx` wholesale, so a synthetic
// context has to model the key or it is a caller anchored NOWHERE, which
// that seam refuses. Every other site in this census ignores it.
__authEnvironmentId: 'env_census',
};
const OBJECT_DOC = { name: 'acct', type: 'object', fields: {}, groups: [] };

Expand DownExpand Up@@ -300,54 +309,72 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('72 invocation sites, 89 mentions — the thread\'s two control numbers hold', () => {
expect(SITES.length).toBe(72);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(89);
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
// 2026-08-30 ruling closed it. It joins as a BARE site behind the
// shared floor, which is the family the next two cases describe.
//
// ⚠️ The two numbers moved by DIFFERENT amounts (+1 and +3) and that is
// the point of counting both: one is the call site, the other two are
// prose mentions in the new doc-comments (the registrar's, recording
// that this route used to call `resolveExecCtx` zero times, and the
// ownership guard's, recording that adding `resolveExecCtx` +
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
});

it('the split is 20 locally caught / 52 bare — NOT 16 / 52, which does not add to 72', () => {
it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
//
// [#13214] The new site is BARE, and that is a decision the next case
// enforces: a locally-caught site sitting behind the shared floor would
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(BARE.length).toBe(52);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 52 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
// next statement; the locally-caught ones carry a `.catch` because
// they are NOT behind that floor and each must decide for itself.
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(52);
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(53);
expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0);
});
});

// ---------------------------------------------------------------------------
// 3. The 52 bare sites, driven
// 3. The 53 bare sites, driven
// ---------------------------------------------------------------------------

describe('[#13160] §3 the 52 bare sites — driven, every one of them', () => {
it('all 52 are reached by the mounted route table, so none is classified by inference', async () => {
describe('[#13160] §3 the 53 bare sites — driven, every one of them', () => {
it('all 53 are reached by the mounted route table, so none is classified by inference', async () => {
const reached = sitesOf(await sweep(undefined, 'FULL'));
const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l));
// ⛔ A bare site that stopped being reachable must show up as a
// shrinking census, never as a row silently inherited from a neighbour.
expect(unreached).toEqual([]);
}, 120_000);

it('an absent context is the ANONYMOUS SUBJECT at all 52: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
it('an absent context is the ANONYMOUS SUBJECT at all 53: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
const fault = await sweep(undefined, 'FULL');
const control = await sweep(ENTITLED, 'FULL');
const bareLines = new Set(BARE.map((s) => s.line));
const controlByRoute = new Map(control.map((r) => [r.route, r]));

const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l)));
expect(rows.length).toBeGreaterThanOrEqual(52);
expect(rows.length).toBeGreaterThanOrEqual(53);

for (const row of rows) {
expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS);
Expand Down
16 changes: 16 additions & 0 deletions packages/rest/src/rest-exec-ctx-principal-kind.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -293,6 +293,22 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output
// it, so it is an assembled field now and absent for the same reason
// every other unset field is: this session carries no gate.
'__kernel',
// [#13214] The SECOND post-assembly internal key, added by the
// 2026-08-30 security ruling and named here rather than left to a
// subset check — this pin exists precisely to make a key ARRIVING
// as loud as a key going missing, and this one arrived.
//
// It carries the environment whose auth service actually validated
// the caller, which is the left-hand side of the ownership
// comparison `enforceEnvironmentOwnership` makes at
// `GET /ui/view/:object/:type`. ⚠️ Unlike `__kernel` it IS an
// authorization input, at exactly one reader inside `rest-server.ts`
// — ⛔ nothing downstream of this transport may branch on it, and
// it is deliberately NOT an `ExecutionContext` field because it
// describes how the context was OBTAINED, not what the principal
// may do. The assembled field set is unchanged; this sits beside it,
// in the same `as any` the class doc-comment already covers.
'__authEnvironmentId',
]));
});

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(rest): require the resolved environment to belong to the caller at GET /ui/view/:object/:type by claude[bot] · Pull Request #13625 · objectstack-ai/objectstack · GitHub
Skip to content
75 changes: 75 additions & 0 deletions .changeset/ui-view-environment-ownership.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
---
"@objectstack/rest": minor
---

fix(rest): require the resolved environment to belong to the caller at `GET /api/v1/ui/view/:object/:type` (#13214)

**Security floor.** This route was the one identity-touching route in
`RestServer`'s table that resolved no identity at all: it went from
`resolveProtocol` straight to `getUiView`, answering **200** to an anonymous
caller, byte-identical to an entitled one, with `resolveExecCtx` called **zero**
times — while the other 52 identity-touching routes answered 401 under an absent
context.

Because the unscoped mount lets the REQUEST name its environment (bound
hostname, else the `X-Environment-Id` header), that made it a cross-environment
disclosure rather than a single-tenant one. Driven on the real route table with
a real `envRegistry` + `kernelManager`: an anonymous request naming another
environment received **that environment's** UI view — object label plus every
field's `name` / `label` / `type` / `required` / `readonly` — through **both**
naming channels, with the foreign kernel acquired. The route was additionally an
object-existence oracle for whatever environment was named, and an
**environment-id** oracle: an unresolvable `X-Environment-Id` was not refused but
silently fell through to the default environment and answered 200 with *that*
environment's view, so two 200s with different bytes distinguished a real
environment id from an invented one.

Maintainer ruling 2026-08-30 (option C). Adding anonymous-deny alone was
explicitly measured **not** to be the repair — it stops the anonymous caller and
nothing else, because an authenticated caller could still name a foreign
environment and nothing downstream compared the environment that was *resolved*
with the environment the caller is *entitled to*.

What the seam does now, in order: resolve the environment once through the
shared entry point; resolve identity **in that environment**; refuse anonymity;
then compare. The comparison reads `__authEnvironmentId` — an internal key
`computeExecCtx` now stamps on every context it produces, naming the environment
whose auth service actually validated the caller. It differs from the resolved
environment in exactly the branch that crosses: when the resolved environment's
kernel carries no `auth` service, the lookup falls back to the **default**
environment's, and a session minted there authenticated a request naming another
one.

Both refusable shapes answer with the anonymous-deny envelope **verbatim**
(401 `UNAUTHENTICATED`), and that is deliberate rather than tidiness: a caller
naming a real foreign environment is already refused by the anonymous gate
(their credential is not valid there), so giving "you do not own this
environment" or "that environment id does not resolve" any *other* status would
rebuild the id oracle one layer up. One shape, byte for byte, for every way a
caller can fail to be entitled to the environment it named. The cost is
diagnosability: an operator whose environment genuinely lacks an `auth` service
sees the anonymous 401 rather than a wiring error.

**Migration.** The published route changes from "anonymous read" to
"authenticated **and** ownership-checked", so a caller that relied on the old
behaviour breaks:

- An **anonymous** consumer of `/ui/view/...` (for example a login screen
rendering a view before authentication) now receives 401. There is no opt-out;
the route is not on `isAuthGateAllowlisted` and was never a declared
control-plane exemption.
- A caller sending `X-Environment-Id` **while on a hostname bound to a different
environment** now receives 401 instead of being served the hostname's
environment. Drop the contradictory header; the bound hostname still decides.
- A caller sending an `X-Environment-Id` the registry cannot resolve now
receives 401 instead of the default environment's view.
- A deployment where an environment's kernel carries no `auth` service of its
own now refuses requests naming that environment, because the credential
would have been validated in the default environment instead. Wire the
environment's `auth` service.

Scoped (`/environments/:environmentId/ui/view/...`) and unscoped mounts are both
gated; naming an environment in the URL is no more of an entitlement than naming
it in a header. What the producer is *told* is unchanged — `getUiView` still
receives `{ object, type }` on the unscoped mount and the route-supplied
`environmentId` on the scoped one.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4284`, `:5647`, `:5895`, `:6258`, `:6451` |
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` |
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
Expand Down
18 changes: 17 additions & 1 deletion packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,7 +231,23 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
reachable: 19,
blindSpot: 61,
populationRule: '`this.routeManager.register(` call sites; reachable = those inside registerMetadataEndpoints',
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 61 },
// [#13214] `enforceAuth` 61 -> 64. ⛔ RE-ANCHORED, not relaxed: the control
// exists to prove this census is still reading the file it thinks it is, and
// a rising `enforceAuth` is precisely what the 2026-08-30 ruling on #13214
// was supposed to cause — `registerUiEndpoints` was the ONE route in this
// file that resolved no identity, and it is now guarded. The move is +3 over
// the whole file (`occurrences` counts the bare term, comments included):
// one new call site — `if (this.enforceAuth(req, res, context)) return;`,
// 52 -> 53 — plus two prose mentions in the new doc-comments. ⛔ Kept as an
// EXACT count rather than a range or a floor: a range would stop this row
// noticing the next move, which is the only thing it is for.
//
// ⚠️ The three sibling numbers were re-derived and did NOT move, which is
// what says this is a guard change and not a surface change: `population`
// 80, `reachable` 19, `private register*Endpoints(` 17 and
// `this.routeManager.register(` 80 are all unchanged — #13214 added no route
// and no registrar. `blindSpot` therefore stays 61 as well.
controls: { 'private register*Endpoints(': 17, 'this.routeManager.register(': 80, enforceAuth: 64 },
note:
'The single non-tripwire probe names ONE registrar of 17. The other 16 can never mint a key: ' +
'registerCrudEndpoints, registerApprovalsEndpoints, registerDataActionEndpoints, registerReportsEndpoints, ' +
Expand Down
51 changes: 39 additions & 12 deletions packages/rest/src/execctx-consumer-census.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,15 @@ const ENTITLED = {
isSystem: false,
tenantId: 'org_census',
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
// [#13214] The internal key `computeExecCtx` stamps on every context it
// produces, naming the environment whose auth service actually validated
// the caller. `enforceEnvironmentOwnership` — the new guard on the UI-view
// site this census now counts — compares it against the environment the
// request resolved to, which under `makeServer` is `env_census`.
// `instrument()` replaces `resolveExecCtx` wholesale, so a synthetic
// context has to model the key or it is a caller anchored NOWHERE, which
// that seam refuses. Every other site in this census ignores it.
__authEnvironmentId: 'env_census',
};
const OBJECT_DOC = { name: 'acct', type: 'object', fields: {}, groups: [] };

Expand DownExpand Up@@ -300,54 +309,72 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
// ---------------------------------------------------------------------------

describe('[#13160] §2 the consumer surface, counted from the tree', () => {
it('72 invocation sites, 89 mentions — the thread\'s two control numbers hold', () => {
expect(SITES.length).toBe(72);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(89);
it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => {
// [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was
// the ONE metadata-touching route in the table that resolved no
// identity at all — the exception this census surfaced — and the
// 2026-08-30 ruling closed it. It joins as a BARE site behind the
// shared floor, which is the family the next two cases describe.
//
// ⚠️ The two numbers moved by DIFFERENT amounts (+1 and +3) and that is
// the point of counting both: one is the call site, the other two are
// prose mentions in the new doc-comments (the registrar's, recording
// that this route used to call `resolveExecCtx` zero times, and the
// ownership guard's, recording that adding `resolveExecCtx` +
// `enforceAuth` was measured NOT to be the repair). A mention count
// that tracked the site count exactly would be measuring one thing
// twice.
expect(SITES.length).toBe(73);
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92);
});

it('the split is 20 locally caught / 52 bare — NOT 16 / 52, which does not add to 72', () => {
it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => {
// 16 sites spell the catch on the invocation line; 4 more spell it on
// the continuation line. A single-line grep sees 16 and the arithmetic
// silently loses four sites.
//
// [#13214] The new site is BARE, and that is a decision the next case
// enforces: a locally-caught site sitting behind the shared floor would
// be the first of its kind and would break the structural claim below.
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
expect(sameLine.length).toBe(16);
expect(CAUGHT.length).toBe(20);
expect(BARE.length).toBe(52);
expect(BARE.length).toBe(53);
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
});

it('⭐ every one of the 52 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => {
// This inverts the reason the thread gave for doing the bare sites
// first ("no local signal that a fault becomes an anonymous subject").
// The bare sites are bare BECAUSE the shared anonymous floor is the
// next statement; the locally-caught ones carry a `.catch` because
// they are NOT behind that floor and each must decide for itself.
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(52);
expect(BARE.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(53);
expect(CAUGHT.filter((s) => s.nextLine === ENFORCE_AUTH_GUARD).length).toBe(0);
});
});

// ---------------------------------------------------------------------------
// 3. The 52 bare sites, driven
// 3. The 53 bare sites, driven
// ---------------------------------------------------------------------------

describe('[#13160] §3 the 52 bare sites — driven, every one of them', () => {
it('all 52 are reached by the mounted route table, so none is classified by inference', async () => {
describe('[#13160] §3 the 53 bare sites — driven, every one of them', () => {
it('all 53 are reached by the mounted route table, so none is classified by inference', async () => {
const reached = sitesOf(await sweep(undefined, 'FULL'));
const unreached = BARE.map((s) => s.line).filter((l) => !reached.has(l));
// ⛔ A bare site that stopped being reachable must show up as a
// shrinking census, never as a row silently inherited from a neighbour.
expect(unreached).toEqual([]);
}, 120_000);

it('an absent context is the ANONYMOUS SUBJECT at all 52: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
it('an absent context is the ANONYMOUS SUBJECT at all 53: 401 UNAUTHENTICATED, and the same instrument serves an entitled caller', async () => {
const fault = await sweep(undefined, 'FULL');
const control = await sweep(ENTITLED, 'FULL');
const bareLines = new Set(BARE.map((s) => s.line));
const controlByRoute = new Map(control.map((r) => [r.route, r]));

const rows = fault.filter((r) => r.sites.some((l) => bareLines.has(l)));
expect(rows.length).toBeGreaterThanOrEqual(52);
expect(rows.length).toBeGreaterThanOrEqual(53);

for (const row of rows) {
expect(row.status, `${row.route} under an absent context`).toBe(ANONYMOUS_DENY_STATUS);
Expand Down
16 changes: 16 additions & 0 deletions packages/rest/src/rest-exec-ctx-principal-kind.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -293,6 +293,22 @@ describe('#6216 — the REST face assembles through the SHARED assembler, output
// it, so it is an assembled field now and absent for the same reason
// every other unset field is: this session carries no gate.
'__kernel',
// [#13214] The SECOND post-assembly internal key, added by the
// 2026-08-30 security ruling and named here rather than left to a
// subset check — this pin exists precisely to make a key ARRIVING
// as loud as a key going missing, and this one arrived.
//
// It carries the environment whose auth service actually validated
// the caller, which is the left-hand side of the ownership
// comparison `enforceEnvironmentOwnership` makes at
// `GET /ui/view/:object/:type`. ⚠️ Unlike `__kernel` it IS an
// authorization input, at exactly one reader inside `rest-server.ts`
// — ⛔ nothing downstream of this transport may branch on it, and
// it is deliberately NOT an `ExecutionContext` field because it
// describes how the context was OBTAINED, not what the principal
// may do. The assembled field set is unchanged; this sits beside it,
// in the same `as any` the class doc-comment already covers.
'__authEnvironmentId',
]));
});

Expand Down
Loading
Loading