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..73574decc4 --- /dev/null +++ b/packages/rest/src/meta-compound-publish-door.test.ts @@ -0,0 +1,549 @@ +// 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 = { + /** + * ⛔ 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 ?? {})); + }, + 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, + /** 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 + * 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 — BOTH arities, read off the real mount table', () => { + const stack = boot(); + + // ⛔ 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 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 () => { + 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",