From bd0ee2fbb634e7cdcd3c2afffb6258fd8f3a0942 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 06:31:29 +0000 Subject: [PATCH] feat(packages): GET /packages and GET /packages/:id rows carry the server's own writable verdict (#14375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0130 Consequences row 6, server half. Both read doors now stamp every package row with `writable: boolean`, computed by the SAME predicate the authoring and lifecycle gates enforce (`isWritablePackage`, ADR-0070 D2), so a client no longer derives it from `manifest.scope` alone — a rule that is not the server's, and that cannot tell a scope-less module booted from a multi-package artifact (read-only, in `engine.manifests`) from a scope-less Studio-created base (writable). - runtime `handlePackagesRequest`: list and detail decorate a spread copy of each registry record (`withWritableVerdict`). - metadata-protocol `getMetaItems({ type: 'package' })`: the producer the REST `GET /packages` door spreads its registry half from decorates the same records the same way; REST itself has no runtime dependency on metadata-protocol and computes nothing. - Pins: the four shapes the predicate distinguishes on both doors, the additive/no-mutation negative, and the REST spread-order carry. - `content/docs/permissions/system-context.mdx`: one line-number re-anchor from `check-system-context-census --fix`; no prose changed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- .../packages-read-door-writable-verdict.md | 30 +++ content/docs/permissions/system-context.mdx | 2 +- .../get-meta-items-package-writable.test.ts | 135 +++++++++++ packages/metadata-protocol/src/protocol.ts | 22 ++ .../src/package-list-writable-carry.test.ts | 115 ++++++++++ .../domains/packages-writable-verdict.test.ts | 216 ++++++++++++++++++ packages/runtime/src/domains/packages.ts | 39 +++- 7 files changed, 556 insertions(+), 3 deletions(-) create mode 100644 .changeset/packages-read-door-writable-verdict.md create mode 100644 packages/metadata-protocol/src/get-meta-items-package-writable.test.ts create mode 100644 packages/rest/src/package-list-writable-carry.test.ts create mode 100644 packages/runtime/src/domains/packages-writable-verdict.test.ts diff --git a/.changeset/packages-read-door-writable-verdict.md b/.changeset/packages-read-door-writable-verdict.md new file mode 100644 index 0000000000..0e6de01d4b --- /dev/null +++ b/.changeset/packages-read-door-writable-verdict.md @@ -0,0 +1,30 @@ +--- +"@objectstack/runtime": patch +"@objectstack/metadata-protocol": patch +--- + +feat(packages): `GET /packages` and `GET /packages/:id` rows carry the server's own `writable` verdict (#14375) + +ADR-0130 Consequences row 6, server half. Every package row served by the two +read doors now carries `writable: boolean`, computed by the SAME predicate the +authoring and lifecycle gates already enforce — `isWritablePackage` (ADR-0070 +D2) — so a client no longer has to guess it. + +- **Why.** Studio's package switcher derived "writable" client-side from + `manifest.scope` alone (`scope !== 'project'`). That is not the server's + rule: `isWritablePackage` reads `engine.manifests` FIRST, so a package booted + from an artifact through `registerApp` is read-only whatever its scope says — + and a scope-less `type: module` carried by a multi-package artifact lands + there too. A scope-less Studio-created database base is writable. The client + cannot see `engine.manifests`, so it cannot tell those two apart; the server + can, and now says so (#8146: one answer to "is this package writable?"). +- **Where.** The runtime dispatcher door (`handlePackagesRequest`, list and + detail) decorates its read of the registry records; the metadata protocol's + `getMetaItems({ type: 'package' })` — the producer the REST `GET /packages` + door spreads its registry half from — decorates the same records the same + way. Both are spread COPIES: the registry's own records are never mutated and + the verdict is never stored. +- **Additive.** No existing key changes; no accept/reject surface moves. A REST + row that has no registry presence (durable-only) carries no verdict, and the + REST detail door's database-first row does not either — the registry item is + the only carrier, by design. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index be7a90a32f..4e6d88b690 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -160,7 +160,7 @@ The largest single consumer — **20 of the 109 sites**. | 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:4629`, `:5992`, `:6240`, `:6671`, `:6864` | | 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` | +| 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:276`, `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` | | 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:95`, `:128` | diff --git a/packages/metadata-protocol/src/get-meta-items-package-writable.test.ts b/packages/metadata-protocol/src/get-meta-items-package-writable.test.ts new file mode 100644 index 0000000000..961b4912b4 --- /dev/null +++ b/packages/metadata-protocol/src/get-meta-items-package-writable.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `getMetaItems({ type: 'package' })` stamps the server's OWN writability + * verdict on every package item (#14375, ADR-0130 Consequences row 6 — the + * producer the REST `GET /packages` door spreads its registry half from). + * + * The verdict is `isWritablePackage` (ADR-0070 D2), the same predicate the + * authoring (`saveMetaItem`) and lifecycle (`DELETE` / `disable`) gates already + * enforce — #8146's "one answer to 'is this package writable?'" applied to the + * read side. It reads `engine.manifests` FIRST: a package booted from an + * artifact through `registerApp` is read-only whatever its scope says, and a + * scope-less `type: module` carried by a multi-package artifact lands there + * too, while a scope-less Studio-created base does not. Only the server holds + * `engine.manifests`, which is why the client could never derive this. + * + * The engine is the same shape `meta-overlay-cache.test.ts` drives: the + * registry surface this method touches, a `find` that answers the overlay + * query with nothing, and — the subject here — a `manifests` map. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** Booted code package, explicit `scope: 'project'`. */ +const CODE_PROJECT = 'app.acme.crm'; +/** Booted, SCOPE-LESS module — the multi-package-artifact sub-package. */ +const CODE_MODULE = 'app.acme.crm.billing'; +/** Platform / marketplace delivered. */ +const SYSTEM_SCOPED = 'com.objectstack.platform'; +const CLOUD_SCOPED = 'com.objectstack.cloudpack'; +/** Studio-created database base: installed, never booted, scope-less. */ +const DB_BASE = 'com.acme.mybase'; + +type Row = { manifest: Record; status: string; enabled: boolean }; + +function row(id: string, extra: Record = {}): Row { + return { manifest: { id, name: id, version: '1.0.0', ...extra }, status: 'installed', enabled: true }; +} + +function make() { + const records: Row[] = [ + row(CODE_PROJECT, { scope: 'project', type: 'app' }), + row(CODE_MODULE, { type: 'module' }), + row(SYSTEM_SCOPED, { scope: 'system' }), + row(CLOUD_SCOPED, { scope: 'cloud' }), + row(DB_BASE), + ]; + const byId = new Map(records.map((r) => [r.manifest.id as string, r])); + // What `ObjectQL.registerApp` records for every package of a loaded artifact. + const manifests = new Map([ + [CODE_PROJECT, byId.get(CODE_PROJECT)!.manifest], + [CODE_MODULE, byId.get(CODE_MODULE)!.manifest], + ]); + const engine: any = { + manifests, + find: async () => [], + registry: { + listItems: (type: string) => (type === 'package' ? records : []), + getPackage: (id: string) => byId.get(id), + getItem: () => undefined, + getObject: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()); + return { protocol, records }; +} + +async function listPackages(protocol: ObjectStackProtocolImplementation) { + const res = await protocol.getMetaItems({ type: 'package' }); + return res.items as Array; +} + +const pick = (items: Array, id: string) => { + const it = items.find((p) => p.manifest.id === id); + if (!it) throw new Error(`item ${id} missing`); + return it; +}; + +describe('getMetaItems({ type: "package" }) carries the writable verdict (#14375)', () => { + it('pin 1: a booted code package with scope "project" is writable: false', async () => { + const items = await listPackages(make().protocol); + expect(pick(items, CODE_PROJECT).writable).toBe(false); + }); + + it('pin 2: a booted, SCOPE-LESS module is writable: false — the row itself carries no scope', async () => { + const items = await listPackages(make().protocol); + const it_ = pick(items, CODE_MODULE); + expect(it_.manifest.scope).toBeUndefined(); + expect(it_.writable).toBe(false); + }); + + it('pin 3: system- and cloud-scoped packages are writable: false', async () => { + const items = await listPackages(make().protocol); + expect(pick(items, SYSTEM_SCOPED).writable).toBe(false); + expect(pick(items, CLOUD_SCOPED).writable).toBe(false); + }); + + it('pin 4: a SCOPE-LESS database base (never booted) is writable: true', async () => { + const items = await listPackages(make().protocol); + const it_ = pick(items, DB_BASE); + expect(it_.manifest.scope).toBeUndefined(); + expect(it_.writable).toBe(true); + }); + + it('pins 2 + 4: the two scope-less rows differ ONLY in the verdict — the scope cannot tell them apart', async () => { + const items = await listPackages(make().protocol); + expect(pick(items, CODE_MODULE).manifest.scope).toBe(pick(items, DB_BASE).manifest.scope); + expect(pick(items, CODE_MODULE).writable).toBe(false); + expect(pick(items, DB_BASE).writable).toBe(true); + }); + + it('pin 5: additive and computed — the served row minus `writable` equals the registry record, which is never mutated', async () => { + const { protocol, records } = make(); + const items = await listPackages(protocol); + expect(items).toHaveLength(records.length); + for (const record of records) { + const { writable, ...rest } = pick(items, record.manifest.id as string); + expect(typeof writable).toBe('boolean'); + expect(rest).toEqual(record); + expect('writable' in record).toBe(false); + } + }); + + it('does not leak onto other types: an `app` listing gains no `writable` key', async () => { + const { protocol } = make(); + const res = await protocol.getMetaItems({ type: 'app' }); + for (const it_ of res.items as Array>) { + expect('writable' in it_).toBe(false); + } + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fa53e5ac6b..6d4d5c1c94 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -7198,6 +7198,28 @@ export class ObjectStackProtocolImplementation implements items = (items as any[]).map((app) => this.engine.registry.applyNavContributions(app)); } + // [#14375 / ADR-0130 Consequences row 6] A package row carries the + // server's OWN writability verdict. The REST `GET /packages` door is + // this producer with the durable rows spread over it, and Studio's + // package switcher reads that list; it used to derive "writable" + // client-side from `manifest.scope` alone, which is not this server's + // rule — ADR-0070 D2 (`isWritablePackage`) reads `engine.manifests` + // FIRST, so a scope-less module booted from a multi-package artifact is + // read-only while a scope-less Studio-created base is writable, and only + // the server can tell the two apart. Same predicate the authoring and + // lifecycle gates use (#8146: one answer), computed on a spread COPY: + // the registry record is never mutated and the verdict is never stored. + // The runtime dispatcher door decorates its own read of the same + // records the same way (`withWritableVerdict` in + // `packages/runtime/src/domains/packages.ts`). + if (request.type === 'package' || request.type === 'packages') { + items = (items as any[]).map((pkg) => { + const manifestId = pkg?.manifest?.id; + const id = typeof manifestId === 'string' ? manifestId : (typeof pkg?.id === 'string' ? pkg.id : undefined); + return { ...pkg, writable: this.isWritablePackage(id) }; + }); + } + return { type: request.type, items: decorateMetadataItems( diff --git a/packages/rest/src/package-list-writable-carry.test.ts b/packages/rest/src/package-list-writable-carry.test.ts new file mode 100644 index 0000000000..142822326a --- /dev/null +++ b/packages/rest/src/package-list-writable-carry.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * REST `GET /packages` CARRIES the producer's `writable` verdict (#14375). + * + * The REST list door does not compute writability itself — it must not: this + * package has no runtime dependency on `@objectstack/metadata-protocol`, and + * the verdict has one definition (`isWritablePackage`, ADR-0070 D2) that the + * protocol's `getMetaItems({ type: 'package' })` now stamps on every registry + * item. What THIS door owns is the merge: the durable (`PackageService.list()`) + * row is spread OVER the registry item, so a durable row that carries no + * `writable` key must leave the registry item's verdict standing, and a + * durable-only row (no registry presence) carries no verdict at all. Both are + * pinned here, because the spread order is the one place this file could lose + * the field. + */ + +import { describe, it, expect } from 'vitest'; +import type { RouteHandler } from '@objectstack/spec/contracts'; +import { registerPackageRoutes } from './package-routes.js'; + +type Captured = { status: number; body: any }; + +function mount(svc: any, protocol: any) { + const routes = new Map(); + const server = { + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, + post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); }, + put: () => {}, + delete: (p: string, h: RouteHandler) => { routes.set(`DELETE:${p}`, h); }, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + } as any; + // The authorization gate (#7033 / #7023) is not this file's subject. + registerPackageRoutes(server, () => svc, '/api/v1', { + resolveExecutionContext: async () => ({ + userId: 'u_pkg', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }), + protocol, + }); + return routes; +} + +async function drive(routes: Map, method: string, path: string, req: Record = {}): Promise { + const handler = routes.get(`${method}:${path}`); + if (!handler) throw new Error(`no handler for ${method} ${path}`); + const captured: Captured = { status: 200, body: undefined }; + const res: any = { + json(data: any) { captured.body = data; }, + send() {}, + status(code: number) { captured.status = code; return res; }, + header() { return res; }, + }; + await handler({ params: {}, query: {}, body: undefined, headers: {}, method, path, ...req } as any, res); + return captured; +} + +/** The scope-less booted module — the producer says read-only. */ +const MODULE = 'app.acme.crm.billing'; +/** A scope-less Studio base — the producer says writable; it also has a durable row. */ +const BASE = 'com.acme.mybase'; +/** A durable-only row: published, never registered on this process. */ +const DURABLE_ONLY = 'com.acme.published-elsewhere'; + +const registryItems = [ + { manifest: { id: MODULE, name: MODULE, version: '1.0.0', type: 'module' }, status: 'installed', enabled: true, writable: false }, + { manifest: { id: BASE, name: BASE, version: '1.0.0' }, status: 'installed', enabled: true, writable: true }, +]; +const protocol = { getMetaItems: async () => ({ type: 'package', items: registryItems }) }; +const svc = { + list: async () => [ + // The durable row for BASE has NO `writable` key — a durable copy is not + // where the verdict lives. + { id: BASE, version: '1.0.0', manifest: { id: BASE, name: BASE, version: '1.0.0' } }, + { id: DURABLE_ONLY, version: '3.0.0', manifest: { id: DURABLE_ONLY, version: '3.0.0' } }, + ], +}; + +const rowOf = (body: any, id: string) => body.data.packages.find((p: any) => (p.manifest?.id ?? p.id) === id); + +describe('REST GET /packages carries the producer\'s writable verdict through the merge (#14375)', () => { + it('a registry-only row keeps the producer\'s verdict', async () => { + const r = await drive(mount(svc, protocol), 'GET', '/api/v1/packages'); + expect(r.status).toBe(200); + const row = rowOf(r.body, MODULE); + expect(row.source).toBe('registry'); + expect(row.writable).toBe(false); + }); + + it('a durable row spread over a registry item does NOT erase the verdict (spread order)', async () => { + const r = await drive(mount(svc, protocol), 'GET', '/api/v1/packages'); + const row = rowOf(r.body, BASE); + expect(row.source).toBe('both'); + // The durable row carried no `writable`; the registry item's stands. + expect(row.writable).toBe(true); + }); + + it('a durable-only row carries no verdict — the registry item is the only carrier', async () => { + const r = await drive(mount(svc, protocol), 'GET', '/api/v1/packages'); + const row = rowOf(r.body, DURABLE_ONLY); + expect(row.source).toBe('database'); + expect('writable' in row).toBe(false); + }); + + it('is additive: nothing else about the merged rows changed', async () => { + const r = await drive(mount(svc, protocol), 'GET', '/api/v1/packages'); + expect(r.body.data.total).toBe(3); + const { writable, ...rest } = rowOf(r.body, MODULE); + expect(writable).toBe(false); + const { writable: _producerVerdict, ...producerItem } = registryItems[0]; + expect(rest).toEqual({ ...producerItem, source: 'registry' }); + }); +}); diff --git a/packages/runtime/src/domains/packages-writable-verdict.test.ts b/packages/runtime/src/domains/packages-writable-verdict.test.ts new file mode 100644 index 0000000000..6e7b933b76 --- /dev/null +++ b/packages/runtime/src/domains/packages-writable-verdict.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/packages` read doors carry the server's OWN writability verdict (#14375, + * ADR-0130 Consequences row 6 — server half). + * + * ## The defect this pins against + * + * Studio's package switcher derived "writable" CLIENT-side from + * `manifest.scope` alone (`scope !== 'project'`). The server's rule is + * `isWritablePackage` (ADR-0070 D2), and it is a different predicate: it reads + * `engine.manifests` FIRST — a package booted from an artifact through + * `registerApp` is read-only whatever its scope says — and only then the + * `system` / `cloud` scopes. The two rules split on exactly the row ADR-0130 + * introduces: a scope-less `type: module` carried by a multi-package artifact + * is in `engine.manifests` (read-only) while a scope-less Studio-created base + * is not (writable). Nothing in the raw row distinguishes them; `engine.manifests` + * does, and only the server holds it. So the server says it. + * + * ## What is asserted + * + * The verdict on the wire, per row and per door, for the four shapes the + * predicate distinguishes — and, as the negative, that the rows are otherwise + * byte-for-byte what they were: the registry's own records untouched, no other + * key added or changed. The tests drive a REAL {@link SchemaRegistry} (the + * records are what `installPackage` actually produces) through the real + * dispatcher, with `manifests` standing in for the engine's boot map exactly as + * `packages-readonly-gate.test.ts` does next door. + */ + +import { describe, it, expect } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +// ── the four shapes ────────────────────────────────────────────────────────── +/** Pin 1 — booted code package, explicit `scope: 'project'` (today's hotcrm shape). */ +const CODE_PROJECT = 'app.acme.crm'; +/** + * Pin 2 — booted code package with NO scope key: the `type: 'module'` sub-package a + * multi-package artifact carries (ADR-0130 D4/D5). The raw body is what the + * load path registers (D7), so the row has no `scope` at all. THE row #14375 + * exists for: the client heuristic said "writable"; the server says read-only. + */ +const CODE_MODULE = 'app.acme.crm.billing'; +/** Pin 3 — platform-delivered (`system`) and marketplace-delivered (`cloud`). */ +const SYSTEM_SCOPED = 'com.objectstack.platform'; +const CLOUD_SCOPED = 'com.objectstack.cloudpack'; +/** + * Pin 4 — a Studio-created database base: installed through `installPackage` + * only (never `registerApp`), and scope-less. Writable. This is the row a + * client-side "missing scope → read-only" rule would have broken. + */ +const DB_BASE = 'com.acme.mybase'; +/** A project-scoped DB base — also writable (ADR-0070: the org owns it). */ +const DB_PROJECT = 'com.acme.myproject'; + +function manifest(id: string, extra: Record = {}) { + return { id, name: id, version: '1.0.0', ...extra } as any; +} + +/** + * A dispatcher over a real `SchemaRegistry`. `objectql` stands in for the + * engine: the route reads `.registry` off it and the ADR-0070 predicate reads + * `.manifests` — the same two handles the authoring side asks for. + */ +function make() { + const registry = new SchemaRegistry({ logLevel: 'silent' } as any); + registry.installPackage(manifest(CODE_PROJECT, { scope: 'project', type: 'app' })); + registry.installPackage(manifest(CODE_MODULE, { type: 'module' })); + registry.installPackage(manifest(SYSTEM_SCOPED, { scope: 'system' })); + registry.installPackage(manifest(CLOUD_SCOPED, { scope: 'cloud' })); + registry.installPackage(manifest(DB_BASE)); + registry.installPackage(manifest(DB_PROJECT, { scope: 'project' })); + + // Only the two code packages booted from an artifact — this is what + // `ObjectQL.registerApp` records for every package of a loaded artifact. + const manifests = new Map([ + [CODE_PROJECT, manifest(CODE_PROJECT, { scope: 'project', type: 'app' })], + [CODE_MODULE, manifest(CODE_MODULE, { type: 'module' })], + ]); + + const objectql = { registry, manifests }; + const kernel: any = { + context: { getService: (name: string) => (name === 'objectql' ? objectql : null) }, + }; + return { dispatcher: new HttpDispatcher(kernel), registry }; +} + +/** Holds the ADR-0106 D4 read set; the caller gate is not this file's subject. */ +const reader = (): any => ({ + request: {}, + environmentId: 'pkg-writable-verdict-test', + executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: ['manage_metadata', 'studio.access'] }, +}); + +type Row = { manifest: { id: string }; writable?: boolean } & Record; + +async function list(dispatcher: HttpDispatcher, query: Record = {}): Promise { + const r = await dispatcher.handlePackages('/', 'GET', undefined, query, reader()); + expect(r.response?.status ?? 200).toBe(200); + return r.response?.body?.data?.packages as Row[]; +} + +const byId = (rows: Row[], id: string): Row => { + const row = rows.find((p) => p.manifest?.id === id); + if (!row) throw new Error(`row ${id} missing from listing`); + return row; +}; + +// ══════════════════════════════════════════════════════════════════════════════ +// 1. The verdict, per shape — GET /packages +// ══════════════════════════════════════════════════════════════════════════════ + +describe('GET /packages — every row carries the server\'s writable verdict (#14375)', () => { + it('pin 1: a booted code package with scope "project" is writable: false', async () => { + const rows = await list(make().dispatcher); + expect(byId(rows, CODE_PROJECT).writable).toBe(false); + }); + + it('pin 2: a booted, SCOPE-LESS module (multi-package artifact sub-package) is writable: false', async () => { + const rows = await list(make().dispatcher); + const row = byId(rows, CODE_MODULE); + // The raw row really has no scope — the verdict is not coming from it. + expect((row.manifest as any).scope).toBeUndefined(); + expect(row.writable).toBe(false); + }); + + it('pin 3: system- and cloud-scoped packages are writable: false', async () => { + const rows = await list(make().dispatcher); + expect(byId(rows, SYSTEM_SCOPED).writable).toBe(false); + expect(byId(rows, CLOUD_SCOPED).writable).toBe(false); + }); + + it('pin 4: a SCOPE-LESS database base (installed, never booted) is writable: true', async () => { + const rows = await list(make().dispatcher); + const row = byId(rows, DB_BASE); + expect((row.manifest as any).scope).toBeUndefined(); + expect(row.writable).toBe(true); + // …and so is a project-scoped one the org owns. + expect(byId(rows, DB_PROJECT).writable).toBe(true); + }); + + it('pins 2 + 4 together: the two scope-less rows are told apart — which no scope-only rule can do', async () => { + const rows = await list(make().dispatcher); + const module_ = byId(rows, CODE_MODULE); + const base = byId(rows, DB_BASE); + expect((module_.manifest as any).scope).toBe((base.manifest as any).scope); // both undefined + expect(module_.writable).not.toBe(base.writable); + }); + + it('carries the verdict through the ?type= filter too', async () => { + const rows = await list(make().dispatcher, { type: 'module' }); + expect(rows.map((p) => p.manifest.id)).toEqual([CODE_MODULE]); + expect(rows[0].writable).toBe(false); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 2. Same verdict, same predicate — GET /packages/:id +// ══════════════════════════════════════════════════════════════════════════════ + +describe('GET /packages/:id — the detail door carries the same verdict (#14375)', () => { + it('pin 2 on the detail door: the scope-less booted module is writable: false', async () => { + const r = await make().dispatcher.handlePackages(`/${CODE_MODULE}`, 'GET', undefined, {}, reader()); + expect(r.response?.status ?? 200).toBe(200); + expect(r.response?.body?.data?.manifest?.id).toBe(CODE_MODULE); + expect(r.response?.body?.data?.writable).toBe(false); + }); + + it('pin 4 on the detail door: the scope-less database base is writable: true', async () => { + const r = await make().dispatcher.handlePackages(`/${DB_BASE}`, 'GET', undefined, {}, reader()); + expect(r.response?.status ?? 200).toBe(200); + expect(r.response?.body?.data?.writable).toBe(true); + }); + + it('an unknown id is still a 404 — the verdict never becomes an existence oracle', async () => { + const r = await make().dispatcher.handlePackages('/com.nobody.here', 'GET', undefined, {}, reader()); + expect(r.response?.status).toBe(404); + }); +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// 3. The negative — nothing else about the rows changed, and nothing was stored +// ══════════════════════════════════════════════════════════════════════════════ + +describe('GET /packages — the verdict is additive and computed, never stored (#14375)', () => { + it('pin 5: apart from `writable`, every row deep-equals the registry\'s own record', async () => { + const { dispatcher, registry } = make(); + const rows = await list(dispatcher); + const records = registry.getAllPackages() as unknown as Row[]; + expect(rows).toHaveLength(records.length); + for (const record of records) { + const { writable, ...rest } = byId(rows, record.manifest.id); + expect(typeof writable).toBe('boolean'); + // Built from the pre-change shape (the registry record itself), not + // re-derived from the new code. + expect(rest).toEqual(record); + } + }); + + it('pin 5b: the registry\'s own records never gain a `writable` key (spread copy, no mutation)', async () => { + const { dispatcher, registry } = make(); + await list(dispatcher); + await dispatcher.handlePackages(`/${CODE_MODULE}`, 'GET', undefined, {}, reader()); + for (const record of registry.getAllPackages() as unknown as Row[]) { + expect('writable' in record).toBe(false); + } + expect('writable' in (registry.getPackage(CODE_MODULE) as any)).toBe(false); + }); + + it('`total` still counts the rows served', async () => { + const r = await make().dispatcher.handlePackages('/', 'GET', undefined, {}, reader()); + expect(r.response?.body?.data?.total).toBe(6); + expect(r.response?.body?.data?.packages).toHaveLength(6); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index a14fd85d59..dbae972119 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -225,6 +225,36 @@ function requireWritablePackage( * 500 and `fields[]` was still dropped. Route new handlers through the shared * helper rather than re-deriving the status here. */ +/** + * [#14375 / ADR-0130 Consequences row 6] Decorate one registry row with the + * server's OWN writability verdict. + * + * Studio's package switcher used to derive "writable" client-side from + * `manifest.scope` alone (`scope !== 'project'`). That is not the rule this + * server enforces: {@link isWritablePackage} (ADR-0070 D2) reads + * `engine.manifests` FIRST — a package booted from an artifact through + * `registerApp` is read-only whatever its scope says, and a scope-less module + * carried by a multi-package artifact (ADR-0130 D4/D5) lands there too. The + * client cannot see `engine.manifests`, so it cannot tell that module + * (read-only) from a scope-less Studio-created base (writable); only the + * server can, so the server says it — with the SAME predicate the authoring + * and lifecycle gates use, which is #8146's ruling ("one answer to 'is this + * package writable?'") applied to the read door. + * + * A spread COPY: the registry's own record is never mutated and the verdict is + * never stored — it is a property of the running engine, recomputed per read. + * The row is keyed the way the registry keys it (`manifest.id`, falling back + * to a bare `id`), so the verdict is asked about the same package the row is. + */ +function withWritableVerdict( + engine: unknown, + row: T, +): T & { writable: boolean } { + const manifestId = row?.manifest?.id; + const id = typeof manifestId === 'string' ? manifestId : (typeof row?.id === 'string' ? row.id : undefined); + return { ...row, writable: isWritablePackage(engine, id) }; +} + export async function handlePackagesRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise { const m = method.toUpperCase(); @@ -274,7 +304,11 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (query?.type) { packages = packages.filter((p: any) => p.manifest?.type === query.type); } - return { handled: true, response: deps.success({ packages, total: packages.length }) }; + // [#14375] Every row carries the server's own writability verdict + // (see `withWritableVerdict`) — copies, so the registry records the + // filters above selected stay untouched. + const rows = packages.map((p: any) => withWritableVerdict(qlService, p)); + return { handled: true, response: deps.success({ packages: rows, total: rows.length }) }; } // POST /packages → install package. @@ -849,7 +883,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const id = decodeURIComponent(parts[0]); const pkg = registry.getPackage(id); if (!pkg) return { handled: true, response: deps.error(`Package '${id}' not found`, 404) }; - return { handled: true, response: deps.success(pkg) }; + // [#14375] Same verdict, same predicate as the list door. + return { handled: true, response: deps.success(withWritableVerdict(qlService, pkg)) }; } // PATCH /packages/:id → edit the manifest (name / description /