From fef09c1e675c04f356de00be12fcd747d3b07348 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 08:25:33 +0000 Subject: [PATCH 1/3] feat(rest): mount the compound-name per-item promotion door `POST /meta/:type/:section/:name/publish` (#11932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A compound-named metadata draft could be STAGED through `PUT /meta/:type/:section/:name?mode=draft` (#11712 / PR #11933) and READ BACK through `GET /meta/:type/:section/:name/published` (#7526), and had no per-item REST door to PROMOTE it: `POST /meta/:type/:name/publish` was mounted in one arity only. Writable, readable, not publishable — same caller, same transport. The capability was never missing. Measured against the real `ObjectStackProtocolImplementation` on a seeded `crm/task` draft before the route existed, `publishMetaItem` promoted it normally: it keys the draft on type/name/organization/package and reads the name's spelling nowhere. It was the route that was missing. Both arities now come out of one two-entry registration loop, the same shape the `/published` read twin has carried since #7526, so the compound door inherits the `manage_metadata` gate, the `?package` binding and its repeated-parameter refusal, the write-actor resolution, the organization scoping, the `404 [no_draft]` answer and the `501` envelope unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .changeset/compound-meta-door-mode-draft.md | 18 +- .changeset/compound-meta-publish-door.md | 50 ++ .../kernel/contracts/metadata-service.mdx | 7 +- .../src/meta-compound-publish-door.test.ts | 505 ++++++++++++++++++ .../src/meta-route-registration-order.test.ts | 139 +++++ ...-write-door-capability-enumeration.test.ts | 11 + packages/rest/src/rest-route-ledger.ts | 2 + packages/rest/src/rest-server.ts | 407 +++++++------- ...-response-internal-fields.tripwire.test.ts | 3 + scripts/engine-double-contract.pinned.json | 10 + 10 files changed, 962 insertions(+), 190 deletions(-) create mode 100644 .changeset/compound-meta-publish-door.md create mode 100644 packages/rest/src/meta-compound-publish-door.test.ts diff --git a/.changeset/compound-meta-door-mode-draft.md b/.changeset/compound-meta-door-mode-draft.md index 511d300334..1e80ecb44d 100644 --- a/.changeset/compound-meta-door-mode-draft.md +++ b/.changeset/compound-meta-door-mode-draft.md @@ -28,13 +28,17 @@ unrecognised `mode=`, an empty `mode=` and no `mode` at all are all unchanged: they publish, exactly as before. The spelling test is the twin's, `draft` case-insensitive. -⚠️ **The draft you can now stage has no per-item REST promotion door in this -arity.** `POST /meta/:type/:name/publish` is mounted for single-segment names -only, while its read twin `GET /meta/:type/:section/:name/published` is mounted -for both — so a compound-named draft is writable and readable over REST and not -promotable there. Until that route exists, promote through -`POST /packages/:id/publish-drafts` (whole-package) or the runtime dispatcher's -own `meta.publish` verb. Tracked in #11932; this release does not change it. +The draft you can now stage **is** promotable per item over REST, as of the +sibling entry in this same release: `POST /meta/:type/:section/:name/publish` +is mounted (#11932). This paragraph used to say the opposite — that the +promotion door existed for single-segment names only, so a compound-named draft +was writable and readable over REST and not promotable there — and it was true +when this entry was written. It is corrected here rather than left standing, +because both entries compile into one release and a reader would otherwise be +told in one paragraph that the door does not exist and in the next that it does. +`POST /packages/:id/publish-drafts` (whole-package) and the runtime dispatcher's +own `meta.publish` verb remain available and unchanged; they were never the +per-item door. **2. A repeated `?mode` is now REFUSED where it was accepted.** This narrows what the door takes. `?mode=draft&mode=draft` arrives as an array; the diff --git a/.changeset/compound-meta-publish-door.md b/.changeset/compound-meta-publish-door.md new file mode 100644 index 0000000000..213fb1e011 --- /dev/null +++ b/.changeset/compound-meta-publish-door.md @@ -0,0 +1,50 @@ +--- +'@objectstack/rest': minor +--- + +feat(rest): mount the compound-name per-item promotion door `POST /api/v1/meta/:type/:section/:name/publish` + +**A new public route.** `POST /api/v1/meta/:type/:section/:name/publish` — four +segments after `/meta`, the compound-name arity of the per-item promotion door +that has been mounted as `POST /api/v1/meta/:type/:name/publish` all along. Both +arities now come out of one two-entry registration loop, exactly as the ADR-0033 +read twin `GET /api/v1/meta/:type/:section/:name/published` has since #7526. + +**What it now makes possible: promoting a compound-named draft over REST, per +item.** A metadata item addressed by a compound name — `views/all_leads`, +`crm/task`, the spelling the SDK documents for `getPublished('lead', +'views/all_leads')` — could already be **staged** +(`PUT /api/v1/meta/:type/:section/:name?mode=draft`, shipped in the +`?mode=draft` entry beside this one) and **read back** +(`GET /api/v1/meta/:type/:section/:name/published`). It could not be promoted: +no registered route matched the four-segment promotion path, so the request +reached the transport's `notFound` and answered `404`, byte-identical to a path +that does not exist. The draft was writable, readable, and not publishable, by +the same caller, over the same transport. + +| Request | Before | After | +| --- | --- | --- | +| `POST /meta/object/crm/task/publish` | `404` — no registered route matched | `200`, the staged body is now the live overlay | +| `POST /meta/object/crm_task/publish` | `200` | `200` — unchanged | + +The workarounds that entry named remain available and are unchanged: +`POST /packages/:id/publish-drafts` promotes a whole package's drafts at once, +and the runtime dispatcher's own `meta.publish` verb is reachable without any +REST route. What they were not is a **per-item** door. + +**Nothing below the route changed, and no accept set widened anywhere else.** +`publishMetaItem` keys the draft on type/name/organization/package and reads the +name's spelling nowhere, so a compound name was always a valid draft key — this +release mounts the route that had been missing, it does not add a capability. +The two arities share one handler, so the compound door inherits, unchanged: +the `manage_metadata` authoring gate (ADR-0066 D1), the `?package=` binding and +its repeated-parameter refusal, the `X-Actor`-ignoring write-actor resolution, +the organization scoping, the `404 [no_draft]` answer when nothing is staged, +and the `501` envelope when a kernel does not implement promotion. The +single-segment door's behaviour is untouched in every one of those respects. + +**For SDK callers there is no API change — only a route that now answers.** +`client.meta.publishItem(type, name)` already built this URL: it interpolates +the name unencoded and its own documentation says "Compound names pass through +unencoded, like `getItem`". Calling it with a compound name returned `404` +before this release and promotes the draft after it. diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index 617e1b29fe..53b322e029 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -420,7 +420,11 @@ const draft = await metadataService.get('object', 'opportunity'); The REST layer mounts package routes under `/api/v1/packages` and per-item metadata routes under `/api/v1/meta`. Publishing a single metadata item's pending draft is done -via the `/meta/:type/:name/publish` route. +via the `/meta/:type/:name/publish` route — or, for an item addressed by a compound +name such as `views/all_leads`, via the compound arity +`/meta/:type/:section/:name/publish`. Both arities are one handler and behave +identically; the compound spelling is how every other read and write on this surface +addresses a sub-resource. | Method | Path | Description | |:---|:---|:---| @@ -429,4 +433,5 @@ via the `/meta/:type/:name/publish` route. | `GET` | `/api/v1/packages/:id` | Get a specific package | | `DELETE` | `/api/v1/packages/:id` | Delete a package | | `POST` | `/api/v1/meta/:type/:name/publish` | Promote a metadata item's pending draft to live | +| `POST` | `/api/v1/meta/:type/:section/:name/publish` | The same promotion, for a compound-named item | | `POST` | `/api/v1/meta/:type/:name/rollback` | Restore a historical version as the live overlay | diff --git a/packages/rest/src/meta-compound-publish-door.test.ts b/packages/rest/src/meta-compound-publish-door.test.ts new file mode 100644 index 0000000000..64e561b43a --- /dev/null +++ b/packages/rest/src/meta-compound-publish-door.test.ts @@ -0,0 +1,505 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11932] `POST /api/v1/meta/:type/:section/:name/publish` — the compound-name + * PROMOTION door, the last missing arity in the compound-name lifecycle. + * + * ## The defect, as measured on `origin/main` at `1e79aa4f8` + * + * Read off the REAL registered server (`new RestServer(...).registerRoutes()`, + * then every `/meta` route from `getRoutes()`), the arities did not line up: + * + * ``` + * PUT /api/v1/meta/:type/:name mounted + * PUT /api/v1/meta/:type/:section/:name mounted ← stages, since #11933 + * GET /api/v1/meta/:type/:name/published mounted + * GET /api/v1/meta/:type/:section/:name/published mounted ← #7526 + * POST /api/v1/meta/:type/:name/publish mounted + * POST /api/v1/meta/:type/:section/:name/publish NOT MOUNTED + * ``` + * + * So a compound-named draft could be STAGED (`?mode=draft`, #11712 / PR #11933) + * and READ BACK (`/published`, #7526) and had no per-item REST door to PROMOTE. + * Writable, readable, not publishable — same caller, same transport. #11933's + * own changeset says so to consumers in as many words: *"Until that route + * exists, promote through `POST /packages/:id/publish-drafts` … Tracked in + * #11932."* + * + * ## It was the ROUTE that was missing, never the capability + * + * Measured before this route existed, driving the real + * `ObjectStackProtocolImplementation` against a seeded `crm/task` draft: + * + * ``` + * STORE before: r_draft name=crm/task state=draft + * PROMOTE -> {"success":true,"version":"sha256:382ea457…","seq":1, + * "message":"Published draft — type=object, name=crm/task [seq=1]"} + * ``` + * + * `publishMetaItem` keys the draft on type/name/organization/package and reads + * the name's SPELLING nowhere, so `crm/task` is a draft key exactly like + * `crm_task` is. That is the same finding #11933 recorded for the save door. + * + * ## Why the REAL protocol and not a double + * + * #11712's defect was a door that ACCEPTED a parameter and answered `200` while + * doing the wrong thing, so a status-only assertion cannot see this class. Every + * ⭐ case below reads the STORE: which row is live, which is staged, and what + * body each carries. The gate is the real `ObjectStackProtocolImplementation` + * over a `sys_metadata`-backed fake engine, exactly as + * `meta-compound-save-mode-parity.test.ts` next door. + * + * ⚠️ That import resolves through `exports` to `@objectstack/metadata-protocol`'s + * **`dist/`** (registered in `check-test-source-alias.mjs`'s + * `KNOWN_UNALIASED_TEST_IMPORTS` for this package), so this suite is a verdict + * about the BUILT protocol. Rebuild it before reading a result here after + * touching `protocol.ts`. Nothing in THIS card's diff is in that package — the + * change is one `for` loop in `rest-server.ts`, which vitest compiles from + * source — so the ablation below measures the source it edits. + * + * ## Where the ORDER is pinned + * + * In `meta-route-registration-order.test.ts`, with the rest of the `/meta` + * family's first-match-wins constraints and the measurement that a same-arity + * sibling registered ahead of this pattern really does shadow it. + */ + +import { describe, it, expect, vi } from 'vitest'; +// `.js` on purpose — NodeNext resolution requires the extension (#7248). +import { RestServer } from './rest-server.js'; +import { assertEngineUpdateDispatch, assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; + +const META = '/api/v1/meta'; +const COMPOUND_PUBLISH = `${META}/:type/:section/:name/publish`; +const SINGLE_PUBLISH = `${META}/:type/:name/publish`; +const COMPOUND_PUT = `${META}/:type/:section/:name`; + +/** The compound URL `section` + `name` spell, and its single-segment twin. */ +const COMPOUND_NAME = 'crm/task'; +const SINGLE_NAME = 'crm_task'; + +/** What the seeded LIVE row carries before anything is promoted. */ +const LIVE_LABEL = 'Live label'; +/** What the seeded DRAFT row carries — a promotion must make THIS live. */ +const DRAFT_LABEL = 'Staged label'; + +/** + * A spec-valid `object` body. `sharingModel` is not decoration: ADR-0090 D1's + * author-time gate refuses an unset OWD (`security-owd-unset`), and without it + * the publish would fail a phase before the one under test — a red that reads + * exactly like "the route did not work". The field set never moves between the + * live and draft bodies, so nothing here trips a destructive-change gate. + */ +const objectBody = (label: string) => ({ + name: SINGLE_NAME, + label, + sharingModel: 'private', + fields: Object.fromEntries(['a', 'b'].map((f) => [f, { name: f, type: 'text', label: f }])), +}); + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(), + }; + return res; +} + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum: string; + version: number; +} + +/** + * Boot both promotion doors over the REAL protocol against ONE store, seeded so + * both names carry a LIVE row and a DRAFT row. The single-segment twin is a + * control in every case rather than a separate suite, because "the two doors + * agree" is the claim (#7019). + */ +function boot(opts?: { capabilities?: string[]; seedDrafts?: boolean }) { + const rows = new Map(); + let nextId = 0; + const seed = (id: string, name: string, state: string, label: string) => rows.set(id, { + id, type: 'object', name, + organization_id: null, package_id: null, state, + metadata: JSON.stringify(objectBody(label)), + checksum: `sha256_11932_${state}`, version: 1, + }); + seed('live_compound', COMPOUND_NAME, 'active', LIVE_LABEL); + seed('live_single', SINGLE_NAME, 'active', LIVE_LABEL); + if (opts?.seedDrafts !== false) { + seed('draft_compound', COMPOUND_NAME, 'draft', DRAFT_LABEL); + seed('draft_single', SINGLE_NAME, 'draft', DRAFT_LABEL); + } + + /** + * Scalar equality ONLY, every combinator REFUSED rather than approximated — + * `pnpm check:where-matcher` (`scripts/check-where-matcher-conformance.mjs`, + * #8494): a `$and` silently falling through to `r['$and']` compares + * `undefined` against an array, excludes the row, and returns an empty + * result set with nothing erroring — a suite can go green while asserting + * about a DIFFERENT query than the one the protocol sent. + */ + const match = (r: any, where: Record): boolean => { + for (const k of Object.keys(where ?? {})) { + if (k.startsWith('$')) { + throw new Error(`fake engine: unsupported logical operator ${k}`); + } + } + return Object.entries(where ?? {}).every(([k, v]) => + v === null || v === undefined + ? r[k] === null || r[k] === undefined + : r[k] === v, + ); + }; + + const engine: any = { + async find(table: string, o?: { where?: Record }) { + if (table !== 'sys_metadata') return []; + return [...rows.values()].filter((r) => match(r, o?.where ?? {})); + }, + async findOne(table: string, o: { where: Record }) { + if (table !== 'sys_metadata') return null; + for (const r of rows.values()) if (match(r, o?.where ?? {})) return r; + return null; + }, + async insert(table: string, data: Record) { + const id = String(data.id ?? `ins_${nextId++}`); + if (table === 'sys_metadata') rows.set(id, { ...(data as any), id } as StoredRow); + return { id }; + }, + // ⛔ Routed through the producer-side predicates, never hand-mirrored: a + // double looser than `ObjectQL` turns a green suite into no suite + // (`check:engine-double-contract`, #4550 / #5480). + async update(_t: string, data: Record, o?: Record) { + assertEngineUpdateDispatch(data, o); + const id = (o as any)?.where?.id; + const existing = id ? rows.get(String(id)) : undefined; + if (existing) rows.set(String(id), { ...existing, ...(data as any) }); + return { id: id ?? null }; + }, + // ⚠️ This one REALLY deletes, unlike the sibling suite's no-op. A + // promotion retires the draft row it promoted, and "no draft is left + // behind" is half of what `outcome()` below reports — a double that + // acknowledges the delete without performing it would report every + // promotion as "published AND still staged", which is nothing that ever + // happens and would make the parity tuple unreadable. + async delete(_t: string, o?: Record) { + assertEngineDeleteDispatch(o); + const where = (o as any)?.where ?? {}; + let deleted = 0; + for (const [id, r] of [...rows.entries()]) { + if (match(r, where)) { rows.delete(id); deleted += 1; } + } + return { deleted }; + }, + registry: { + registerItem: () => {}, registerObject: () => {}, listItems: () => [], + getItem: () => undefined, getArtifactItem: () => undefined, + removeRuntimeShadow: () => false, removeOverlayEntry: () => {}, uninstallPackage: () => {}, + }, + }; + + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map()); + + /** + * Every request the doors hand the protocol, recorded at the seam. The store + * answers "what did the promotion DO"; this answers "under what key" — and + * for this card the key is the point: the compound door must hand + * `publishMetaItem` ONE opaque `crm/task`, not two fields. + */ + const seen: any[] = []; + const realPublish = protocol.publishMetaItem.bind(protocol); + protocol.publishMetaItem = async (request: any) => { seen.push(request); return realPublish(request); }; + const realSave = protocol.saveMetaItem.bind(protocol); + protocol.saveMetaItem = async (request: any) => realSave(request); + + const rest = new RestServer( + mockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + ); + // `manage_metadata` held by default — the #8919 capability gate is pinned + // explicitly in §3 and must not be what silently answers everywhere else. + (rest as any).resolveExecCtx = async () => ({ + userId: 'u_author', + systemPermissions: opts?.capabilities ?? ['manage_metadata'], + }); + rest.registerRoutes(); + + const route = (method: string, path: string) => (rest as any).getRoutes().find( + (r: any) => r.method === method && r.path === path, + ); + + const call = async ( + method: string, path: string, + params: Record, + query: Record = {}, + body: unknown = {}, + ) => { + const found = route(method, path); + expect(found, `${method} ${path} is not registered at all — the door cannot serve`).toBeTruthy(); + const res = mockRes(); + await found!.handler({ params, query, headers: {}, body }, res); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; + + /** The body a row of one lifecycle state carries — read from the STORE. */ + const labelOf = (name: string, state: string): string | undefined => { + for (const r of rows.values()) { + if (r.name === name && r.state === state) return JSON.parse(r.metadata)?.label; + } + return undefined; + }; + + return { + seen, + /** + * The pair this file turns on, for one name: what the LIVE row carries + * and what (if anything) is still STAGED beside it. A tuple so §5 can + * compare the two doors without either side naming an outcome. + */ + outcome: (name: string) => [labelOf(name, 'active'), labelOf(name, 'draft')] as const, + compoundOutcome: () => [labelOf(COMPOUND_NAME, 'active'), labelOf(COMPOUND_NAME, 'draft')] as const, + singleOutcome: () => [labelOf(SINGLE_NAME, 'active'), labelOf(SINGLE_NAME, 'draft')] as const, + /** ⭐ The door this card mounts. */ + compoundPublish: (query: Record = {}, body: unknown = {}) => + call('POST', COMPOUND_PUBLISH, { type: 'object', section: 'crm', name: 'task' }, query, body), + /** Its single-segment twin — mounted since long before this card. */ + singlePublish: (query: Record = {}, body: unknown = {}) => + call('POST', SINGLE_PUBLISH, { type: 'object', name: SINGLE_NAME }, query, body), + /** The compound SAVE door (#11933), for the end-to-end lifecycle in §6. */ + compoundPut: (query: Record = {}, label = 'Edited through PUT') => + call('PUT', COMPOUND_PUT, { type: 'object', section: 'crm', name: 'task' }, query, objectBody(label)), + rows, + }; +} + +/** What the store looks like once a draft was PROMOTED: live replaced, nothing staged. */ +const PROMOTED = [DRAFT_LABEL, undefined]; +/** What it looks like when nothing happened: live untouched, draft still staged. */ +const UNTOUCHED = [LIVE_LABEL, DRAFT_LABEL]; + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. ⭐ The door exists, and it PROMOTES — the case that cannot even run +// without the route +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11932] POST /meta/:type/:section/:name/publish — the compound promotion door', () => { + it('⭐ is registered at all', () => { + const stack = boot(); + // Pre-fix this threw inside `call()` with "is not registered at all". + // Registration is necessary and NOT sufficient — everything below is + // about what it then does. (#7526's lesson: a mounted-but-shadowed or + // stubbed route answers a plausible 200.) + expect(stack.compoundPublish).toBeTypeOf('function'); + }); + + it('⭐ makes the staged compound body LIVE — read from the store, not from the status', async () => { + const stack = boot(); + + expect(stack.compoundOutcome()).toEqual(UNTOUCHED); + + const answer = await stack.compoundPublish(); + + expect(answer.status).toBe(200); + // ⛔ The status is NOT the pin. #11712's whole defect was a door that + // answered 200 while doing the wrong thing. THIS is the claim: the body + // that was staged is now the live body, and nothing is left staged. + expect(stack.compoundOutcome()).toEqual(PROMOTED); + }); + + it('⭐ hands the protocol ONE opaque compound key assembled from the two segments', async () => { + const stack = boot(); + + await stack.compoundPublish(); + + // The seam. `
/` is one protocol key — the same assembly + // the `/published` READ twin and the compound `getItem`/`saveItem` doors + // perform. A door that forwarded `name: 'task'` would promote a + // DIFFERENT item, or nothing, and could still answer 200. + expect(stack.seen).toHaveLength(1); + expect(stack.seen[0].name).toBe(COMPOUND_NAME); + expect(stack.seen[0].type).toBe('object'); + }); + + it('⭐ does not touch the single-segment twin\'s rows — one name, not both', async () => { + const stack = boot(); + + await stack.compoundPublish(); + + // Same fixture, two names, one store: promoting `crm/task` must leave + // `crm_task` exactly as it was. A door that dropped the `section` + // segment would promote the twin instead and still answer 200. + expect(stack.compoundOutcome()).toEqual(PROMOTED); + expect(stack.singleOutcome()).toEqual(UNTOUCHED); + }); + + it('reports the promotion in its answer body too', async () => { + const stack = boot(); + + const answer = await stack.compoundPublish(); + + expect(answer.body?.success).toBe(true); + // The ADR-0008 optimistic-concurrency token the SDK's `publishItem` + // documents as its return value — present, so a caller can echo it as + // `If-Match` on the next write to the compound-named item. + expect(typeof answer.body?.version).toBe('string'); + // The receipt names the item under the key it was promoted by, which is + // what an audit query on this type will later match. + expect(String(answer.body?.message)).toContain(COMPOUND_NAME); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. ⛔ It is a real door, not a stub — the #7526 disguise, refused +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11932 / #7526] the compound door can FAIL, which a stub could not', () => { + it('⛔ 404s when the compound name has no draft staged', async () => { + const stack = boot({ seedDrafts: false }); + + const answer = await stack.compoundPublish(); + + // #7526's two defects were routes that answered a plausible 200 for a + // name that does not exist. A door that can never refuse is a door that + // proves nothing when it succeeds. + expect(answer.status).toBe(404); + expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. ⛔ [#7019 / #8919] The new arity is NOT a bypass of the capability gate +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11932 / #8919] the compound door demands `manage_metadata` too', () => { + it('⛔ 403s an authenticated principal holding no authoring capability, and promotes nothing', async () => { + const stack = boot({ capabilities: [] }); + + const answer = await stack.compoundPublish(); + + // #7019's finding, inherited: gating only the single-segment door leaves + // the compound one as a bypass of it. Mounting a new arity is exactly + // the moment that can be reintroduced, so it is pinned rather than + // assumed from "it is the same handler". + expect(answer.status).toBe(403); + expect(answer.body?.error?.code).toBe('FORBIDDEN'); + // The gate runs BEFORE the protocol is resolved, so nothing was promoted + // and nothing was even asked of the protocol. + expect(stack.seen).toHaveLength(0); + expect(stack.compoundOutcome()).toEqual(UNTOUCHED); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. [#6877] The repeated-parameter guard reaches the new arity +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11932 / #6877] a repeated `?package` is refused at the compound door', () => { + it('⛔ `?package=a&package=b` is a 400 and promotes nothing', async () => { + const stack = boot(); + + const answer = await stack.compoundPublish({ package: ['pkg_a', 'pkg_b'] }); + + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(stack.seen).toHaveLength(0); + expect(stack.compoundOutcome()).toEqual(UNTOUCHED); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. ⭐ [#7019] The twins agree — literal-free on BOTH sides +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11932 / #7019] the two publish doors answer the same way', () => { + /** + * ⛔ Deliberately literal-free on both sides: these cases assert that the + * two doors AGREE, never what they agree on. §1–§4 name the outcomes; this + * section names only the equality, so a future move on EITHER door reddens + * here independently of whichever literal the sections above happen to pin. + * (#11731 §4 / #11933 §5 are the precedent.) + * + * The single-segment door is untouched by this card and is the control: its + * behaviour is asserted rather than assumed, which is the only shape in + * which "the twins agree" is a pin instead of a comment. + */ + it.each([ + { label: 'a plain promotion', query: {}, capabilities: undefined as string[] | undefined, seedDrafts: true }, + { label: 'a promotion carrying `?package`', query: { package: 'pkg_a' }, capabilities: undefined, seedDrafts: true }, + { label: 'a repeated `?package`', query: { package: ['a', 'b'] }, capabilities: undefined, seedDrafts: true }, + { label: 'nothing staged to promote', query: {}, capabilities: undefined, seedDrafts: false }, + { label: 'no authoring capability held', query: {}, capabilities: [] as string[], seedDrafts: true }, + ])('⭐ $label: same status, same error code, same store outcome at both doors', async ({ query, capabilities, seedDrafts }) => { + const compoundStack = boot({ capabilities, seedDrafts }); + const singleStack = boot({ capabilities, seedDrafts }); + + const compound = await compoundStack.compoundPublish(query); + const single = await singleStack.singlePublish(query); + + // 1. The answer, read off the other door rather than written down. + expect(compound.status).toBe(single.status); + expect(compound.body?.success).toBe(single.body?.success); + expect(compound.body?.error?.code).toBe(single.body?.error?.code); + // 2. What the promotion actually DID, as `[live label, staged label]`. + expect(compoundStack.compoundOutcome()).toEqual(singleStack.singleOutcome()); + }); + + it('and the twin is UNTOUCHED — its protocol request is what it always was', async () => { + const stack = boot(); + + await stack.singlePublish(); + + // The fence. This card mounts a second arity; it must not have edited + // the door that was already right. + expect(stack.seen).toHaveLength(1); + expect(stack.seen[0]).toMatchObject({ type: 'object', name: SINGLE_NAME }); + expect(stack.singleOutcome()).toEqual(PROMOTED); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 6. ⭐ The lifecycle #11933's changeset says is impossible today +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11932 + #11712] stage a compound draft over REST, then promote it over REST', () => { + it('⭐ PUT ?mode=draft → POST /publish leaves the edited body LIVE', async () => { + const stack = boot({ seedDrafts: false }); + const EDITED = 'Edited through PUT'; + + const staged = await stack.compoundPut({ mode: 'draft' }, EDITED); + expect(staged.status).toBe(200); + // Staged, not published — #11933's guarantee, restated as this card's + // precondition rather than trusted. + expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, EDITED]); + + const promoted = await stack.compoundPublish(); + expect(promoted.status).toBe(200); + + // The whole point of the card, in one line: the draft a caller staged + // over REST is now the live body, promoted over REST, per item. Before + // this route the second call had no door and the only way out was + // `POST /packages/:id/publish-drafts` (whole-package) or the runtime + // dispatcher's own `meta.publish` verb. + expect(stack.compoundOutcome()).toEqual([EDITED, undefined]); + }); +}); diff --git a/packages/rest/src/meta-route-registration-order.test.ts b/packages/rest/src/meta-route-registration-order.test.ts index c07eef4aa2..1bec2ebaa9 100644 --- a/packages/rest/src/meta-route-registration-order.test.ts +++ b/packages/rest/src/meta-route-registration-order.test.ts @@ -21,6 +21,17 @@ * function can silently undo it, and the failure is a plausible 200 rather * than an error. * + * [#11932] The `POST` half arrived later and is pinned here too. Its constraint + * is REAL but currently LATENT, and the difference is stated rather than + * blurred, because a reader who assumes the `GET` story applies verbatim will + * draw the wrong conclusion from a green run: `/meta` mounts NO `POST` + * catch-all (measured, and pinned below as a list), so nothing on the live + * table can shadow the compound promotion door today. What IS measured against + * a real Hono app is that a same-arity sibling registered ahead of it DOES + * shadow it — so the constraint is one bad edit away rather than theoretical, + * and reachability is probed against the live router instead of inferred from + * a position in the table. + * * WHY THIS AND NOT ONLY THE PARITY GATE. The dogfood parity gate * (`route-ledger-live-mount-parity.dogfood.test.ts`) catches the same breakage * against a real booted server, and it is the stronger check. This one is @@ -31,6 +42,13 @@ import { describe, it, expect, vi } from 'vitest'; import { RestServer } from './rest-server.js'; +// [#11932] The REAL router, not a model of one. `resolveMountedRoute` is the +// same live-router observation the dogfood parity gate reads (#7526 rule 2: +// registration is not reachability), and it is what turns "is the pattern in +// the table" into "which registration would actually answer this path". +// `vitest.config.ts` aliases this specifier to the sibling's SOURCE, so no +// build artifact sits on the path these cases measure. +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; function createMockServer() { return { @@ -136,6 +154,127 @@ describe('/meta registration order', () => { } }); + it('mounts BOTH arities of the per-item promotion door (#11932)', () => { + const order = metaRoutesInOrder(); + for (const key of [ + 'POST /api/v1/meta/:type/:name/publish', + 'POST /api/v1/meta/:type/:section/:name/publish', + ]) { + expect( + order, + `${key} is not registered — a compound-named draft can be staged ` + + '(PUT ?mode=draft, #11712) and read back (/published, #7526) with no per-item door to promote it', + ).toContain(key); + } + }); + + it('registers the compound promotion door immediately beside its single-segment twin', () => { + const order = metaRoutesInOrder(); + // Adjacency is #7019's ruling made structural: the two arities come out of + // ONE two-entry loop, so a divergence between them has to be written + // deliberately rather than drifted into. If a later edit splits them apart + // this reddens, and that is the moment to re-read #7019. + expect(indexOf(order, 'POST /api/v1/meta/:type/:section/:name/publish')) + .toBe(indexOf(order, 'POST /api/v1/meta/:type/:name/publish') + 1); + }); + + it('⛔ `/meta` mounts no POST catch-all — the premise the ordering cases below rest on', () => { + // Measured rather than assumed, and pinned as a LIST so it cannot rot + // quietly: only a SAME-arity pattern can absorb a five-segment `/meta` + // path, and none of these is one. If this list grows such a pattern, the + // latent constraint has become live and the reachability cases below stop + // being a demonstration and start being the thing under test. + expect(metaRoutesInOrder().filter((k) => k.startsWith('POST '))).toEqual([ + 'POST /api/v1/meta/_migrate-stored', + 'POST /api/v1/meta/:type/:name/publish', + 'POST /api/v1/meta/:type/:section/:name/publish', + 'POST /api/v1/meta/:type/:name/rollback', + ]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// [#11932] REACHABILITY, over the real router — the half `metaRoutesInOrder` +// structurally cannot answer. +// +// `getRoutes()` reports what was REGISTERED. On a first-match-wins router that +// is necessary and not sufficient (#7526 rule 2), so these cases boot the real +// `HonoHttpServer`, hand it the real `RestServer` registration sequence, and +// ask the LIVE router which pattern would answer a concrete path. +// ═══════════════════════════════════════════════════════════════════════════ + +/** The real adapter, carrying the real registration order. */ +function liveRouter(): HonoHttpServer { + const hono = new HonoHttpServer(0); + const rest = new RestServer(hono as any, createCapableProtocol() as any, {} as any); + rest.registerRoutes(); + return hono; +} + +describe('[#11932] the compound promotion door is REACHABLE, not merely registered', () => { + it('⭐ a compound-named promotion resolves to the compound pattern', () => { + expect(liveRouter().resolveMountedRoute('POST', '/api/v1/meta/object/crm/task/publish')) + .toEqual({ method: 'POST', pattern: '/api/v1/meta/:type/:section/:name/publish' }); + }); + + it('and the single-segment spelling still resolves to its OWN pattern', () => { + // The fence: mounting a second arity must not move the first one's answer. + // Both are asserted, so a change that widened one by narrowing the other + // reddens here rather than passing as "the compound case works". + expect(liveRouter().resolveMountedRoute('POST', '/api/v1/meta/object/crm_task/publish')) + .toEqual({ method: 'POST', pattern: '/api/v1/meta/:type/:name/publish' }); + }); + + it('⭐ the instrument returns a NEGATIVE for a path this build does not mount', () => { + // The canary. Without it, "the compound publish path resolves" could be a + // probe that resolves everything. `POST …/rollback` is the same shape one + // door over and is mounted in ONE arity only, so the live router answers + // `undefined` for its compound spelling — which is exactly what the publish + // door answered before this card. + // + // ⛔ Not a ruling that the rollback door should stay single-arity. It is + // this suite's control; the asymmetry it records is filed separately. + expect(liveRouter().resolveMountedRoute('POST', '/api/v1/meta/object/crm/task/rollback')) + .toBeUndefined(); + }); + + it('⛔ shows what a wrong-place registration would do — first-match-wins, measured', async () => { + // The hazard demonstrated on the real router rather than asserted in a + // comment. A same-arity sibling registered AHEAD of the promotion door + // answers the promotion path instead — with its own 200 body, the #7526 + // disguise, not an error anyone would notice. + const wrong = new HonoHttpServer(0); + wrong.post('/api/v1/meta/:type/:section/:name/:verb', ((_q: any, res: any) => res.json({ answered: 'sibling' })) as any); + wrong.post('/api/v1/meta/:type/:section/:name/publish', ((_q: any, res: any) => res.json({ answered: 'publish' })) as any); + + expect(wrong.resolveMountedRoute('POST', '/api/v1/meta/object/crm/task/publish')) + .toEqual({ method: 'POST', pattern: '/api/v1/meta/:type/:section/:name/:verb' }); + expect(await (await wrong.getRawApp().request('/api/v1/meta/object/crm/task/publish', { method: 'POST' })).json()) + .toEqual({ answered: 'sibling' }); + + // The same two registrations, the other way round. + const right = new HonoHttpServer(0); + right.post('/api/v1/meta/:type/:section/:name/publish', ((_q: any, res: any) => res.json({ answered: 'publish' })) as any); + right.post('/api/v1/meta/:type/:section/:name/:verb', ((_q: any, res: any) => res.json({ answered: 'sibling' })) as any); + + expect(await (await right.getRawApp().request('/api/v1/meta/object/crm/task/publish', { method: 'POST' })).json()) + .toEqual({ answered: 'publish' }); + }); + + it('a DIFFERENT-arity sibling cannot absorb it, however it is ordered', async () => { + // Why the constraint is latent today rather than violated: only a + // SAME-arity pattern can match this path. Registered first, the compound + // three-param `POST` still does not answer the promotion path. + const arity = new HonoHttpServer(0); + arity.post('/api/v1/meta/:type/:section/:name', ((_q: any, res: any) => res.json({ answered: 'compound' })) as any); + arity.post('/api/v1/meta/:type/:section/:name/publish', ((_q: any, res: any) => res.json({ answered: 'publish' })) as any); + + expect(arity.resolveMountedRoute('POST', '/api/v1/meta/object/crm/task/publish')) + .toEqual({ method: 'POST', pattern: '/api/v1/meta/:type/:section/:name/publish' }); + }); +}); + +describe('/meta registration order — the retired plural', () => { it('no longer registers the plural FSM state read (#9180 step 2)', () => { // The retirement is the ruling's substance, so it is pinned as a fact // about the mount table rather than left to the ledger's prose: the diff --git a/packages/rest/src/meta-write-door-capability-enumeration.test.ts b/packages/rest/src/meta-write-door-capability-enumeration.test.ts index 155ac81343..68d52c57f3 100644 --- a/packages/rest/src/meta-write-door-capability-enumeration.test.ts +++ b/packages/rest/src/meta-write-door-capability-enumeration.test.ts @@ -113,6 +113,17 @@ const DOORS: readonly Door[] = [ method: 'POST', path: `${META}/:type/:name/publish`, protocolMethod: 'publishMetaItem', params: { type: 'object', name: 'account' }, body: {}, }, + { + // [#11932] The compound-name arity of the door above, mounted from the + // same two-entry loop. It is enumerated here rather than trusted to + // "same handler, same gate": this file exists because the gate used to + // be a convention held by repetition, and a second arity is precisely + // the kind of edit that can split one handler into two. + label: 'POST /meta/:type/:section/:name/publish — compound-name promote [#11932]', + method: 'POST', path: `${META}/:type/:section/:name/publish`, + protocolMethod: 'publishMetaItem', + params: { type: 'object', section: 'views', name: 'all_leads' }, body: {}, + }, { label: 'POST /meta/:type/:name/rollback — restore a historical version [#8919]', method: 'POST', path: `${META}/:type/:name/rollback`, diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 4c05f0f3e3..5ad13ad116 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -180,6 +180,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAudit' }, { route: 'POST /api/v1/meta/:type/:name/publish', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.publishItem', note: 'per-item ADR-0033 publish; packages.publishDrafts remains the package-scoped flow' }, + { route: 'POST /api/v1/meta/:type/:section/:name/publish', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.publishItem', + note: '[#11932] compound-name arity of the row above, mounted from the same two-entry loop in `registerMetadataEndpoints`. It closes the last hole in the compound-name lifecycle: `PUT /:type/:section/:name?mode=draft` STAGES (#11712/PR #11933), `GET /:type/:section/:name/published` READS (#7526), and until this row there was no per-item door to PROMOTE — writable, readable, and not publishable by the same caller over the same transport. The capability was never missing: `publishMetaItem` keys the draft on type/name/organization/package and reads the name\'s spelling nowhere, measured against the real protocol on a seeded `crm/task` draft before the route existed. `meta.publishItem` already CONSTRUCTED this URL — its own doc comment says "Compound names pass through unencoded, like getItem" — so the SDK promised a door the server had not mounted, and the #3642 URL guard could not see it because the compound name is interpolated into the same `${name}` slot the single-arity pattern declares' }, { route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.rollbackItem' }, { route: 'GET /api/v1/meta/:type/:name/diff', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.diffItem' }, // [#7526] The two routes that were ledgered in `runtime/src/route-ledger.ts` diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 74e0b3975b..6c0244df15 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -6057,189 +6057,232 @@ export class RestServer { // POST /meta/:type/:name/publish — promote the pending draft // overlay to live. 404 [no_draft] when nothing to publish. - this.routeManager.register({ - method: 'POST', - path: `${metaPath}/:type/:name/publish`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // [#8919] Authoring capability gate — the SAME four lines the - // `PUT` / `DELETE` / `_migrate-stored` doors carry, deliberately - // not a second way of demanding the same capability. - // - // Promotion is authoring. `promoteDraftForPublish` flips the - // `sys_metadata` row `state: 'draft'` → `'active'`, and - // ADR-0027 (E)(5) defines sealing a publish as exactly that - // flip — so this door decides which body is LIVE. Measured - // before the gate: an authenticated principal holding no - // authoring capability at all reached `publishMetaItem` and - // got 200, i.e. it could take a draft somebody else authored - // and make it the live overlay. The `/meta` umbrella already - // refused ANONYMOUS here (401, `registerMetadataEndpoints`), - // so this closes the authenticated-but-uncapable cohort — the - // one the four sibling doors close and these two did not. - // - // ⛔ Not a publish-specific capability: `manage_metadata` is - // ADR-0066 D1's authoring capability and the same one the save - // door demands, so no caller who can author a draft is newly - // refused (measured: the save→publish loop's own first step is - // already gated on it). Splitting author from publisher would - // need a DIFFERENT declared capability and is a product call. - // - // Gate FIRST — before the protocol is resolved — so an - // unauthorized caller cannot use the 501-vs-200 answer to probe - // which kernels implement publishing, and so nothing is promoted - // before the refusal. `isSystem` bypasses, matching every other - // capability gate on the platform. - const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); - const held = new Set( - Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], - ); - if (!ctx?.isSystem && !held.has('manage_metadata')) { - res.status(403).json({ - error: { - code: 'FORBIDDEN', - message: 'Publishing a metadata item requires the `manage_metadata` capability.', - }, - }); - return; - } - const p = await this.resolveProtocol(environmentId, req); - if (!p.publishMetaItem) { - res.status(501).json({ - error: 'Publish operation not supported by protocol implementation', - }); - return; - } - // [#7749 producer, #7941 precedence] The request's authenticated - // identity — one producer, shared by every `/meta` write (see - // resolveMetaWriteActor). `X-Actor` is not consulted. - const actor = await this.resolveMetaWriteActor(environmentId, req); - const body = (req.body && typeof req.body === 'object') ? req.body : {}; - const message = typeof body.message === 'string' ? body.message : undefined; - - // [#10063] Software-package binding for the PROMOTION — - // `?package=`, deliberately the SAME wire spelling and the - // same normalisation the `PUT` door states it with a few - // hundred lines up, not a second dialect for one value. - // - // Why this door needed it at all. #9612 taught the runtime - // publish gate to narrow `objects` to the written item's - // package closure, but only when the caller can NAME the - // package. Three write doors reach that gate; `saveMetaItem` - // and `publishPackageDrafts` both name one, and this one — - // the single-item draft→active promotion — named nothing. So - // every HTTP-driven promotion, which is precisely Studio's - // designer save→publish loop on every edit, handed the gate - // the whole tenant. The protocol half was already built and - // waiting: `promoteDraftForPublish` declares - // `packageId?: string | null` and threads it into both the - // gate and `repo.promoteDraft`. Only the caller was mute. - // - // ⚠️ THE SHARP EDGE, and the reason this is a conditional - // spread rather than a plain key. `promoteDraftForPublish` - // forwards to `repo.promoteDraft` with - // `...('packageId' in request ? { packageId: request.packageId ?? null } : {})` - // — it branches on the KEY BEING PRESENT, not on the value, - // because `null` is a meaningful scope there (pin the lookup - // to the UNBOUND row) while an absent key means "match any - // package", the historical resolution. Writing - // `packageId: packageId` here would therefore put a - // present-and-`undefined` key on every publish that names no - // package, coercing it to `null` downstream and pinning the - // lookup to unbound rows — so a draft authored under a package - // would stop being found and the door would answer `no_draft`. - // That is a silent outage on the untouched path, produced by a - // change that reads like it only ADDS an option. The key must - // be ABSENT when the caller states nothing. - // - // ⛔ Not read off the draft row either — see - // `promoteDraftForPublish`'s own warning: `rowToItem` projects - // `sys_metadata` into a `MetadataItem`, which carries no - // package id, so a read from there is `undefined` on every - // path — narrowing that never fires while looking like it - // does. Widening `MetadataItem` is a `packages/spec` contract - // change and stays filed rather than taken here. - if (refuseRepeatedQueryParams(req, res, ['package'])) return; - const packageRaw = req.query?.package; - const packageId = typeof packageRaw === 'string' && packageRaw && packageRaw !== 'all' - ? packageRaw - : undefined; + // + // [#11932] BOTH ARITIES, and the loop is the point rather than a + // tidy-up: the ADR-0033 `/published` READ a few hundred lines down is + // mounted in both spellings (#7526) and the `getItem`/`saveItem` twins + // are too, so a compound-named draft could be STAGED through + // `PUT /meta/:type/:section/:name?mode=draft` (#11712, PR #11933) and + // READ back through `GET /meta/:type/:section/:name/published` — and + // then had no per-item REST door to PROMOTE it. Writable, readable, + // and not publishable, by the same caller, over the same transport. + // + // ⛔ It was never the CAPABILITY that was missing, which is why this is + // a mount and not a feature: `publishMetaItem` keys the draft on + // type/name/organization/package and reads the name's SPELLING + // nowhere, so `crm/task` is a draft key exactly like `crm_task` is. + // Measured against the real protocol over a seeded compound draft + // before this route existed — it promoted normally. + // + // ORDER. The hazard the `/published` twin documents below is REAL for + // this shape and is pinned in `meta-route-registration-order.test.ts`: + // Hono is first-match-wins, so a same-arity sibling registered ahead of + // this pattern shadows it (measured — a `/:type/:section/:name/:verb` + // catch-all registered first answers `…/publish` instead). What differs + // from the READ side, and is worth stating because a reader will assume + // otherwise: `/meta` has NO `POST` catch-all today, so nothing on the + // live table can absorb this path — the constraint is latent, not + // currently violated, and the pin exists so it stays that way. + for (const publishPath of [ + `${metaPath}/:type/:name/publish`, + `${metaPath}/:type/:section/:name/publish`, + ]) { + this.routeManager.register({ + method: 'POST', + path: publishPath, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + // [#8919] Authoring capability gate — the SAME four lines the + // `PUT` / `DELETE` / `_migrate-stored` doors carry, deliberately + // not a second way of demanding the same capability. + // + // Promotion is authoring. `promoteDraftForPublish` flips the + // `sys_metadata` row `state: 'draft'` → `'active'`, and + // ADR-0027 (E)(5) defines sealing a publish as exactly that + // flip — so this door decides which body is LIVE. Measured + // before the gate: an authenticated principal holding no + // authoring capability at all reached `publishMetaItem` and + // got 200, i.e. it could take a draft somebody else authored + // and make it the live overlay. The `/meta` umbrella already + // refused ANONYMOUS here (401, `registerMetadataEndpoints`), + // so this closes the authenticated-but-uncapable cohort — the + // one the four sibling doors close and these two did not. + // + // ⛔ Not a publish-specific capability: `manage_metadata` is + // ADR-0066 D1's authoring capability and the same one the save + // door demands, so no caller who can author a draft is newly + // refused (measured: the save→publish loop's own first step is + // already gated on it). Splitting author from publisher would + // need a DIFFERENT declared capability and is a product call. + // + // Gate FIRST — before the protocol is resolved — so an + // unauthorized caller cannot use the 501-vs-200 answer to probe + // which kernels implement publishing, and so nothing is promoted + // before the refusal. `isSystem` bypasses, matching every other + // capability gate on the platform. + const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined); + const held = new Set( + Array.isArray(ctx?.systemPermissions) ? ctx!.systemPermissions : [], + ); + if (!ctx?.isSystem && !held.has('manage_metadata')) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: 'Publishing a metadata item requires the `manage_metadata` capability.', + }, + }); + return; + } + const p = await this.resolveProtocol(environmentId, req); + if (!p.publishMetaItem) { + res.status(501).json({ + error: 'Publish operation not supported by protocol implementation', + }); + return; + } + // [#7749 producer, #7941 precedence] The request's authenticated + // identity — one producer, shared by every `/meta` write (see + // resolveMetaWriteActor). `X-Actor` is not consulted. + const actor = await this.resolveMetaWriteActor(environmentId, req); + const body = (req.body && typeof req.body === 'object') ? req.body : {}; + const message = typeof body.message === 'string' ? body.message : undefined; + + // [#10063] Software-package binding for the PROMOTION — + // `?package=`, deliberately the SAME wire spelling and the + // same normalisation the `PUT` door states it with a few + // hundred lines up, not a second dialect for one value. + // + // Why this door needed it at all. #9612 taught the runtime + // publish gate to narrow `objects` to the written item's + // package closure, but only when the caller can NAME the + // package. Three write doors reach that gate; `saveMetaItem` + // and `publishPackageDrafts` both name one, and this one — + // the single-item draft→active promotion — named nothing. So + // every HTTP-driven promotion, which is precisely Studio's + // designer save→publish loop on every edit, handed the gate + // the whole tenant. The protocol half was already built and + // waiting: `promoteDraftForPublish` declares + // `packageId?: string | null` and threads it into both the + // gate and `repo.promoteDraft`. Only the caller was mute. + // + // ⚠️ THE SHARP EDGE, and the reason this is a conditional + // spread rather than a plain key. `promoteDraftForPublish` + // forwards to `repo.promoteDraft` with + // `...('packageId' in request ? { packageId: request.packageId ?? null } : {})` + // — it branches on the KEY BEING PRESENT, not on the value, + // because `null` is a meaningful scope there (pin the lookup + // to the UNBOUND row) while an absent key means "match any + // package", the historical resolution. Writing + // `packageId: packageId` here would therefore put a + // present-and-`undefined` key on every publish that names no + // package, coercing it to `null` downstream and pinning the + // lookup to unbound rows — so a draft authored under a package + // would stop being found and the door would answer `no_draft`. + // That is a silent outage on the untouched path, produced by a + // change that reads like it only ADDS an option. The key must + // be ABSENT when the caller states nothing. + // + // ⛔ Not read off the draft row either — see + // `promoteDraftForPublish`'s own warning: `rowToItem` projects + // `sys_metadata` into a `MetadataItem`, which carries no + // package id, so a read from there is `undefined` on every + // path — narrowing that never fires while looking like it + // does. Widening `MetadataItem` is a `packages/spec` contract + // change and stays filed rather than taken here. + if (refuseRepeatedQueryParams(req, res, ['package'])) return; + const packageRaw = req.query?.package; + const packageId = typeof packageRaw === 'string' && packageRaw && packageRaw !== 'all' + ? packageRaw + : undefined; - // [#8805] The publish half of the same organization, and it - // is REQUIRED for the `PUT` fix to be usable rather than a - // separate improvement: `promoteDraftForPublish` resolves the - // draft through `getOverlayRepo(orgId)`, so once a draft - // authored through `PUT ?mode=draft` lands org-scoped, a - // publish carrying no organization looks in the env-wide - // partition, finds nothing, and answers `no_draft` — the - // Studio designer's save→publish loop, broken. Scoping the - // save without scoping the publish is not a smaller change, - // it is a broken one. - // - // [#8919] The context is now the one the capability gate above - // already resolved, so the caller a publish is SCOPED to can - // never drift from the caller it was AUTHORIZED against — the - // same single-resolution shape the `PUT` door carries. - // `resolveExecCtx` is memoised per request and called in 40+ - // handlers in this file (see the `/published` comment's seam - // warning, which stands). - const organizationId = organizationIdForMetaWrite( - // [#10340] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. - canonicalMetaUrlType(req.params.type), ctx?.tenantId, - ); - // [#11145] The `(p as any)` cast this call carried came off - // when `MetadataProtocol` declared `publishMetaItem` (#11006, - // maintainer ruling 2026-08-22, option B). What the cast was - // load-bearing FOR is recorded because it is counter-intuitive - // and was measured, not assumed: deleting it while the member - // was undeclared answered - // `TS2339: Property 'publishMetaItem' does not exist on - // type 'RestProtocol'` - // — NOT a `TS2353` about an unknown key. The cast was feature - // detection for an ADR-0076 D9 server-only extension, so only - // declaring the member could retire it; widening the - // implementation's own request type in - // `@objectstack/metadata-protocol` (which this package - // deliberately does not depend on) never could, and #10350 - // measured exactly that. - // - // What replaces it is the point of the exercise, not a - // side effect: the literal below is compiled against the spec - // contract through the #9741 `TransportScopedMetaRequest` - // wrapper, so an undeclared key here is a COMPILE ERROR - // (`TS2353`, measured) instead of a payload member no contract - // has ever seen. `environmentId` is the transport-level - // routing key that wrapper layers on — ⛔ never a protocol - // key; a key that belongs on the request belongs in the spec - // schema. - // - // The 501 feature-detection guard above STAYS. The member is - // declared OPTIONAL (ADR-0076 D9 promotion is additive to a - // shipped contract, and a kernel may not implement the - // promotion door at all), and that same guard is what narrows - // it to callable here. - const publishRequest: TransportScopedMetaRequest = { - type: req.params.type, - name: req.params.name, - organizationId, - ...(environmentId ? { environmentId } : {}), - ...(actor ? { actor } : {}), - ...(message ? { message } : {}), - ...(packageId ? { packageId } : {}), - }; - const result = await p.publishMetaItem(publishRequest); - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Publish the pending draft overlay (promotes draft → active)', - tags: ['metadata'], - }, - }); + // [#8805] The publish half of the same organization, and it + // is REQUIRED for the `PUT` fix to be usable rather than a + // separate improvement: `promoteDraftForPublish` resolves the + // draft through `getOverlayRepo(orgId)`, so once a draft + // authored through `PUT ?mode=draft` lands org-scoped, a + // publish carrying no organization looks in the env-wide + // partition, finds nothing, and answers `no_draft` — the + // Studio designer's save→publish loop, broken. Scoping the + // save without scoping the publish is not a smaller change, + // it is a broken one. + // + // [#8919] The context is now the one the capability gate above + // already resolved, so the caller a publish is SCOPED to can + // never drift from the caller it was AUTHORIZED against — the + // same single-resolution shape the `PUT` door carries. + // `resolveExecCtx` is memoised per request and called in 40+ + // handlers in this file (see the `/published` comment's seam + // warning, which stands). + const organizationId = organizationIdForMetaWrite( + // [#10340] FOLDED, not raw — see the PUT door's + // org-scope comment for the measurement. + canonicalMetaUrlType(req.params.type), ctx?.tenantId, + ); + // [#11145] The `(p as any)` cast this call carried came off + // when `MetadataProtocol` declared `publishMetaItem` (#11006, + // maintainer ruling 2026-08-22, option B). What the cast was + // load-bearing FOR is recorded because it is counter-intuitive + // and was measured, not assumed: deleting it while the member + // was undeclared answered + // `TS2339: Property 'publishMetaItem' does not exist on + // type 'RestProtocol'` + // — NOT a `TS2353` about an unknown key. The cast was feature + // detection for an ADR-0076 D9 server-only extension, so only + // declaring the member could retire it; widening the + // implementation's own request type in + // `@objectstack/metadata-protocol` (which this package + // deliberately does not depend on) never could, and #10350 + // measured exactly that. + // + // What replaces it is the point of the exercise, not a + // side effect: the literal below is compiled against the spec + // contract through the #9741 `TransportScopedMetaRequest` + // wrapper, so an undeclared key here is a COMPILE ERROR + // (`TS2353`, measured) instead of a payload member no contract + // has ever seen. `environmentId` is the transport-level + // routing key that wrapper layers on — ⛔ never a protocol + // key; a key that belongs on the request belongs in the spec + // schema. + // + // The 501 feature-detection guard above STAYS. The member is + // declared OPTIONAL (ADR-0076 D9 promotion is additive to a + // shipped contract, and a kernel may not implement the + // promotion door at all), and that same guard is what narrows + // it to callable here. + + // [#11932] The compound arity's name is assembled from the two + // URL segments, byte-for-byte the way the `/published` READ twin + // and the compound `getItem`/`saveItem` doors assemble theirs: + // `
/` is ONE opaque protocol key, not two fields. + // Absent `section` (the single-segment arity) the value is the + // `name` segment unchanged, so the door this card widens answers + // exactly what it answered before. + const publishSection = req.params?.section; + const publishName = publishSection + ? `${publishSection}/${req.params?.name ?? ''}` + : String(req.params?.name ?? ''); + const publishRequest: TransportScopedMetaRequest = { + type: req.params.type, + name: publishName, + organizationId, + ...(environmentId ? { environmentId } : {}), + ...(actor ? { actor } : {}), + ...(message ? { message } : {}), + ...(packageId ? { packageId } : {}), + }; + const result = await p.publishMetaItem(publishRequest); + res.json(result); + } catch (error: any) { + handleRouteError(res, error); + } + }, + metadata: { + summary: 'Publish the pending draft overlay (promotes draft → active)', + tags: ['metadata'], + }, + }); + } // POST /meta/:type/:name/rollback — restore a historical version // as the new live overlay. Body: { toVersion: , message? }. diff --git a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts index a85c6f3ee8..5ca0b47628 100644 --- a/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts +++ b/packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts @@ -337,6 +337,9 @@ const DISPOSITIONS: Record = { 'PUT /api/v1/meta/:type/:name': { kind: 'no-record-echo', why: 'Metadata plane: metadata item, not a data record.' }, 'DELETE /api/v1/meta/:type/:name': { kind: 'no-record-echo', why: 'Metadata plane: delete receipt.' }, 'POST /api/v1/meta/:type/:name/publish': { kind: 'no-record-echo', why: 'Metadata plane: publish receipt.' }, + // [#11932] The compound-name arity of the row above — one handler, one + // receipt shape, reached by a name spelled in two URL segments. + 'POST /api/v1/meta/:type/:section/:name/publish': { kind: 'no-record-echo', why: 'Metadata plane: publish receipt (compound-name arity).' }, 'POST /api/v1/meta/:type/:name/rollback': { kind: 'no-record-echo', why: 'Metadata plane: rollback receipt.' }, 'PUT /api/v1/meta/:type/:section/:name': { kind: 'no-record-echo', why: 'Metadata plane: compound metadata section.' }, diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index cb8f9d8e35..70bfc1d009 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1686,6 +1686,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/meta-compound-publish-door.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/rest/src/meta-compound-publish-door.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/rest/src/meta-compound-save-force-parity.test.ts", "verb": "delete", From efd007b7318c166ee9ef27f04614788880bdb103 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 08:49:03 +0000 Subject: [PATCH 2/3] test(rest): make the mount assertion read the real route table, not the helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ablation caught it: `expect(stack.compoundPublish).toBeTypeOf('function')` is true whether or not the route exists, so that case passed with the source change reverted — a vacuous pin sitting in the middle of a suite whose whole claim is that the door is mounted. It now asserts the composed server's own `POST …/publish` patterns, both arities, in registration order. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../src/meta-compound-publish-door.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/rest/src/meta-compound-publish-door.test.ts b/packages/rest/src/meta-compound-publish-door.test.ts index 64e561b43a..56474574fc 100644 --- a/packages/rest/src/meta-compound-publish-door.test.ts +++ b/packages/rest/src/meta-compound-publish-door.test.ts @@ -272,6 +272,10 @@ function boot(opts?: { capabilities?: string[]; seedDrafts?: boolean }) { return { seen, + /** Every `POST …/publish` pattern the composed server really registered. */ + mountedPublishDoors: () => (rest as any).getRoutes() + .filter((r: any) => r.method === 'POST' && String(r.path).endsWith('/publish')) + .map((r: any) => String(r.path)), /** * The pair this file turns on, for one name: what the LIVE row carries * and what (if anything) is still STAGED beside it. A tuple so §5 can @@ -304,13 +308,18 @@ const UNTOUCHED = [LIVE_LABEL, DRAFT_LABEL]; // ═══════════════════════════════════════════════════════════════════════════ describe('[#11932] POST /meta/:type/:section/:name/publish — the compound promotion door', () => { - it('⭐ is registered at all', () => { + it('⭐ is registered at all — BOTH arities, read off the real mount table', () => { const stack = boot(); - // Pre-fix this threw inside `call()` with "is not registered at all". + + // ⛔ Not `typeof compoundPublish === 'function'` — that is true of the + // helper whether or not the route exists, and it passed the ablation. + // The claim is about the SERVER's table: both spellings, single-segment + // first, and nothing else answering `…/publish`. + expect(stack.mountedPublishDoors()).toEqual([SINGLE_PUBLISH, COMPOUND_PUBLISH]); // Registration is necessary and NOT sufficient — everything below is - // about what it then does. (#7526's lesson: a mounted-but-shadowed or - // stubbed route answers a plausible 200.) - expect(stack.compoundPublish).toBeTypeOf('function'); + // about what the door then does, and `meta-route-registration-order` + // carries the reachability half. (#7526's lesson: a mounted-but-shadowed + // or stubbed route answers a plausible 200.) }); it('⭐ makes the staged compound body LIVE — read from the store, not from the status', async () => { From 2326dd5d4f882908f04f6bebbcf4849d445ee44b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 09:49:56 +0000 Subject: [PATCH 3/3] test(rest): refuse the caller's bound in this suite's ObjectQL `find` double MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:objectql-double-limit` went red on this PR (Lint & Repo Gates, job 97745771073) naming the test file this PR itself adds: packages/rest/src/meta-compound-publish-door.test.ts: NEW ObjectQL `find` double that does not hold the caller's bound (1 blind, 0 breaking a shape rule, 0 unjudged). line 175: BLIND WHY THIS PR TRIPS IT. The gate is a ratchet whose baseline never grows, and this PR adds a new fake engine whose `find` matched `where` and handed back every matched row — limit-blind by the gate's definition. The gate itself landed on `main` AFTER this branch was cut, which is why it appears in neither the PR body's 40 derived gates nor any local derivation on this branch: `scripts/pm/dispatch-gates.mjs` derives families from the tree it is run in, and at this branch's commit that gate does not exist. CI sees it because CI tests the merge with `main`. THE REMEDY IS MEASURED, NOT GUESSED. The gate prints two conforming exits — apply the bound by presence after the filter, or throw when handed a bound the double does not implement. Which one is right turns on whether any caller in this suite actually passes a bound, so that was measured rather than assumed: the double was instrumented at the seam and the suite run whole (15 cases). find 18 calls — every one `sys_metadata_history`, keys ["where","context"], typeof o.limit === 'undefined' bound 0 calls carrying `limit` in any form findOne 54 calls — the CONTROL, proving the instrument records at all The control matters: a first attempt probed via `console.error`, which vitest swallowed, and read zero for both. A zero with no control is not a reading. So no caller passes a bound, and a `slice()` branch would be a line no case in this suite executes. Dead code cannot be relied on to be right on the day it finally runs. A throw instead states what this fake actually is — it does not implement paging — and turns the day some case starts handing it a bound into a RED rather than a quiet full scan that reads exactly like a passing test. It also matches this file's own established idiom: `match` already throws on unsupported logical operators. Presence (`!== undefined`), not truthiness, so `limit: 0` — a request for NOTHING — refuses too instead of being answered with the whole table. WHY THIS IS NOT A GATE BYPASS. Refusal is a conforming exit the gate defines and self-tests ("the refusing fixture is CONFORMING"), for its own recorded reason: the defect class is SILENCE, not incompleteness. The baseline was not touched, no gate was skipped, relaxed or edited, and the census moved in the one direction a genuine repair moves it: before 77 apply the bound or refuse it loudly (0 refuse), 169 limit-blind after 78 apply the bound or refuse it loudly (1 refuse), 168 limit-blind VERIFIED at this commit's tree. `check:objectql-double-limit` red reproduced before the change and green after. Because the ESLint job runs its gates sequentially and the first failure aborts the rest, the 46 gates sitting after this one in lint.yml had never run on this PR; the ones this change set can move were run here — 21 script gates plus check:type-check-coverage, all exit 0 — along with `pnpm --filter @objectstack/rest test` (147 files / 2385 tests passed, unchanged), `--filter @objectstack/rest typecheck`, and whole-repo `eslint . --no-inline-config` (0 findings). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../src/meta-compound-publish-door.test.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/meta-compound-publish-door.test.ts b/packages/rest/src/meta-compound-publish-door.test.ts index 56474574fc..73574decc4 100644 --- a/packages/rest/src/meta-compound-publish-door.test.ts +++ b/packages/rest/src/meta-compound-publish-door.test.ts @@ -172,7 +172,42 @@ function boot(opts?: { capabilities?: string[]; seedDrafts?: boolean }) { }; const engine: any = { - async find(table: string, o?: { where?: Record }) { + /** + * ⛔ REFUSES the caller's bound rather than approximating it — the `limit` + * twin of the combinator refusal in `match` above, and the same reasoning: + * a double looser than `ObjectQL` turns a green suite into no suite + * (`pnpm check:objectql-double-limit`, + * `scripts/check-objectql-double-limit.mjs`, #11525 from #10978). + * + * A `find` that matches `where` and hands back every matched row cannot + * tell a read bounded at 200 from the same read bounded at 1000, or from + * one carrying no bound at all. Every limit change on such a read is green + * BY CONSTRUCTION, and the production symptom is a silently TRUNCATED + * result set rather than an error. + * + * REFUSAL and not `slice()`, because the bound is UNREACHABLE here, and + * that is measured rather than assumed. With this double instrumented at + * the seam and the suite run whole (15 cases): 18 `find` calls, every one + * of them `sys_metadata_history`, carrying keys `["where","context"]` and + * `typeof o.limit === 'undefined'` — zero calls carry a bound. The absence + * is a reading and not a silent probe: the same instrument recorded 54 + * `findOne` calls in the same run as its control. So a `slice()` branch + * would be a line no case in this suite executes, and dead code cannot be + * relied on to be right on the day it finally runs. A throw states what + * this fake actually is — *it does not implement paging* — and turns the + * day some case starts handing it a bound into a RED, instead of a quiet + * full scan that reads exactly like a passing test. + * + * By PRESENCE (`!== undefined`), so `limit: 0` — a request for NOTHING — + * refuses too instead of falling through as falsy and answering with the + * whole table. + */ + async find(table: string, o?: { where?: Record; limit?: number }) { + if (o?.limit !== undefined) { + throw new Error( + `fake engine: unsupported bound limit=${String(o.limit)} — this double does not implement paging`, + ); + } if (table !== 'sys_metadata') return []; return [...rows.values()].filter((r) => match(r, o?.where ?? {})); },