From c4a996fd897a02206c5a896fbe98c56ad9a9b5dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 07:32:56 +0000 Subject: [PATCH 1/2] fix(cloud-connection): the install-local listing requires an authenticated principal, and narrows two fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- ...stall-local-capability-enumeration.test.ts | 14 +- ...etplace-install-local-list-posture.test.ts | 308 ++++++++++++++++++ .../src/marketplace-install-local-plugin.ts | 79 ++++- 3 files changed, 386 insertions(+), 15 deletions(-) create mode 100644 packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts diff --git a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts index 7566f7f6c8..f57b667210 100644 --- a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts @@ -45,9 +45,12 @@ * the day it is added — before anyone has to notice it lacks a gate. * * ⚠️ The `GET` listing is deliberately NOT in this family. It is a read, and - * this card's ruling is about the four mutating doors; its own posture is a - * separate question tracked separately, and silently folding it in here would - * decide it by accident. + * this card's ruling is about the four mutating doors; silently folding it in + * here would have decided its posture by accident. That posture has since been + * ruled on separately (#9011: authenticated floor, with `installedBy` and + * `storageDir` narrowed to `manage_metadata` holders) and is pinned in + * `marketplace-install-local-list-posture.test.ts` — so the filter below still + * means "not this family", never "ungated". * * ## Rejection cases assert the ENVELOPE (ADR-0112) AND the absence of effect * @@ -296,6 +299,11 @@ describe('#8976 — the mutating install-local doors are enumerated, not recited it('mounts the read listing too — so the filter above is a CHOICE, not an empty set', async () => { // Without this, a refactor that stopped mounting the GET would leave the // assertion above passing while silently proving less than it claims. + // The listing's OWN posture lives in + // `marketplace-install-local-list-posture.test.ts` (#9011); ⛔ the fix + // for an unauthorized read there is a refusal, never an unmounted route + // — cloud#1287 made this mount unconditional so air-gapped boxes stop + // 404ing, and this assertion is what keeps that true. const { rawApp } = await mount('capable', dir); expect(rawApp.routes.has(`GET ${ROUTE_BASE}`)).toBe(true); }); diff --git a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts new file mode 100644 index 0000000000..06ba45c41d --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts @@ -0,0 +1,308 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#9011] `GET /api/v1/marketplace/install-local` — the read door's posture. + * + * ## THIS is the file that answers "is the installed-apps LISTING gated?" + * + * Its sibling `marketplace-install-local-capability-enumeration.test.ts` answers + * the same question for the four MUTATING doors and says so; it deliberately + * filters `GET ` out of its enumeration, because a read whose posture had not + * been ruled on had no business being decided by an `it.each` over a list built + * for writes. That ruling has since landed, and this file is where it lives. Two + * further neighbours sound like they answer it and do not — + * `marketplace-install-local-posture-gate.test.ts` (an ADR-0120 D5e data-shape + * ceremony) and `marketplace-install-local-tenancy-posture.test.ts` (which + * seeding path runs). "Posture" means three different things across those three + * files; only this one means authorization on the listing. + * + * ## What was measured before this landed + * + * On `origin/main` at `23abe2782`, `handleList` opened on `this.readAll()`. No + * identity resolution of any kind stood in front of it — not a weaker gate, the + * absence of one — so a caller who could reach the port received `200` and the + * full ledger: + * + * per entry: packageId, versionId, manifestId, version, installedAt, + * installedBy, withSampleData + * per response: items, total, storageDir + * + * `installedBy` is a platform user id, enumerated across every install; + * `storageDir` is an absolute filesystem path on the host. The package + * inventory itself is a version-level bill of materials for the deployment. + * + * ## The ruled posture (maintainer, 2026-08-16 — Option 3) + * + * caller 200/401 items total installedBy storageDir + * anonymous 401 —— —— —— —— + * authenticated, no `manage_metadata` 200 ✔ ✔ ✘ ✘ + * authenticated, `manage_metadata` 200 ✔ ✔ ✔ ✔ + * + * All three rows are pinned below, and the middle row is pinned in BOTH + * directions — the inventory is present AND the two fields are absent. Asserting + * only the absence would keep passing if the handler started refusing the + * non-operator outright, which is the option the ruling rejected for withdrawing + * a shipped console page. + * + * ## Rejection asserts the ENVELOPE (ADR-0112), not "it failed" + * + * `code` AND `status`. These handlers answer by RETURNING a response, so a + * throw-shaped assertion could not tell "refused with the wrong envelope" from + * "did not refuse at all" — and the anonymous row is precisely the case where + * the handler used to return a perfectly well-formed `200`. + * + * The refusal is also asserted to reach the caller BEFORE the ledger is read: + * a 401 issued after `readAll()` still lets an anonymous caller probe what is + * installed through timing or a storage error, so `ledgerReads` counts the + * ledger accesses the request itself caused. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +const ROUTE_BASE = '/api/v1/marketplace/install-local'; + +type Handler = (c: any) => Promise; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +/** The three caller shapes the ruling names, as the shared resolver sees them. */ +type Shape = 'anonymous' | 'member' | 'operator'; + +/** + * Real `sys_*` grant rows, not a pre-baked capability list. + * + * `resolveAuthzContext` aggregates `systemPermissions` out of + * `sys_user_permission_set` → `sys_permission_set`, and serving those rows + * through the same `find` production calls is what keeps this file honest about + * the resolution path: a gate rewired to some other aggregate would not see + * these and would fail here rather than silently keep passing. + */ +function grantRows(shape: Shape): Record { + // `organization_admin` deliberately withholds `manage_metadata` — the + // narrowed row is a REAL tenant administrator, not a permissionless account. + const held: string[] = shape === 'operator' + ? ['manage_metadata', 'studio.access', 'setup.access'] + : shape === 'member' + ? ['setup.access', 'manage_org_users'] + : []; + return { + sys_user: [{ id: `usr_${shape}`, email: `${shape}@acme.test` }], + sys_member: [], + sys_user_position: [], + sys_position: [], + sys_position_permission_set: [], + sys_user_permission_set: held.length + ? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', organization_id: null }] + : [], + sys_permission_set: held.length + ? [{ + id: 'ps1', + name: shape === 'operator' ? 'admin_full_access' : 'organization_admin', + system_permissions: held, + }] + : [], + }; +} + +const INSTALLED = { + packageId: 'pkg_crm', + versionId: 'pkgv_crm_1', + manifestId: 'app.test.crm', + version: '1.4.0', + manifest: { id: 'app.test.crm', version: '1.4.0', objects: [{ name: 'crm_x', fields: { name: { type: 'text' } } }] }, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: 'usr_operator', + withSampleData: true, +}; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mil-list-posture-')); + // A real ledger file, written through the ledger's own writer — the listing + // under test must have something to disclose, or every "no `installedBy`" + // assertion would pass over an empty array. + new LocalManifestSource(dir).write(INSTALLED as any); +}); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +/** + * Compose the plugin the way the kernel does — `start()` + `kernel:ready` — and + * hand back the mounted GET together with the ledger-read counter. + */ +async function mount(shape: Shape, storageDir: string) { + const rawApp = makeRawApp(); + const hooks = new Map(); + const rows = grantRows(shape); + + // The anonymous shape has NO session: `getSession` resolves nothing, which + // is the whole of what an unauthenticated caller looks like here. + const sessionUser = shape === 'anonymous' ? null : { id: `usr_${shape}` }; + + const services: Record = { + manifest: { register: vi.fn() }, + auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } }, + objectql: { syncSchemas: vi.fn(async () => undefined), find: async (object: string) => rows[object] ?? [] }, + metadata: {}, + }; + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + registerService: () => undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir }); + await plugin.start(ctx); + await hooks.get('kernel:ready')?.(); + + // Counted from AFTER boot, deliberately: `kernel:ready` rehydrates the + // ledger, so a spy installed earlier would attribute the BOOT's read to the + // request under test and the "refused before any work" assertion would be + // measuring the wrong thing. + const ledgerReads = { count: 0 }; + const realList = LocalManifestSource.prototype.list; + vi.spyOn(LocalManifestSource.prototype, 'list').mockImplementation(function (this: any) { + ledgerReads.count += 1; + return realList.call(this); + }); + + return { list: rawApp.routes.get(`GET ${ROUTE_BASE}`)!, ledgerReads, ctx }; +} + +/** A Hono-ish context. Anonymous means: real headers, and nothing in them. */ +function makeC(headers: Record = {}) { + const h = new Headers(headers); + return { + req: { + url: `http://localhost:3000${ROUTE_BASE}`, + raw: new Request('http://localhost:3000/x', { headers: h }), + header: (n: string) => h.get(n) ?? undefined, + json: async () => ({}), + param: () => undefined, + }, + json: (payload: any, status?: number) => ({ payload, status: status ?? 200 }), + }; +} + +describe('#9011 — anonymous is refused 401 and learns nothing', () => { + it('answers the ADR-0112 UNAUTHENTICATED envelope, not the ledger', async () => { + // THE regression this file exists for: this exact request used to + // return 200 with every field below. + const { list } = await mount('anonymous', dir); + + const res = await list(makeC()); + + expect(res.status).toBe(401); + expect(res.payload).toMatchObject({ success: false, error: { code: 'UNAUTHENTICATED' } }); + // Nothing of the ledger rides along in the refusal body. + const serialized = JSON.stringify(res.payload); + expect(serialized).not.toContain('app.test.crm'); + expect(serialized).not.toContain('usr_operator'); + expect(serialized).not.toContain(dir); + }); + + it('a bare `x-user-id` header is still anonymous', async () => { + // #8976 removed the header fallback from the shared resolver; this pins + // that the read door inherits that removal rather than re-growing its + // own. A caller who could reach the port must not self-assert identity. + const { list } = await mount('anonymous', dir); + + const res = await list(makeC({ 'x-user-id': 'attacker' })); + + expect(res.status).toBe(401); + expect(res.payload.error.code).toBe('UNAUTHENTICATED'); + }); + + it('is refused BEFORE the ledger is read', async () => { + // A 401 issued after `readAll()` is still a probe: timing and storage + // errors both leak whether anything is installed. + const { list, ledgerReads } = await mount('anonymous', dir); + + const res = await list(makeC()); + + expect(res.status).toBe(401); + expect(ledgerReads.count).toBe(0); + }); +}); + +describe('#9011 — an authenticated non-operator gets the inventory WITHOUT the two operator fields', () => { + it('serves items and total, and omits `installedBy` / `storageDir`', async () => { + const { list } = await mount('member', dir); + + const res = await list(makeC()); + + // ① The half the ruling PRESERVES. Asserting only the absences below + // would keep passing if this caller were refused outright — the + // option the ruling rejected for withdrawing a shipped console page. + expect(res.status).toBe(200); + expect(res.payload.success).toBe(true); + expect(res.payload.data.total).toBe(1); + expect(res.payload.data.items).toHaveLength(1); + const [item] = res.payload.data.items; + expect(item).toMatchObject({ + packageId: 'pkg_crm', + versionId: 'pkgv_crm_1', + manifestId: 'app.test.crm', + version: '1.4.0', + installedAt: INSTALLED.installedAt, + withSampleData: true, + }); + + // ② The half it NARROWS — absent keys, not null values. `null` would be + // a claim about the ledger ("installed by nobody") rather than a fact + // about the caller. + expect(Object.keys(item).sort()).toEqual( + ['installedAt', 'manifestId', 'packageId', 'version', 'versionId', 'withSampleData'], + ); + expect('installedBy' in item).toBe(false); + expect(Object.keys(res.payload.data).sort()).toEqual(['items', 'total']); + expect('storageDir' in res.payload.data).toBe(false); + + // ③ And neither value reaches the wire by any other route. + const serialized = JSON.stringify(res.payload); + expect(serialized).not.toContain('usr_operator'); + expect(serialized).not.toContain(dir); + }); +}); + +describe('#9011 — a `manage_metadata` holder still gets the full payload', () => { + it('serves `installedBy` and `storageDir` unchanged', async () => { + // The control. Narrowing that also narrowed the operator would have + // broken `os package install`'s reason for #6721 putting the resolved + // directory on the wire at all. + const { list } = await mount('operator', dir); + + const res = await list(makeC()); + + expect(res.status).toBe(200); + expect(res.payload.success).toBe(true); + expect(res.payload.data.total).toBe(1); + expect(res.payload.data.storageDir).toBe(new LocalManifestSource(dir).dir); + const [item] = res.payload.data.items; + expect(item.installedBy).toBe('usr_operator'); + // The pre-#9011 wire shape, intact for the caller who is entitled to it. + expect(Object.keys(item).sort()).toEqual( + ['installedAt', 'installedBy', 'manifestId', 'packageId', 'version', 'versionId', 'withSampleData'], + ); + expect(Object.keys(res.payload.data).sort()).toEqual(['items', 'storageDir', 'total']); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index cea9588af1..454b08cde4 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -25,7 +25,11 @@ * the kernel's `manifest` service. Returns the installed entry. * * GET /api/v1/marketplace/install-local - * → lists currently installed marketplace packages + * → lists currently installed marketplace packages. Requires an + * authenticated principal (anonymous → 401); `installedBy` and + * `storageDir` are served only to a `manage_metadata` holder + * (#9011). The four routes above require `manage_metadata` + * outright (#8976). * * DELETE /api/v1/marketplace/install-local/:manifestId * → removes the cached manifest. Kernel must be restarted to fully @@ -791,8 +795,51 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * is explicitly NOT made here. What the fix removes is the case where a * short list was served with `success: true` and nobody, anywhere, could * have known. + * + * ## [#9011] Authenticated floor + field narrowing — the posture, ruled + * + * #8976 gated the four MUTATING doors and left this read as the only + * anonymous door on the surface: `handleList` opened on `this.readAll()`, + * resolved no identity at all, and served the whole ledger with `200` to + * anyone who could reach the port — 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: + * + * caller items total installedBy storageDir + * anonymous —— —— —— —— → 401 + * authenticated, no `manage_metadata` ✔ ✔ ✘ ✘ → 200 + * authenticated, `manage_metadata` ✔ ✔ ✔ ✔ → 200 + * + * **Why a floor and not the write doors' 403.** The console's Setup → + * "Installed Apps" page is a real, signed-in consumer that ships to + * non-operator users; demanding `manage_metadata` for the whole read would + * have withdrawn a shipped page rather than closed a hole. What is genuinely + * operator-grade is not "which packages are installed" — it is **who + * installed them and where they live on this host**, so those two fields, + * and only those two, follow the capability the write doors demand. + * + * **Omitted, not nulled.** A narrowed caller's entry simply has no + * `installedBy` key. `null` would be a claim about the ledger (installed by + * nobody) instead of a fact about the caller, and the console already + * renders that line conditionally. + * + * ⛔ The mount stays unconditional — cloud#1287 moved it out of the + * `marketplaceUrl` ternary precisely so air-gapped boxes stop 404ing. The + * answer to an unauthorized read is a refusal, never an absent route. + * + * Identity comes from {@link resolveInstallPrincipal} — the SAME resolver + * the four mutating doors use, not a second session read. Two auth + * mechanisms in one file is how the next gap gets created, and this file has + * already produced one. */ private handleList = async (c: any, ctx: PluginContext): Promise => { + // Before the ledger is touched, exactly as the mutating doors refuse + // before any work: a refused caller must not be able to learn what is + // installed from timing or from a downstream storage error. + const principal = await this.resolveInstallPrincipal(c, ctx); + if (!principal) return this.refuseUnauthenticated(c); + const operator = principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY); + const { entries, skipped } = this.readAll(); this.warnSkippedLedgerEntries(ctx, skipped, 'it is MISSING from the installed-apps list served to the console'); return c.json({ @@ -804,11 +851,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { manifestId: e.manifestId, version: e.version, installedAt: e.installedAt, - installedBy: e.installedBy, withSampleData: e.withSampleData ?? false, + ...(operator ? { installedBy: e.installedBy } : {}), })), total: entries.length, - storageDir: this.storageDir, + ...(operator ? { storageDir: this.storageDir } : {}), }, }, 200); }; @@ -1454,6 +1501,22 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } }; + /** + * [#9011] The ONE `401` this plugin issues — every door, one literal. + * + * The five routes now share an authenticated floor but NOT a capability + * requirement (the four writes demand `manage_metadata`; the read narrows + * two fields instead), so the "nobody is authenticated" refusal is the one + * answer they must give identically. Extracted rather than copied: a client + * branching on `UNAUTHENTICATED` must not have to learn which door it + * knocked on, and an envelope duplicated per handler is exactly how this + * file's five routes drifted apart on authorization in the first place. + */ + private refuseUnauthenticated = (c: any): Response => c.json({ + success: false, + error: { code: 'UNAUTHENTICATED', message: 'Authentication required.' }, + }, 401); + /** * [#8976] The shared refusal for the four mutating routes: 401 when nobody * is authenticated, 403 when somebody is but holds no authoring capability, @@ -1479,15 +1542,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { action: string, ): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> => { const principal = await this.resolveInstallPrincipal(c, ctx); - if (!principal) { - return { - ok: false, - response: c.json({ - success: false, - error: { code: 'UNAUTHENTICATED', message: 'Authentication required.' }, - }, 401), - }; - } + if (!principal) return { ok: false, response: this.refuseUnauthenticated(c) }; if (!principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY)) { ctx.logger?.warn?.( `[MarketplaceInstallLocal] refused ${action} for ${principal.userId} — ` From dbdb23969a9807b3b5f304523bd9c50202e80053 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 07:46:39 +0000 Subject: [PATCH 2/2] docs(changeset): the install-local listing auth floor and field narrowing (#9011) --- .../install-local-listing-auth-floor.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .changeset/install-local-listing-auth-floor.md diff --git a/.changeset/install-local-listing-auth-floor.md b/.changeset/install-local-listing-auth-floor.md new file mode 100644 index 0000000000..31fc5b76a2 --- /dev/null +++ b/.changeset/install-local-listing-auth-floor.md @@ -0,0 +1,85 @@ +--- +"@objectstack/cloud-connection": minor +--- + +fix(cloud-connection): the `install-local` listing requires an authenticated principal, and narrows `installedBy` / `storageDir` to `manage_metadata` holders (#9011) + + + +**BREAKING for any consumer that reads this route anonymously — it now answers `401` +— and for any authenticated non-operator consumer that reads `installedBy` or +`storageDir` from it.** Landing after the v17.0.0 cut, so it ships as `minor` under the +lockstep launch-window convention. + +`GET /api/v1/marketplace/install-local` — the console's Setup → "Installed Apps" list — +called **no** identity resolution whatsoever. `handleList`'s first statement read the +ledger. After #8976 capability-gated the four mutating doors on this surface, this was +the only anonymous door left on it: not a weaker gate, the absence of one, so any caller +who could reach the port received `200` and the complete payload. + +**What was disclosed.** Per ledger entry: `packageId`, `versionId`, `manifestId`, +`version`, `installedAt`, `installedBy`, `withSampleData`; once per response: `items`, +`total`, `storageDir`. + +- `installedBy` is a **platform user id**, and the listing enumerates them across every + install. +- `storageDir` is an **absolute filesystem path on the host** (#6721 put it on the wire + deliberately, for a *signed-in* CLI operator who cannot see the remote host's disk). +- The inventory itself is a version-level software bill of materials for the deployment + — which packages, at which versions, installed when. + +On the walled multi-org EE shape the inventory and the installer identities are +cross-tenant information, for the same reason #8976's write channel was: metadata is +environment-scoped, not org-scoped, so Layer 0's tenant wall does not scope this read +either. Severity is nonetheless lower than #8976's: this is read-only disclosure, not a +write channel. The measurement is a code-path measurement through a composed host, not +an exploit demonstrated against a running deployment. + +**The fix — authenticated floor plus field narrowing** (maintainer ruling 2026-08-16): + +| caller | status | `items` / `total` | `installedBy` | `storageDir` | +|:--|:--|:--|:--|:--| +| anonymous | **401 `UNAUTHENTICATED`** | — | — | — | +| authenticated, **no** `manage_metadata` | 200 | served | **omitted** | **omitted** | +| authenticated, `manage_metadata` | 200 | served | served | served | + +Splitting the payload rather than gating it whole is the point: "which packages are +installed here" and "who installed them and where they live on this host" are genuinely +different sensitivities. Demanding `manage_metadata` for the whole read would have +withdrawn a console page that ships to non-operator users today, and an authenticated +floor alone would have left the user ids and the host path on the wire for every signed-in +account. + +The two narrowed keys are **omitted, not nulled** — `null` would be a claim about the +ledger ("installed by nobody") instead of a fact about the caller. The console already +renders the "installed by" line conditionally and never reads `storageDir`, so a narrowed +caller sees the same list minus that one line. + +Identity is resolved by the **same** `resolveInstallPrincipal` the four mutating doors use +— `resolveAuthzContext`, the platform's single authorization resolver — not a second +session read; two auth mechanisms in one file is how the next gap gets created, and this +file has already produced one. The 401 envelope is extracted into one +`refuseUnauthenticated` seam shared by all five routes, so a client branching on +`UNAUTHENTICATED` never has to learn which door it knocked on. The read door inherits +#8976's removal of the `x-user-id` fallback: a bare header is still anonymous. + +**No new capability is minted** (#8919 discipline) — the narrowing reuses +`manage_metadata`, matching the `/meta` precedent. The plugin's mount stays +**unconditional** (cloud#1287 moved it out of the `marketplaceUrl` ternary so air-gapped +boxes stop 404ing); the answer to an unauthorized read is a refusal, never an absent +route, and the enumeration suite still asserts the GET is mounted. + +**Pinned.** `marketplace-install-local-list-posture.test.ts` pins all three rows above and +states, in its own docblock, that it is the file which answers "is the listing gated?" — +the sibling `capability-enumeration` suite answers that only for the mutating doors and +deliberately filters the GET out. The non-operator row is pinned in **both** directions +(the inventory is present *and* the two fields are absent), because asserting only the +absences would keep passing if that caller were refused outright — the option the ruling +rejected. The refusal asserts the ADR-0112 envelope (`code` **and** `status`) and that it +is issued **before** the ledger is read, so a refused caller cannot probe what is installed +through timing or a storage error.