Uh oh!
There was an error while loading. Please reload this page.
fix(cloud-connection): the install-local listing requires an authenticated principal, and narrows installedBy / storageDir to manage_metadata holders - #9256
Conversation
…cated principal, and narrows two fields `GET /api/v1/marketplace/install-local` resolved no identity at all. After #8976 gated the four mutating doors it was the only anonymous door left on the surface: `handleList` opened on `this.readAll()`, so any caller who could reach the port got 200 and the whole ledger — including `installedBy` (a platform user id, enumerated across every install) and `storageDir` (an absolute filesystem path on the host). Maintainer ruling 2026-08-16, Option 3 — authenticated floor plus field narrowing: - anonymous -> 401 UNAUTHENTICATED - authenticated, no `manage_metadata` -> 200, inventory without `installedBy` / `storageDir` - authenticated, `manage_metadata` -> 200, full payload unchanged Identity comes from `resolveInstallPrincipal` — the same resolver the four mutating doors already use — rather than a second session read, and the 401 envelope is extracted into one `refuseUnauthenticated` seam so the read door and the write doors cannot drift apart on it. The mount stays unconditional (cloud#1287); the answer to an unauthorized read is a refusal, not a 404. No new capability is minted. Fixes#9011
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not seeCoarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop):
|
Uh oh!
There was an error while loading. Please reload this page.
…#9325) The `lint` job in lint.yml publishes one check run whose name is its `name:`, and that name was `ESLint` while the job ran ~70 sequential `pnpm check:*` steps. Any one of them failing published a red check called `ESLint` on a PR whose diff may have nothing to do with linting, so the reader had to open the log and scroll to the failing step before they could even decide whose problem it was. That cost was paid three times on 2026-08-17 (#9258, PRs #9256/#9291), and the compounding harm is that a check whose name does not describe what it verifies trains readers to re-run on red -- which is how a real regression in this gate family gets waved through (#5617 is that failure, from the other direction). Renames the job to `Lint & Repo Gates` and moves the pinned literal in the `check-required-contexts.mjs` registry (#6865) in the same diff, so the two repo-side halves cannot drift. The self-test's rename ablation now anchors on the new name and mutates it back to `ESLint`, which makes it the regression test for this very change: reverting lint.yml without the registry is red.⚠️ This is only the repo-side half. A GitHub required status check is matched by check-run name, so the entry in Settings -> Rulesets must be swapped from `ESLint` to `Lint & Repo Gates` by a maintainer immediately after this merges. Merging without that swap leaves every open PR and the merge queue wedged on a permanently-pending `ESLint` that can never report again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NYgmGheCzM6NrHZN436Cxf
Fixes#9011
Implements the maintainer ruling of 2026-08-16 13:03Z (comment 5307568223): Option 3 — authenticated floor plus field narrowing on
GET /api/v1/marketplace/install-local.Premise re-measured on current
main, not assumedBranched from
23abe2782, i.e. the post-#8976 handler (PR #9012,e0695b582). Both of the card's claims hold:1. The GET really is reachable with no credential — it is not covered by an upstream middleware.
handleList's first statement wasconst { entries, skipped } = this.readAll();. The route is mounted on the raw framework app viahttpServer.getRawApp(), and the only global middleware the Hono server installs are the Server-Timing collector, CORS, and theIHttpServermiddleware seam — none of them authenticates. So the absence of a gate in the handler is the whole story: not a weaker gate, no gate.2. Both narrowed fields were on the wire, and here is the complete field set. The ruling narrows two, so the rest are deliberately kept and are worth naming:
packageIdversionIdmanifestIdversioninstalledAtwithSampleDatainstalledBymanage_metadataholdersitemstotalstorageDirmanage_metadataholdersThe fork clause: no unauthenticated consumer exists
Checked every caller in both repos before writing code, and none fetches this route anonymously:
objectuimarketplaceApi.tslistLocalInstalls()—credentials: 'include'; this is the signed-in Setup "Installed Apps" page, the known legitimate consumer.objectuiPackagesPage.tsxandinstallLocal()— the POST door, signed-in, already gated by install-local routes accept any authenticated session (plus an x-user-id header fallback) — a schema-mutating channel with no capability check, cross-tenant on walled multi-org deployments #8976.packages/clios package install— the POST door; it signs in for a real session cookie.packages/clientSDK — has no listing method for this route at all.So Option 3 breaks no shipped behaviour, and the fork clause is not triggered. Two consequences worth recording: the console renders the "installed by" line conditionally (
entry.installedBy && ...), so a narrowed caller sees the same list minus that one line rather than a broken render; and no console code readsstorageDirat all.The change
items/totalinstalledBystorageDirUNAUTHENTICATEDmanage_metadatamanage_metadataresolveInstallPrincipal— the sameresolveAuthzContextpath fix(cloud-connection): gate the four mutating install-local routes on manage_metadata, drop the x-user-id fallback (#8976) #9012 introduced for the four mutating doors — rather than a second session read. The read door therefore inherits install-local routes accept any authenticated session (plus an x-user-id header fallback) — a schema-mutating channel with no capability check, cross-tenant on walled multi-org deployments #8976's removal of thex-user-idfallback for free, which is pinned as its own case.refuseUnauthenticatedseam now shared by all five routes, so the read door and the write doors cannot drift apart on the answer a client branches on. This is the only edit to the mutating doors' path, and it is a pure extraction.nullwould be a claim about the ledger ("installed by nobody") instead of a fact about the caller.readAll(), matching the mutating doors, so a refused caller cannot probe what is installed through timing or a storage error./metapublish and rollback doors carry nomanage_metadatagate, so the authoring capability the PUT/DELETE doors enforce is reachable around #8919 discipline) — the narrowing reusesmanage_metadata, matching the/metaprecedent.Tests — the three pinned postures, plus the direction proved
New
packages/cloud-connection/src/marketplace-install-local-list-posture.test.tspins all three rows. It declares in its own docblock that it is the file which answers "is the listing gated?", because three neighbours have names that sound like they do and do not.The non-operator row is pinned in both directions — the inventory is present and the two fields are absent. Asserting only the absences would keep passing if that caller were refused outright, which is the option the ruling explicitly rejected for withdrawing a shipped console page. Refusals assert the ADR-0112 envelope (
codeandstatus), never a bare failure.Reverse verification, direction predicted before running. With the fix committed, the gate was ablated (
resolveInstallPrincipalremoved,operatorforced true) and the suite re-run: 4 failed, 160 passed, every failure inside the new file and none outside it —expected 200 to be 401x-user-idheader:expected 200 to be 401expected 200 to be 401installedByThe operator control stayed green under the ablation, as intended: it is the row the change does not move. The file was then restored with
git checkout HEAD -- ...(index and tree both), leavinggit status --porcelainempty.marketplace-install-local-capability-enumeration.test.tsis touched for comments only: its docblock said the GET's posture was "a separate question tracked separately", which this PR makes stale. It now names the ruling and the file that pins it, and the "mounts the read listing" assertion records why the answer to an unauthorized read must stay a refusal rather than an unmounted route. No assertion was weakened or added.Verification
All commands below were run at the final commit of this branch,
dbdb23969.pnpm --filter '@objectstack/cloud-connection^...' build— dependency closure built first (green).pnpm --filter @objectstack/cloud-connection test— 21 files, 164 tests passed.node scripts/pm/dispatch-gates.mjsagainst the four actually-changed paths, all green:check:changeset-gate-self-tests,check:objectui-changeset,check-adr-0087-registration.mjs,check-changeset-no-major.mjs,check-empty-changeset.mjs,docs-audit/check-affected-docs.mjs, plus the convention-triggered set for a new test file:check:query-options-erasure,check:type-check-coverage,check:type-check-debt(the--re-measureratchet, run against a fully built workspace),check:engine-double-contract,check:where-matcher.check:nul-bytes,check:authz-resolver,check:route-envelope,check:error-code-casing,check:error-status-conformance,check:adr-anchors(this file carries an ADR-0120/ADR-0104 anchor; its invariant is about the install seam and is untouched).A changeset is included and is explicit that anonymous callers now receive 401 — a breaking change for any anonymous consumer, and for any authenticated non-operator consumer reading the two narrowed fields.
Generated by Claude Code