Uh oh!
There was an error while loading. Please reload this page.
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
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
📓 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): |
os-zhuang
commented
Aug 30, 2026
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33297756999 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
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