test(rest): drive the tenancy axis of GET /ui/view/:object/:type — an anonymous caller can name another environment and receive its UI view - #13258
Draft
os-trump wants to merge 3 commits into
Conversation
Extends PR #13244's measurement with the axis it explicitly did not assert: its harness had no envRegistry and no kernelManager, so the environment resolution chain was read from source and never driven. Measurement only. rest-server.ts is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…s body The ablation showed three §3 assertions were envelope-shape readings true of either environment, so they survived a leg that removed the crossing. Each now asserts environment B's own label first, making the inventory a statement about what the NAMED environment disclosed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
…k creates An unknown X-Environment-Id does not refuse; it falls through to the default environment and answers 200 with that environment's view. Two 200s with different bytes let an anonymous caller distinguish a real environment id from an invented one, which is the difference between "must possess an id" and "can discover one". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
This was referenced Aug 29, 2026
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #13214
⛔ Deliberately not
Fixes: #13214 is a decision awaiting the maintainer, and a measurement must not auto-close it.⛔ MEASUREMENT ONLY — nothing is repaired
The change set is one new test file.
packages/rest/src/rest-server.tsis byte-identical to the base — blob3eca151d4b118f0a84c10958cd3c5e8b43b79406on both sides, verifiable withgit rev-parse HEAD:packages/rest/src/rest-server.ts. Access control is a human floor in this repo: reporting the measurement is the complete delivery.packages/core/src/security/auth-gate.ts(#7898's on-hold trigger file, H17) was not read or edited on this branch — the previous run already settled that question by driving the predicate.What this extends, and what it does NOT re-measure
PR #13244 settled the single-tenant half: this route resolves no identity at the REST seam, and
getUiViewapplies no authorization downstream because the seam hands it exactly{ object, type }. ⛔ None of that is re-measured here.What #13244 explicitly did not assert is its own
UNMEASURED 1. Its harness had noenvRegistryand nokernelManager, so the environment-resolution chain was read from source and never driven. This PR drives it for the first time, treating the earlier reading as a hypothesis rather than a fact.⭐ The instrument is proved bidirectional before any reading is trusted
Four controls run first (
section 0), because a harness that always answers "environment B" is no evidence of a crossing and one that can only answer "environment A" is no evidence of its absence:/environments/:environmentId/ui/view/...), where naming an environment is declared and URL-visible, answers with environment B's view. So the route CAN deliver B's body on this instrument, and a negative would have been a real negative.acquiredis an observable and not a constant.A third distinct answer (
Control Plane Accounts) is wired at the bootprotocolargument, so "fell through to the control plane" cannot collapse into "resolved to A".① and ② — the answers, per channel
Both channels cross. Each is a separate reading and each carries its own controls.
Channel 1 — the
X-Environment-IdheaderAn anonymous request (no execution context at all) on the unscoped URL, whose hostname resolves to nothing, carrying only
X-Environment-Idnaming environment B, receives environment B's UI view. Three independent observations, not one:Beta Environment Accounts,beta_only_field), not the A baseline C2 measured;acquired === ['env_beta']);resolveExecCtxwas called zero times, on every site the request reached.An entitled caller naming B receives byte-identical bytes, so the answer does not depend on the caller.
Negative control: a header naming an id the registry does not know does not cross — it falls to the default environment — and the registry was consulted (
idLookupscontains the probed id), so that green is a decision rather than a header nobody read.Channel 2 — the request hostname
An anonymous request whose hostname is bound to environment B receives environment B's view, again with
resolveExecCtxat zero and B's kernel acquired.③ Blast radius on the cross-environment path — measured, ⛔ not inherited
Every reading below is pinned to the crossed body (it asserts B's own label first), so none of it is an envelope statement true of any environment.
{ object, list }withlist={ type, label, columns, sort, searchableFields }and each column exactly{ field, label, sortable }.rows,records,data,total,valuesoritems; and the payload names no driver, connection, datasource or credential, and does not echo the environment id.required/readonly/type/ label declarations.hiddenis NOT a uniform floor — and this is where the single-tenant result does not carry over. Driven with two hidden fields of different kinds:beta_secret(hidden, not a priority name) is dropped from the list, whilestatus(hidden, and one of the producer's priority names) is served, with its label. The producer's list branch applies!fields[k].hiddenonly to the fill pass, never to the priority pass; the form branch filters all hidden fields uniformly, so the two branches of one producer disagree. test(rest): measure identity resolution at GET /ui/view/:object/:type — seam, downstream producer, and the ratchet blind spot behind it #13244 measured this with one hidden non-priority field and reported "hidden is dropped by declaration" — true of the field it drove, not of the class. Recorded as a measurement; the repair is not this card.④⚠️ Exactly what a caller must know — the severity turns on this
Must be true of the deployment (all measured, each with a control):
envRegistryANDkernelManagerboth wired. With neither, the header is inert and the request falls to the control-plane protocol — which is precisely why test(rest): measure identity resolution at GET /ui/view/:object/:type — seam, downstream producer, and the ratchet blind spot behind it #13244's harness could not see this, and that reproduction is included as a control. WithenvRegistrybut nokernelManager, the legacy chain is skipped entirely (its guard isenvRegistry && kernelManager).requestEnvResolverinjected, or one that throws. When the host injects a resolver its normal return is final (ADR-0076 D11 step ④), so the legacy hostname/header chain is never consulted — measured, with the control that shows the same injected resolver can send the request to B, so this is the resolver deciding and not a dead channel. A throwing resolver degrades to the legacy chain and the header crosses again — also measured.⭐ The generalisation matters more than either channel. The route applies no identity check regardless of which channel named the environment: with a
requestEnvResolverwired that names B, the anonymous caller still receives B's view. So injecting a resolver relocates the decision; it is not by itself a gate. Whether a given production deployment wireskernel-resolveris a deployment fact this PR did not drive —rest-api-plugin.ts's comment says the cloud runtime registers one next toenv-registry, and nokernel-resolverimplementation ships in this repo. Stated as READ, not driven.Must be known by the caller:
accountis a stock name and no credential is needed to try one.envRegistry.resolveById, so an arbitrary string does not win.env_alpha) produces the same "this id resolves" signal, so the discriminator is validity and not the identity of B.Must NOT be known: nothing else. No cookie, no
Authorization, no session, no prior request. Driven with a request carrying onlyhostplus the one header.Contrast — a guarded sibling under the same tenancy wiring
GET /data/:objectnaming environment B anonymously answers401with the anonymous-deny code, and serves an entitled caller — so this harness does express a refusal, and the 200s above are readings about this route rather than a driver that cannot produce a 401.Reverse verification (ablation)
Run on the committed implementation, mutating the legacy hostname/header branch of
resolveRequestEnvironmentIdto be unreachable. Predicted direction: the crossing disappears — every cross-environment reading should fall back to the default environment.grep -c= 1, anchor text remaining = 0, blob moved3eca151dto8a0174ee. The script aborts the run if any of those does not hold.rest-server.ts, imported in-package as./rest-server.jsand resolved by vitest to the TS source, not through a dependency'sexportstodist/. The 12 reds with no rebuild are themselves the proof the mutation reached the code under test. The one dependency that does resolve todist/is@objectstack/metadata-protocol(unaliased here, registered inKNOWN_UNALIASED_TEST_IMPORTSfor@objectstack/rest); the full workspace closure was built before every reading, and the test additionally asserts a post-GET /ui/view/:object/:type发的是{list}/{form},spec 的GetViewResponseSchema声明的是{object, view}—— 同一路由声明与实际两种形状(#5563 同族) #5948 shape (objecton the container, absent fromlist) so a stale dist would fail loudly rather than report an old producer's behaviour as current.git checkout HEAD -- "$REPO_ROOT/packages/rest/src/rest-server.ts"(absolute path,HEAD-pinned to bypass the index) undertrap ... EXIT INT TERM. Restored blob3eca151d...equals the HEAD blob, injected text count 0,git diff HEADempty.Verification
All exit codes captured before any pipe; every gate quoted from its own printed verdict line. Union re-derived for the actual change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(it takes the change set from the merge base itself and confirmed the answer comes fromobjectstack-ai/objectstackat commitba06dabd) and run at final headba06dabd.pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2—Test Files 159 passed (159) / Tests 2650 passed (2650).pnpm --filter @objectstack/rest typecheck— exit 0;check:test-typecheck: OK — @objectstack/rest's test layer compiles under packages/rest/tsconfig.test.json. ⭐ Not a NOT-MEASURED green:tsc -p tsconfig.test.json --listFilesputs this file in the program (665-line listing, 1 hit), and the file contributes zero of the 6 ledgered errors. That program is also what makes the constructor pin real — the boot builds its 20 arguments as a tuple typed as the constructor's own parameter list (theConstructorParametersutility type applied toRestServer), so both arity and every position are type-checked. test(rest): measure identity resolution at GET /ui/view/:object/:type — seam, downstream producer, and the ratchet blind spot behind it #13244's 27-argument call to the 20-parameter constructor ran fine while shifting three providers onto the wrong parameters, andkernelManager/envRegistryare positions 4 and 5, which is exactly where a tenancy harness would have been silently wrong.pnpm check:authz-resolver—check:authz-resolver: single shared authorization resolver intact; both entry points delegate.check:nul-bytes(OK (scanned 7358 text file(s) ... no raw ASCII control bytes)),check:cross-package-test-inputs(OK: 24 package(s) read outside themselves, all declared),check:dispatcher-error-vocabulary,check:logger-receiver-detach,check:objectql-double-limit,check:page-declaration-shape,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check-ci-filter-parity,check-comment-mask-adoption,check-cross-package-test-inputs,check-keyed-text-bounds,check-plugin-teardown-shape,check-shard-attestation,check-undeclared-dep-imports,docs-audit/check-affected-docs,docs-audit/check-drift-comment.turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*—70 successful, 70 total) because the next two refuse outright unbuilt and a refusal is NOT MEASURED:check:dual-build-cjs-loads—102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse;check:type-check-debt—check-type-check-coverage --re-measure: OK — 30 ledger entr(ies) re-measured in 247.4s, 1560 raw tsc error(s) total, none above its recorded number,surplus: none.pnpm lint(repo-wideeslint . --no-inline-config) — exit 0. Run in full; no narrowing claimed.node scripts/check-test-completeness.mjsexits 3PREREQUISITE NOT METwith no argument — it grades a savedturbo run testlog and cannot produce one; CI tees it and passes the path. Recorded NOT MEASURED, never as a pass and never as a red.No changeset
Test-only diff: one file under
packages/rest/src/, publishing nothing from any package (the build config excludes*.test.tsfrom the published artifact, and ci.yml gates that).skip-changesetapplies.Generated by Claude Code
Generated by Claude Code