From c96c5e9cfde524d2e352f7928ebb8b4349e0446c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 20:44:54 +0000 Subject: [PATCH 1/3] feat(rest,runtime,client)!: retire compound-name metadata addressing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of the #12176 maintainer-ruled retirement of slash-bearing metadata item names. Stage 1 (#12194) declared the item-name grammar and refuses every slash-bearing name at the publish door, so the compound arities addressed only names that can no longer be created — this removes them. packages/rest: - Un-mount the three compound arities: GET and PUT /meta/:type/:section/:name and GET /meta/:type/:section/:name/published. Drop their three route-ledger rows and the ordering pins that existed only because the three-segment compound route was a CATCH-ALL shadowing every literal sibling. packages/runtime: - Un-fold the dispatcher: the /published branch requires exactly three segments and the item branch exactly two, instead of re-joining every trailing segment into one slash-bearing key. - Decode the :name segment. This dispatcher splits the RAW path and nothing decodes for it, unlike the Hono routes in packages/rest — measured, not assumed. Without it a pre-grammar residue row would be addressable through REST and not through the dispatcher, breaking #12194's landed acceptance criterion that stored junk names stay listable and clearable. The sibling domains/packages.ts already decodes its own id segments this way. packages/client: - One URL spelling: encodeURIComponent on every /meta item address, closing the 10-unencoded / 4-encoded split. Encoding a name that satisfies #12194's grammar is a no-op, so this is byte-identical for every writable name; a residue name now reaches the single-segment door as %2F rather than forking the request onto a second door. - Correct the docblocks that promised unencoded compound pass-through, and the #11712 mode carve-out, which is closed at the source rather than here. Capability is re-expressed, not removed: %2F matches the single-segment pattern and Hono decodes the parameter back to the stored spelling, so residue rows still read, write and delete through the surviving doors. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01H9StxQgG2DPA26XzZZqnJB --- packages/client/src/index.ts | 87 ++--- packages/rest/src/rest-route-ledger.ts | 34 +- packages/rest/src/rest-server.ts | 421 +++++-------------------- packages/runtime/src/domains/meta.ts | 77 ++++- 4 files changed, 219 insertions(+), 400 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 1d1a425f61..8f8dc38d3f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -601,11 +601,11 @@ export interface SaveMetaItemOptions { * empty spelling would pin the write against the empty string and refuse * every save with a 409 the caller never asked for. * - * ✅ REACHES BOTH DOORS — unlike `mode` below. The compound-name twin - * `PUT /meta/:type/:section/:name` reads `if-match` and strips ETag-style - * quotes exactly as the single-segment door does, so - * `saveItem('object', 'views/all_leads', item, { ifMatch })` is - * OCC-guarded like any other save. + * [#12195] There is ONE door now. The compound-name twin + * `PUT /meta/:type/:section/:name` — which this note used to pair with — + * is retired, and every name reaches `PUT /meta/:type/:name` + * percent-encoded, so `if-match` behaviour no longer varies by how the + * name is spelled. * * Same member name, same header, same truthy guard as the sibling * first-party `@object-ui/data-objectstack` `MetadataClient.save`, whose @@ -645,21 +645,21 @@ export interface SaveMetaItemOptions { * on the wire that the server ignores. Same shape the first-party * `@object-ui/data-objectstack` `MetadataClient.save` already uses. * - * ⚠️ COMPOUND NAMES DO NOT STAGE. `mode` reaches only the single-segment - * `PUT /meta/:type/:name`. Its compound-name twin - * `PUT /meta/:type/:section/:name` — the door a `name` containing a slash - * lands on, e.g. `saveItem('object', 'views/all_leads', item)` — never - * reads this parameter, so `{ mode: 'draft' }` there is IGNORED and the - * write is PUBLISHED LIVE, answered 200. It is not refused; there is no - * signal at the call site. Filed as objectstack#11712 and deliberately not - * repaired from this side: threading it is the route's decision, and a - * client-side guess would be a second place the two doors disagree. + * [#12195] REACHES EVERY SAVE — the carve-out this note used to carry is + * GONE, and it is worth recording why rather than deleting it silently. * - * ⛔ Do not "fix" this by rejecting compound names here. `force` and - * `packageId` DO reach both doors (measured: the compound handler reads - * and threads `?force` since objectstack#11095 and `?package` alongside - * it), so refusing the whole bag on a compound name would break the two - * parameters that work in order to warn about the one that does not. + * `mode` used to reach only the single-segment `PUT /meta/:type/:name`. + * A `name` containing a slash landed on the compound-name twin + * `PUT /meta/:type/:section/:name`, which never read this parameter — so + * `{ mode: 'draft' }` there was IGNORED and the write was PUBLISHED LIVE, + * answered 200, with no signal at the call site (objectstack#11712). + * + * Two changes closed it at the source rather than from this side. Stage 1 + * (#12194) made a slash-bearing name unwritable at all, and this stage + * retired the twin and unified this file on `encodeURIComponent`, so every + * save now arrives at the one door that reads `mode`. A name that would + * once have forked to the silent-publish door is now refused `400 + * INVALID_REQUEST` by the grammar — loud, at the door, before any write. */ mode?: 'draft' | 'publish'; } @@ -911,7 +911,7 @@ export class ObjectStackClient { const params = new URLSearchParams(); if (options?.packageId) params.set('package', options.packageId); const qs = params.toString(); - const url = `${this.baseUrl}${route}/${type}/${name}${qs ? `?${qs}` : ''}`; + const url = `${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs ? `?${qs}` : ''}`; const res = await this.fetch(url); return this.unwrapResponse(res); }, @@ -953,10 +953,16 @@ export class ObjectStackClient { // omits the `headers` key altogether, so a save without `ifMatch` // hands `fetch` the same `init` it always did. const headers = metaSaveHeaders(options); - // `type`/`name` stay UNENCODED — a compound name's slash must survive - // so the request reaches `PUT /meta/:type/:section/:name` instead of - // collapsing onto the 3-segment route (pinned in client.test.ts). - const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}${query}`, { + // [#12195] ENCODED, like every other `/meta` item address in this file. + // This site used to leave `type`/`name` RAW so a compound name's slash + // would survive into a separate path segment and reach + // `PUT /meta/:type/:section/:name`. That door is retired, and encoding + // is now the single spelling: a legal name (#12194's grammar — snake + // case, optionally dot-qualified) contains nothing `encodeURIComponent` + // alters, so this is byte-identical for every name that can be written, + // and a pre-grammar residue name reaches the single-segment door with + // its slash intact as `%2F` instead of forking the request. + const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${query}`, { method: 'PUT', body: JSON.stringify(item), ...(headers ? { headers } : {}), @@ -1090,13 +1096,19 @@ export class ObjectStackClient { */ /** - * ADR-0033: the published version of a metadata item. Compound names are - * passed through unencoded (e.g. `getPublished('lead', 'views/all_leads')`), - * matching how `getItem` addresses sub-resources. + * ADR-0033: the published version of a metadata item. + * + * [#12195] The name is percent-encoded, like every other `/meta` item + * address in this file. This docblock used to promise the opposite — that + * a compound name passed through UNENCODED, `getPublished('lead', + * 'views/all_leads')`, so its slash would reach the compound arity + * `GET /meta/:type/:section/:name/published`. That arity is retired and a + * slash-bearing name is refused at the publish door (#12194), so there is + * one spelling and one door. */ getPublished: async (type: string, name: string) => { const route = this.getRoute('metadata'); - const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/published`); + const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/published`); return this.unwrapResponse(res); }, @@ -1177,7 +1189,7 @@ export class ObjectStackClient { */ getReferences: async (type: string, name: string) => { const route = this.getRoute('metadata'); - const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/references`); + const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/references`); return this.unwrapResponse(res); }, @@ -1200,15 +1212,16 @@ export class ObjectStackClient { getAudit: async (type: string, name: string, opts?: { limit?: number }) => { const route = this.getRoute('metadata'); const qs = opts?.limit !== undefined ? `?limit=${opts.limit}` : ''; - const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/audit${qs}`); + const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/audit${qs}`); return this.unwrapResponse(res); }, /** * ADR-0033: promote a single item's pending draft overlay to live — * the per-item flow beside `packages.publishDrafts`' package-scoped one. - * 404 [no_draft] when there is nothing to publish. Compound names pass - * through unencoded, like `getItem`. + * 404 [no_draft] when there is nothing to publish. [#12195] The name is + * percent-encoded, like `getItem` — this line used to promise unencoded + * pass-through for compound names, whose arity is now retired. * * The resolved `version` is the ADR-0008 optimistic-concurrency token, the * same carrier `saveItem` returns and with the same job: pass it back as @@ -1229,7 +1242,7 @@ export class ObjectStackClient { opts?: { message?: string }, ): Promise => { const route = this.getRoute('metadata'); - const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/publish`, { + const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish`, { method: 'POST', body: JSON.stringify(opts?.message ? { message: opts.message } : {}), }); @@ -1241,7 +1254,7 @@ export class ObjectStackClient { */ rollbackItem: async (type: string, name: string, toVersion: number, opts?: { message?: string }) => { const route = this.getRoute('metadata'); - const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/rollback`, { + const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/rollback`, { method: 'POST', body: JSON.stringify({ toVersion, ...(opts?.message ? { message: opts.message } : {}) }), }); @@ -1258,7 +1271,7 @@ export class ObjectStackClient { if (opts?.from !== undefined) params.set('from', String(opts.from)); if (opts?.to !== undefined) params.set('to', String(opts.to)); const qs = params.toString(); - const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/diff${qs ? `?${qs}` : ''}`); + const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/diff${qs ? `?${qs}` : ''}`); return this.unwrapResponse(res); } }; @@ -5581,7 +5594,7 @@ export class ScopedProjectClient { const params = new URLSearchParams(); if (options?.packageId) params.set('package', options.packageId); const qs = params.toString(); - const res = await this.parent._fetch(this.url(`/meta/${type}/${name}${qs ? `?${qs}` : ''}`)); + const res = await this.parent._fetch(this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs ? `?${qs}` : ''}`)); return this.parent._unwrap(res); }, /** @@ -5608,7 +5621,7 @@ export class ScopedProjectClient { // Header half of the same bag, through the same one builder the twin // calls — see {@link metaSaveHeaders}. const headers = metaSaveHeaders(options); - const res = await this.parent._fetch(this.url(`/meta/${type}/${name}${query}`), { + const res = await this.parent._fetch(this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}${query}`), { method: 'PUT', body: JSON.stringify(item), ...(headers ? { headers } : {}), diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 7ba904c505..9bb933f302 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -187,22 +187,34 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ // so the SDK guard (#3642) certified them off a DECLARATION while they died // at runtime. Both are `route-manager` mounts here now. // - // Order is load-bearing and pinned by `meta-route-registration-order.test.ts`: - // `/state/:field` precedes the compound `/published` twin (they collide only - // on a field literally named `published`), and BOTH `/published` rows precede - // `GET /api/v1/meta/:type/:section/:name` — a three-segment literal - // registered after that catch-all is mounted and unreachable. + // [#12195] The ordering constraint this note used to carry is DISCHARGED, + // not merely unstated: the compound `/:type/:section/:name` arities are + // retired (stage 3 of #12176), and they were the three-segment catch-all + // that every literal three-segment sibling had to be registered above. The + // four-segment `/state/:field` collision with the compound `/published` + // twin is gone with it. `meta-route-registration-order.test.ts` still pins + // the surviving constraint — a literal-prefixed route above the + // `:type`-parameterised route it shares a segment count with. { route: 'GET /api/v1/meta/object/:name/state/:field', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getLegalNextStates', note: 'ADR-0020 D3.3 legal-next-state introspection. `next: null` = no state_machine governs the field, `next: []` = a declared dead end. #9180 step 2 retired the plural `/api/v1/meta/objects/:name/state/:field` twin that used to carry this `sdk` disposition, and the SDK now spells the segment `object` — the `/meta` type segment is singular, always. The retired twin was a DECLARED registration, not a `META_URL_TO_SINGULAR` fold tolerance (this route matches a literal segment and never consulted the fold), so the boundary accept set is unchanged. ⚠ What the retirement did NOT make universal, because an author reading only this row would assume it did: the legacy dispatcher `/meta` if-chain in `packages/runtime/src/domains/meta.ts` still matches BOTH literals, so the plural is refused HERE and still answered wherever `dispatch()` fronts the request instead of this server. That is deliberate, by the maintainer re-weigh of 2026-08-17 (item 3: no new refusals beyond step 1; the external break deferred with no scheduled window), and it is recorded with its provenance on the dispatcher ledger row plus `runtime/src/domains/meta-state-plural-tolerance.test.ts` (#10179)' }, { route: 'GET /api/v1/meta/:type/:name/published', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getPublished', note: 'ADR-0033 published snapshot; 404s for a name that does not exist, which the pre-#7526 fall-through into the compound-name route structurally could not do (it answered a protection-envelope stub identical before publish and for a bogus name)' }, - { route: 'GET /api/v1/meta/:type/:section/:name/published', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getPublished', - note: 'compound-name arity of the row above — the SDK documents getPublished(\'lead\', \'views/all_leads\'), the same unencoded pass-through getItem/saveItem carry' }, - { route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem', - note: 'compound names pass through getItem unencoded (URL-pinned in client.test.ts); only deleteItem encodes' }, - { route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem', - note: 'compound names pass through saveItem unencoded (URL-pinned in client.test.ts). [#7019] gated on `manage_metadata` (ADR-0066 D1), identical to the single-name PUT — it was MEASURED that with #6603 in place the same ADR-0106 masked round trip still deleted fields through this door. [#11095] reads `?force=true` too — the fourth divergence from its single-segment twin closed on #7019\'s reason (after that capability gate, #8805\'s write-side organization and #7035\'s 501 envelope): the Phase 3a-destructive `409 DESTRUCTIVE_CHANGE` prescribes that parameter, and until this card the prescription was true of the twin and false here. Repeated `?force` is refused (#6877) in the same stroke — an array falls to `!!raw`, turning a doubled opt-OUT into force ON' }, + // [#12195] THREE ROWS RETIRED HERE — `GET /api/v1/meta/:type/:section/:name`, + // `PUT` on the same path, and `GET …/:section/:name/published`. They were the + // compound-name arities: `section` and `name` folded back into one + // slash-bearing key the protocol layer treated as a single opaque string. + // Stage 1 (#12194) made every such name unwritable at the publish door, so + // the arities addressed only names that can no longer be created. + // + // No `client:` disposition moved to `absent` as a result: `meta.getItem`, + // `meta.saveItem` and `meta.getPublished` are all still ledgered above on + // their single-segment rows, which is the door the SDK now uses for EVERY + // name — it percent-encodes, and `%2F` matches the single-segment pattern + // with the parameter decoded back to the stored spelling. That is what keeps + // a pre-grammar residue row readable, writable and deletable after the + // removal, per #12194's "any stored junk name remains listable and + // clearable". // ── ui ──────────────────────────────────────────────────────────────────── { route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'sdk', client: 'meta.getView', diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 39a50b2668..8ac62c8093 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3986,19 +3986,29 @@ export class RestServer { * book tree (`/book/:name/tree`) → the per-item read/write * (`/:type/:name`) with its sub-resources (`references`, `layers`, * `history`, `audit`, `diff`, `publish`, `rollback`, `published`, and the - * object FSM read `state/:field`) → the compound-name twins spelled - * `/:type/:section/:name`. + * object FSM read `state/:field`). * - * Two ordering facts are load-bearing rather than cosmetic. Matching is - * first-match-wins, so a literal-prefixed route must stay ABOVE the - * `:type`-parameterised route it shares a SEGMENT COUNT with — the three + * [#12195] The compound-name twins spelled `/:type/:section/:name` used to + * close that list. They are RETIRED (stage 3 of #12176): every item is + * addressed through the single-segment `/:type/:name`, with the name + * percent-encoded by the caller. + * + * One ordering fact is still load-bearing rather than cosmetic. Matching + * is first-match-wins, so a literal-prefixed route must stay ABOVE the + * `:type`-parameterised route it shares a SEGMENT COUNT with — the * collisions that implies are pinned by * `meta-route-registration-order.test.ts`, and dropping below the line is - * how `/meta/types` once answered as an empty metadata type. And a - * compound-name twin is the SAME operation reached by a name spelled in - * two segments, so a gate or an org scope added to one door must be added - * to its twin — gating one and not the other leaves the twin as the - * bypass. + * how `/meta/types` once answered as an empty metadata type. + * + * The SECOND ordering fact retired with the twins, and it is worth knowing + * that it is gone rather than merely absent: `/:type/:section/:name` was a + * three-segment CATCH-ALL that shadowed every literal three-segment + * sibling (`/history`, `/audit`, `/diff`, `/published`, `/layers`), so + * those had to be registered above it. Nothing shadows them now. The + * twin-parity obligation retired with it too — a gate or an org scope + * added to the single door no longer has a second door to be added to, + * which is exactly the defect family (#6603/#7019, #8805, #7035, #11095, + * #11712) the retirement closes at the source. */ private registerMetadataEndpointsInner(basePath: string): void { const { metadata } = this.config; @@ -4808,9 +4818,10 @@ export class RestServer { // [#5882] GET /meta/:type/:name/layers — the three-layer diagnostic // projection as its OWN resource. Registered BEFORE // /meta/:type/:name for the same first-match reason as - // /references above, and before /meta/:type/:section/:name, which - // would otherwise capture this path with section=, - // name="layers". + // /references above. [#12195] It also used to have to precede the + // compound `/:type/:section/:name`, which would otherwise capture + // this path with section=, name="layers"; that catch-all is + // retired, so only the /references-style reason remains. // // This path exists because the projection used to be reachable only // as `GET /meta/:type/:name?layers=true` — the same route answering @@ -6443,15 +6454,14 @@ export class RestServer { // boundary's accept set for `/meta/:type/...` is unchanged, which is // what the 2026-08-17 re-weigh (item 3) requires of this step. // - // Registered BEFORE the compound `/:type/:section/:name/published` - // twin below, which it still collides with on exactly one shape: - // `/meta/object/x/state/published`. Two literal segments (`object`, - // `state`) beat one, so the FSM reading wins that path — a field - // literally named `published` is the ambiguity, and answering it as - // "the published version of the compound name object/x/state" would - // be the less likely of the two by a wide margin. The collision - // OUTLIVES the plural, so the order pin keeps its singular arm; only - // the plural's own line in it goes away. + // [#12195] The four-segment collision this comment used to describe is + // GONE with the compound `/:type/:section/:name/published` twin. That + // twin captured `/meta/object/x/state/published` as "the published + // version of the compound name object/x/state", and only the literal + // `object`/`state` segments winning kept the FSM reading — a field + // literally named `published` was the ambiguity. With the twin retired + // no other route matches four segments, so this mount is now the only + // reading of that path rather than the preferred one. this.routeManager.register({ method: 'GET', path: `${metaPath}/object/:name/state/:field`, @@ -6533,27 +6543,34 @@ export class RestServer { // after publish, identical for a name that does not exist: a route // that structurally could not 404. // - // Both arities, mirroring the `getItem` / `saveItem` twins: the SDK - // documents `getPublished('lead', 'views/all_leads')`, and a compound - // name is how every other read on this surface addresses a - // sub-resource. REGISTERED BEFORE `/:type/:section/:name` — the - // three-segment form collides with it exactly the way `/history` and - // `/audit` do, and Hono is first-match-wins. - for (const publishedPath of [ - `${metaPath}/:type/:name/published`, - `${metaPath}/:type/:section/:name/published`, - ]) { + // ONE arity since #12195 (stage 3 of #12176's maintainer-ruled + // retirement of compound-name addressing, 2026-08-25). This route used + // to be mounted twice — the second registration was + // `/:type/:section/:name/published`, folding `section` and `name` back + // into one slash-bearing key so the SDK's + // `getPublished('lead', 'views/all_leads')` could reach it. + // + // Stage 1 (#12194) declared the item-name grammar and refuses every + // slash-bearing name at the publish door, so no name reachable ONLY + // through that arity can exist any more. What remains addressable is a + // pre-grammar residue row, and it is reachable HERE: a percent-encoded + // `%2F` matches this single-segment pattern and Hono decodes the + // parameter back to `views/all_leads` (measured, not assumed), which is + // the spelling the SDK now sends for every name. So the compound arity + // was removed WITHOUT removing the capability — D1's "any stored junk + // name remains listable and clearable" still holds through this door. + { this.routeManager.register({ method: 'GET', - path: publishedPath, + path: `${metaPath}/:type/:name/published`, handler: async (req: any, res: any) => { try { const environmentId = isScoped ? req.params?.environmentId : undefined; const type = String(req.params?.type ?? ''); - const section = req.params?.section; - const name = section - ? `${section}/${req.params?.name ?? ''}` - : String(req.params?.name ?? ''); + // [#12195] No `section` fold: this route has one arity. + // A percent-encoded slash arrives already decoded here, + // so a residue name reads exactly as it is stored. + const name = String(req.params?.name ?? ''); // [#8278] The AUTHORITATIVE published store is consulted // first: the `state:'active'` `sys_metadata` overlay row. // Mirrors the dispatcher fix (#8031 / PR #8254, @@ -6724,310 +6741,32 @@ export class RestServer { }); } - // GET /meta/:type/:section/:name - Get specific item with compound name - // Compound names express sub-resources of a type (e.g. a view of an - // object, a flow under an automation). The protocol layer treats - // `
/` as a single opaque key. - if (metadata.endpoints.item !== false) { - this.routeManager.register({ - method: 'GET', - path: `${metaPath}/:type/:section/:name`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const p = await this.resolveProtocol(environmentId, req); - const compoundName = `${req.params.section}/${req.params.name}`; - // [#6877] Same single owning package as the single-segment - // read this route mirrors. - if (refuseRepeatedQueryParams(req, res, ['package'])) return; - const packageId = req.query?.package || undefined; - // [#9454] The compound-name door serves EVERY type - // through one generic `getMetaItem` — including the - // org-overridable ones — so it needs the same scope the - // single-segment read it mirrors now states. Left - // org-blind it would be the surviving route that keeps - // answering from the wrong partition after the other - // doors are fixed. - const compoundCtx = await this.resolveExecCtx(environmentId, req) - .catch(() => undefined); - const compoundOrganizationId = organizationIdForMetaRead( - // [#10340] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. - canonicalMetaUrlType(req.params.type), compoundCtx?.tenantId, - ); - const envelope = await p.getMetaItem({ - type: req.params.type, - name: compoundName, - packageId, - ...(compoundOrganizationId ? { organizationId: compoundOrganizationId } : {}), - } as any) as Record; - // [ADR-0106 D5(4)] Compound names express sub-resources, - // and no object uses one today — but this route serves - // EVERY type through one generic `getMetaItem`, so the - // question it answers for `object` is the same question - // the single-item route answers. Running the projection - // here costs one predicate on a path that will never - // reach it, and leaves no exit whose coverage depends on - // a naming convention holding. - let compoundDocument: any = envelope?.item; - const compoundType = RestServer.metaTypeSingular(req.params.type); - let compoundPosture: ObjectSchemaMaskPosture; - try { - compoundPosture = await (await this.resolveObjectMasker(environmentId, req, compoundType))(compoundName); - } catch (maskError: any) { - if (maskError instanceof ObjectSchemaMaskEvaluationError) { - sendFieldVisibilityFault(res, compoundName); - return; - } - throw maskError; - } - if (compoundPosture.kind === 'project') { - const masked = this.maskObjectDocument(res, compoundPosture, compoundName, compoundDocument); - if (!masked) return; - compoundDocument = masked.document; - } else if (compoundPosture.kind === 'undetermined') { - res.header('Cache-Control', 'private, no-store'); - } - res.header('Vary', 'Accept-Language'); - res.json(await this.translateMetaEnvelope( - req, req.params.type, environmentId, envelope, compoundDocument, - )); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Get specific metadata item by compound name', - tags: ['metadata'], - }, - }); - } - - // PUT /meta/:type/:section/:name - Save metadata item with compound name - this.routeManager.register({ - method: 'PUT', - path: `${metaPath}/:type/:section/:name`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - // [#7019] The compound-name twin of the gate #6603 put on - // `PUT /meta/:type/:name` — WORD FOR WORD the same - // mechanism, because it is word for word the same - // operation: one generic `saveMetaItem`, reached by a name - // spelled in two segments instead of one. - // - // Gating only the single-segment door left this one as a - // bypass of it, and that was measured rather than reasoned: - // with #6603's gate in place, the identical ADR-0106 - // GET → edit a label → PUT still round-tripped a MASKED - // object schema back into the store through here, deleting - // the fields the caller was never allowed to see. Same - // caller, same object, same loss, one route over. - // - // Independently of masking, this door also served the older - // hole for EVERY metadata type: any authenticated session - // could clobber any metadata item. - // - // Gate FIRST — before the protocol is resolved — so an - // unauthorized caller cannot use the 501-vs-200 answer to - // probe which kernels implement saving, and so nothing is - // written 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: 'Saving a metadata item requires the `manage_metadata` capability.', - }, - }); - return; - } - const p = await this.resolveProtocol(environmentId, req); - if (!p.saveMetaItem) { - // [#7035] ADR-0112 envelope. Converged together with the - // single-segment `PUT /meta/:type/:name` above, because - // the two refusals were BYTE-IDENTICAL (see the comment - // block on the gate: "WORD FOR WORD the same mechanism"). - // Fixing one and leaving its literal twin would leave the - // wrong template in the file next to the right one, which - // is the harm #7035 is about — a copier copies whichever - // line they scrolled to. - res.status(501).json({ - error: { - code: 'NOT_IMPLEMENTED', - message: 'Save operation not supported by protocol implementation', - }, - }); - return; - } - - const compoundName = `${req.params.section}/${req.params.name}`; - const ifMatchHeader = req.headers?.['if-match'] ?? req.headers?.['If-Match']; - const parentVersion = typeof ifMatchHeader === 'string' - ? ifMatchHeader.replace(/^"|"$/g, '') - : undefined; - // [#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); - - // [#6877] The `typeof` guard below dropped a repeated - // `?package=` to `undefined`, i.e. wrote the row as an - // env-local overlay instead of into the package the caller - // named — a silent change of where the save LANDS. - // - // [#11095] `force` joins that list in the SAME stroke as the - // parameter itself, and the order is not cosmetic: #6877's - // sharpest measured case is on this very parameter one route - // over — `?force=false&force=false` reaches the `typeof` - // ternary below as an ARRAY, falls through to `!!forceRaw`, - // and a non-empty array is truthy, so a caller repeating an - // explicit opt-OUT turns the destructive-change guard ON. - // Threading `force` here without also naming it here would - // have re-opened that inversion on a fresh door, on a - // destructive verb, reported as 200. - // - // [#11712] `mode` joins for the same reason and in the same - // stroke as the read below. The single-segment twin has - // listed all three since #6877; this door listed two, - // because it read two. The mechanism is #6877's unchanged: - // a repeated `?mode=draft&mode=draft` arrives as an ARRAY, - // the `typeof req.query?.mode === 'string'` test below is - // FALSE for it, and the save falls silently back to - // publishing live — the very outcome this card is about, - // re-entered through the door the fix opens. - if (refuseRepeatedQueryParams(req, res, ['force', 'package', 'mode'])) return; - // [#11095] Phase 3a-destructive: `?force=true` opts past the - // destructive-change safety check — BYTE-IDENTICAL to the - // single-segment `PUT /meta/:type/:name` above, truthy - // spellings and all, because it is byte-identically the same - // decision. - // - // Until this landed the request below was built field by - // field with no `force` among the fields, so `saveMetaItem`'s - // Phase 3a-destructive gate refused a save through this door - // with `409 DESTRUCTIVE_CHANGE` and the remedy clause - // `— re-submit with ?force=true to proceed.`, and a caller - // who did exactly that got the identical refusal back. The - // clause was true of the single-segment twin and false here. - // - // Threading rather than rewording is #7019's ruling applied - // again, with its reason: this route is "word for word the - // same operation" as its twin — one generic `saveMetaItem` - // reached by a name spelled in two segments — and gating only - // the single-segment door was MEASURED to leave this one a - // bypass of it. #8805 (write-side organization) and #7035 - // (the 501 envelope) both cite that same finding. A twin pair - // that disagrees about which risks a caller may acknowledge - // is the same shape, one field along. - // - // ⛔ NOT a licence for every door that reaches this gate: the - // runtime dispatcher's `PUT /meta` was ruled the other way in - // the same stroke (it has no query string at all) and states - // its own `writeFace` so its 409 stops prescribing a - // parameter it does not have. See `destructiveChangeRemedy`. - const forceRaw = req.query?.force; - const force = typeof forceRaw === 'string' - ? ['true', '1', 'yes', 'on'].includes(forceRaw.toLowerCase()) - : !!forceRaw; - const packageRaw = req.query?.package; - const packageId = typeof packageRaw === 'string' && packageRaw && packageRaw !== 'all' - ? packageRaw - : undefined; - - // [#8805] The compound-name twin of the write-side - // organization, for the same reason #7019's gate is - // duplicated here: it is word for word the same operation, - // one generic `saveMetaItem` reached by a name spelled in - // two segments. Gating one door and scoping the other would - // leave this one the bypass — which is exactly how the - // masking round-trip stayed open after #6603. Full rationale - // on the single-segment `PUT` above. - const organizationId = organizationIdForMetaWrite( - // [#10340] FOLDED, not raw — see the PUT door's - // org-scope comment for the measurement. - canonicalMetaUrlType(req.params.type), ctx?.tenantId, - ); - const result = await p.saveMetaItem({ - type: req.params.type, - name: compoundName, - item: req.body, - organizationId, - // [#10888] This door answers with an ADR-0112 error - // envelope that carries the refusal's `issues[]` - // structurally beside the message (`sendError` threads a - // top-level `issues`), so `saveMetaItem`'s 422 renders - // its findings as a headline here instead of restating - // the per-key prose a console would then show twice. - // Server-stated: this object is built field by field - // from named `req` values and never spreads the body, so - // a client cannot smuggle a face in. - // - // [#11095] The face stays `'meta-envelope'` — the same - // one the single-segment twin states — and that is now - // the whole point rather than an inherited default: the - // 409 clause this face renders prescribes `?force=true`, - // and with the line below this door finally HAS one. The - // alternative repair (a face of its own, saying the - // parameter is unavailable) is the option the ruling - // rejected for this door and adopted for the dispatcher. - writeFace: 'meta-envelope', - ...(environmentId ? { environmentId } : {}), - ...(parentVersion !== undefined ? { parentVersion } : {}), - ...(actor ? { actor } : {}), - ...(force ? { force: true } : {}), - ...(packageId ? { packageId } : {}), - // [#11712] ADR-0005 per-item lifecycle: `?mode=draft` - // stages the write instead of publishing it live. - // BYTE-IDENTICAL to the single-segment - // `PUT /meta/:type/:name` above, spelling test and all, - // because it is byte-identically the same decision — - // #7019's ruling applied a fifth time, with its reason: - // this route is "word for word the same operation" as - // its twin, one generic `saveMetaItem` reached by a name - // spelled in two segments. #6603/#7019 (capability - // gate), #8805 (write-side org), #7035 (the 501 - // envelope) and #11095 (`?force`) each closed a - // divergence on this pair on exactly that finding. - // - // The harm was measured, not reasoned. Until this - // landed the request was built field by field with no - // `mode` among the fields, so `saveMetaItem` fell to its - // `'publish'` default: `PUT /meta/object/crm/task - // ?mode=draft` answered `200` with `state: 'active'` and - // OVERWROTE the live row, while the byte-identical - // intent one route over inserted a `state: 'draft'` row - // and left the live one alone. Nothing in the answer - // said the parameter had been ignored — a caller asking - // for a staging buffer got a publish. - // - // ⛔ NOT repaired by refusing the parameter here: the - // draft store keys on `type`/`name`/org/package and is - // indifferent to how the name is spelled, and the - // ADR-0033 read half is ALREADY mounted in both arities - // (`GET /:type/:section/:name/published`, #7526, whose - // own comment cites `getPublished('lead', - // 'views/all_leads')`). A compound draft is a shape this - // surface already serves; only the write door was - // missing. - ...((typeof req.query?.mode === 'string' - && req.query.mode.toLowerCase() === 'draft') - ? { mode: 'draft' } : {}), - } as any); - res.json(result); - } catch (error: any) { - handleRouteError(res, error); - } - }, - metadata: { - summary: 'Save specific metadata item by compound name', - tags: ['metadata'], - }, - }); + // ── RETIRED: the compound `/:type/:section/:name` arities ────────── + // + // `GET` and `PUT /meta/:type/:section/:name` were mounted here until + // #12195 (stage 3 of #12176's maintainer-ruled retirement of + // compound-name addressing, 2026-08-25). Both folded `section` and + // `name` back into one slash-bearing key (`views/all_leads`) that the + // protocol layer then treated as a single opaque string — the section + // half was never stored, filtered or enumerated, so it was addressing + // syntax and nothing else. + // + // Stage 1 (#12194) declared the item-name grammar and refuses every + // slash-bearing name at the publish door, which is what makes this a + // removal of dead addressing rather than of a capability: no name + // reachable only through these arities can be created any more. + // + // Callers address every item through the single-segment twins + // (`GET`/`PUT /meta/:type/:name`), percent-encoding the name — which + // the SDK now does everywhere. A pre-grammar residue row spelled with + // a slash still reads, writes and deletes through those twins, because + // `%2F` matches the single-segment pattern and Hono decodes the + // parameter back to the stored spelling (measured, not assumed). + // + // These were also the three-segment CATCH-ALL that shadowed every + // literal sibling (`/history`, `/audit`, `/diff`, `/published`), which + // is why the registration order below them was load-bearing; with the + // catch-all gone that hazard is gone with it. } /** diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index a8bc150b8a..b2d0d8ccf9 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -176,6 +176,41 @@ async function maskObjectSchemaList( return { ok: true, data: Array.isArray(data) ? projected : { ...data, items: projected } }; } +/** + * Percent-decode the `:name` path segment. [#12195] + * + * This dispatcher splits the RAW path (`path.split('/')`) and, unlike the + * `packages/rest` Hono routes, nothing decodes its parameters for it — + * measured, not assumed: Hono's `c.req.path`, which the adapter's catch-all + * hands to `dispatch()`, returns `/meta/lead/views%2Fall_leads` verbatim while + * `c.req.param('name')` on the same request yields `views/all_leads`. + * + * That difference is load-bearing here. Until #12195 the compound fold + * (`parts.slice(1).join('/')`) is what let a slash-bearing name be addressed + * on this transport at all — unencoded, across segments. Retiring the fold + * without decoding would leave a pre-grammar residue row addressable through + * `packages/rest` and NOT through the dispatcher, breaking #12194's landed + * acceptance criterion that "reads and `deleteMetaItem` still answer for + * pre-grammar residue rows, so any stored junk name remains listable and + * clearable". Decoding makes ONE spelling — percent-encoded, the spelling the + * SDK now sends everywhere — correct on both transports. + * + * `decodeURIComponent` throws `URIError` on a malformed escape (a literal `%` + * that starts no valid sequence). A name is a store key, so the right answer + * to un-decodable input is the RAW segment: it simply will not match a stored + * row, and the caller gets the ordinary 404 rather than a 500 from the split. + * + * The sibling `domains/packages.ts` already decodes its own id segments the + * same way; this domain was the outlier. + */ +function decodeMetaNameSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + /** * Handles Metadata requests * Standard: /metadata/:type/:name @@ -253,11 +288,18 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin return { handled: true, response: deps.success({ object: name, field, from: from ?? null, next }) }; } - // GET /metadata/:type/:name(/:subname...)/published → get published version - // Supports compound names like `lead/views/all_leads/published`. - if (parts.length >= 3 && parts[parts.length - 1] === 'published' && (!method || method === 'GET')) { + // GET /metadata/:type/:name/published → get published version + // + // [#12195] EXACTLY three segments, and no fold. This used to be + // `parts.length >= 3` with `parts.slice(1, -1).join('/')`, which re-joined + // every middle segment into one slash-bearing key so + // `lead/views/all_leads/published` resolved as name `views/all_leads`. + // Stage 1 (#12194) refuses every slash-bearing name at the publish door, + // so that fold could only ever address a name that can no longer be + // written. + if (parts.length === 3 && parts[2] === 'published' && (!method || method === 'GET')) { const type = parts[0]; - const name = parts.slice(1, -1).join('/'); + const name = decodeMetaNameSegment(parts[1]); // [#8031] The AUTHORITATIVE published store is consulted first: the // `state:'active'` `sys_metadata` overlay row. @@ -332,14 +374,27 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin return { handled: true, response: deps.error('Not found', 404) }; } - // /metadata/:type/:name where :name may itself contain slashes - // (e.g. /metadata/lead/views/all_leads → type='lead', name='views/all_leads'). - // Compound names are how the client expresses sub-resources of a type - // (a view of an object, a flow under an automation, etc.) and the - // metadata service treats the full string as the lookup key. - if (parts.length >= 2) { + // /metadata/:type/:name — EXACTLY two segments. [#12195] + // + // This used to be `parts.length >= 2` with `parts.slice(1).join('/')`: every + // segment after the type was re-joined into one slash-bearing lookup key, + // so `/metadata/lead/views/all_leads` resolved as name `views/all_leads`. + // That fold WAS compound-name addressing on this transport, and stage 1 + // (#12194) made every name it could reach unwritable at the publish door. + // + // ⚠️ The `>=` also swallowed three-segment paths that were never compound + // names at all — `/metadata/object/foo/references` folded to name + // `foo/references` — so a sub-resource verb this dispatcher does not + // implement was answered as a metadata READ of a name nothing stores, + // rather than as the ROUTE_NOT_FOUND it is. Requiring exactly two segments + // ends that silently-wrong reading too. + // + // A pre-grammar residue row stays addressable: the caller percent-encodes + // the name, which keeps the segment count at two, and + // `decodeMetaNameSegment` restores the stored spelling. + if (parts.length === 2) { const type = parts[0]; - const name = parts.slice(1).join('/'); + const name = decodeMetaNameSegment(parts[1]); // Extract optional package filter from query string const packageId = query?.package || undefined; From c6c3d98d3da82a231c90641036febb311411f2e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 21:19:04 +0000 Subject: [PATCH 2/3] test(rest,runtime,client): rework the compound-arity pins for the removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pin that measured the retired compound-name arities is REWORKED, never silenced. Two shapes, chosen per file by what is actually still true: INVERTED — files whose subject was a shadowing or divergence hazard now pin the ABSENCE of the arity, because a re-mount is how the hazard returns and an order pin phrased against a retired route would go green while the retirement is undone: - meta-route-registration-order: the three-segment catch-all ordering pins become "no compound `:section` arity is mounted", plus a companion pin that the literals it used to shadow are all still mounted. - meta-item-layered-route, meta-plural-i18n, rest-server-meta-write-org-scope, meta-compound-save-{mode,force}-parity, meta-compound-save-and-reset- capability-gate, meta-501-envelope: same inversion, each keeping the reason the original pin existed (a re-mounted door arrives ungated, org-blind, and reading neither `?force` nor `?mode` until someone re-derives that work). RE-AIMED at the surviving door — coverage that was never about the spelling: - the full #11712 `?mode` and #11095 `?force` contracts, the #6877 repeated- parameter guard, the #7019 capability gate and the #8842 falsy-body hole now run against `/meta/:type/:name`. - meta-published-overlay's residue-row read drives the single-segment `/published` route with the decoded name — the pin that the retirement costs no capability. - client URL pins invert to `%2F`, each with a control proving a grammar-legal name is byte-identical on the wire. NEW post-removal pins, ADR-0112 code AND status on every one: - the compound path answers `404 ROUTE_NOT_FOUND` at the dispatcher; - the ENCODED spelling still reaches the item-name grammar's `400 INVALID_REQUEST`, so "the route is gone" and "the name is illegal" stay distinguishable; - `DELETE` on a compound path answers 404, not the 405 its two-segment address gets — the two refusals are different facts. Also: the meta domain's tail `{ handled: false }` was unreachable dead code until this card (0, 1 and — through the fold — every 2+ segment path were all covered). Un-folding makes it reachable, so it becomes a LOCATED `routeNotFound` rather than letting the adapter answer an anonymous 404 on the very shape this retirement newly produces. Same form `domains/ai.ts` and `domains/share-links.ts` already use. Changeset: minor on rest/runtime/client with the BREAKING route table and the FROM → TO spelling, per the launch-window convention. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01H9StxQgG2DPA26XzZZqnJB --- ...etire-compound-name-metadata-addressing.md | 57 +++ packages/client/src/client.test.ts | 60 ++- .../src/meta-automation-descriptors.test.ts | 20 +- packages/rest/src/meta-501-envelope.test.ts | 45 ++- ...und-save-and-reset-capability-gate.test.ts | 135 ++----- .../meta-compound-save-force-parity.test.ts | 341 +++++++--------- .../meta-compound-save-mode-parity.test.ts | 373 +++++++----------- .../rest/src/meta-item-layered-route.test.ts | 28 +- packages/rest/src/meta-object-fls.test.ts | 17 +- packages/rest/src/meta-plural-i18n.test.ts | 41 +- .../rest/src/meta-published-overlay.test.ts | 20 +- .../src/meta-route-registration-order.test.ts | 64 +-- ...-write-door-capability-enumeration.test.ts | 11 +- ...server-meta-org-scope-url-spelling.test.ts | 17 +- .../rest-server-meta-write-org-scope.test.ts | 30 +- .../rest-server-query-multiplicity.test.ts | 13 +- ...-response-internal-fields.tripwire.test.ts | 5 +- .../src/domains/meta-put-falsy-body.test.ts | 24 +- .../domains/meta-save-capability-gate.test.ts | 15 +- .../meta-state-plural-tolerance.test.ts | 34 +- .../src/domains/meta-verb-fallthrough.test.ts | 22 +- packages/runtime/src/domains/meta.ts | 20 +- packages/runtime/src/http-dispatcher.test.ts | 51 ++- .../src/meta-compound-arity-mint-door.test.ts | 66 +++- 24 files changed, 785 insertions(+), 724 deletions(-) create mode 100644 .changeset/retire-compound-name-metadata-addressing.md diff --git a/.changeset/retire-compound-name-metadata-addressing.md b/.changeset/retire-compound-name-metadata-addressing.md new file mode 100644 index 0000000000..37f58522bd --- /dev/null +++ b/.changeset/retire-compound-name-metadata-addressing.md @@ -0,0 +1,57 @@ +--- +'@objectstack/rest': minor +'@objectstack/runtime': minor +'@objectstack/client': minor +--- + +Retire compound-name metadata addressing (`/meta/:type/:section/:name`) + +Stage 3 of the maintainer-ruled retirement of slash-bearing metadata item names. +Stage 1 declared the item-name grammar and refuses every slash-bearing name at +the publish door, so the routes removed here addressed only names that can no +longer be created. + +**BREAKING — three public REST routes stop answering:** + +| stops answering | use instead | +| :-- | :-- | +| `GET /api/v1/meta/:type/:section/:name` | `GET /api/v1/meta/:type/:name` | +| `PUT /api/v1/meta/:type/:section/:name` | `PUT /api/v1/meta/:type/:name` | +| `GET /api/v1/meta/:type/:section/:name/published` | `GET /api/v1/meta/:type/:name/published` | + +Each retired route folded its `:section` and `:name` segments back into one +slash-bearing key (`views/all_leads`) that the protocol layer then treated as a +single opaque string — the section half was never stored, filtered or +enumerated. A request to a retired path now answers `404 ROUTE_NOT_FOUND`. + +The `@objectstack/runtime` dispatcher stops folding in the same way: its +`/meta` handler requires exactly two path segments for an item and three for +`…/published`, instead of re-joining every trailing segment. A `/meta` path +that matches no route now answers a located `404 ROUTE_NOT_FOUND` rather than +falling through to the adapter's anonymous 404. + +**FROM → TO for callers.** Address every item through the single-segment route +and percent-encode the name: + +``` +GET /api/v1/meta/lead/views/all_leads → GET /api/v1/meta/lead/views%2Fall_leads +``` + +`@objectstack/client` now calls `encodeURIComponent` on every `/meta` item +address, so SDK callers need no change: the SDK already sends the new spelling. +Encoding is a **no-op** for every name the item-name grammar admits (lowercase +snake_case segments, optionally dot-qualified), so the bytes on the wire are +unchanged for every name that can be written today. + +A pre-grammar **residue** row whose stored name contains a slash remains +readable, writable and deletable: `%2F` matches the single-segment pattern and +the parameter is decoded back to the stored spelling before the handler runs. +Nothing that could be stored has become unaddressable. + +Two SDK doc comments that promised "compound names pass through unencoded" +(`meta.getPublished`, `meta.publishItem`) are corrected, and the +`SaveMetaItemOptions.mode` carve-out — `{ mode: 'draft' }` was silently ignored +at the compound door and published live — is closed at the source: there is one +door, and it reads every member of the options bag. + + diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 55d6af2c06..e2d0314f5b 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -321,20 +321,46 @@ describe('ObjectStackClient', () => { ); }); - it('meta.getItem/saveItem pass compound names through unencoded (reaches /meta/:type/:section/:name)', async () => { + it('[#12195] meta.getItem/saveItem ENCODE the name — one spelling, one door', async () => { const { client, fetchMock } = createMockClient({ success: true, data: { name: 'views/all_leads' } }); + // ⚠️ This pin is INVERTED from what it was, and the inversion is the + // card. It used to require the slash to survive UNENCODED, so the + // request would reach the compound handler + // `/meta/:type/:section/:name` instead of collapsing onto the + // two-segment route. #12176 retired compound-name addressing: that + // handler is gone, so `%2F` is now the correct and only spelling. + // + // Encoding is a no-op for every name #12194's grammar admits (snake + // case, optionally dot-qualified), so this changes nothing a legal + // caller sends. What it changes is a pre-grammar residue name: it now + // reaches the surviving door with its slash intact as `%2F`, which Hono + // decodes back to `views/all_leads` — the capability that used to + // require a second route. await client.meta.getItem('object', 'views/all_leads'); - // The slash must survive: %2F would collapse the request onto the - // 3-segment /meta/:type/:name route and miss the compound handler. expect(String(fetchMock.mock.calls[0][0])).toBe( - 'http://localhost:3000/api/v1/meta/object/views/all_leads', + 'http://localhost:3000/api/v1/meta/object/views%2Fall_leads', ); await client.meta.saveItem('object', 'views/all_leads', { label: 'All leads' }); expect(String(fetchMock.mock.calls[1][0])).toBe( - 'http://localhost:3000/api/v1/meta/object/views/all_leads', + 'http://localhost:3000/api/v1/meta/object/views%2Fall_leads', ); expect(fetchMock.mock.calls[1][1].method).toBe('PUT'); }); + + it('[#12195] a LEGAL name is byte-identical before and after the encoding change', async () => { + // The other half: unifying on `encodeURIComponent` must not have moved + // the wire for any name a caller can actually write. Dotted and flat + // snake_case both pass through untouched. + const { client, fetchMock } = createMockClient({ success: true, data: {} }); + await client.meta.getItem('object', 'crm_lead'); + await client.meta.getItem('view', 'crm_lead.pipeline'); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/object/crm_lead', + ); + expect(String(fetchMock.mock.calls[1][0])).toBe( + 'http://localhost:3000/api/v1/meta/view/crm_lead.pipeline', + ); + }); }); describe('Reports namespace (#3587 gap closure)', () => { @@ -2662,14 +2688,15 @@ describe('[#11391] meta.saveItem query string (unscoped client)', () => { ); }); - it('a compound name keeps its unencoded slash AND gets the query string', async () => { + it('[#12195] a slash-bearing name is ENCODED and still gets the query string', async () => { const { client, fetchMock } = createMockClient({ success: true }); await client.meta.saveItem('object', 'views/all_leads', { label: 'All leads' }, { force: true }); - // The slash must still survive (%2F would collapse this onto the - // 3-segment route and miss `PUT /meta/:type/:section/:name`), and the - // compound door reads `?force` too since #11095. + // Inverted by #12195: the slash used to be required to survive raw so + // the request reached `PUT /meta/:type/:section/:name`, which had read + // `?force` since #11095. That door is retired; `%2F` reaches the + // surviving door, which has always read `?force`. expect(String(fetchMock.mock.calls[0][0])).toBe( - 'http://localhost:3000/api/v1/meta/object/views/all_leads?force=true', + 'http://localhost:3000/api/v1/meta/object/views%2Fall_leads?force=true', ); }); }); @@ -2878,16 +2905,15 @@ describe('[#11713] meta.saveItem sends the If-Match header (unscoped client)', ( expect(JSON.parse(init.body)).toEqual({ name: 'customer' }); }); - it('OCC-guards a COMPOUND name too — unlike `mode`, this reaches both doors', async () => { + it('[#12195] OCC-guards a slash-bearing name too, at the one surviving door', async () => { const { client, fetchMock } = createMockClient({ success: true }); await client.meta.saveItem('object', 'views/all_leads', { label: 'All leads' }, { ifMatch: OCC_TOKEN }); - // The compound-name door `PUT /meta/:type/:section/:name` reads - // `if-match` and strips ETag quotes exactly as the single-segment door - // does — measured in rest-server.ts. `mode` is the member that does NOT - // reach it; this one does, so the slash must survive AND the pin must - // ride along. + // This case used to say "unlike `mode`, this reaches BOTH doors" — the + // compound door read `if-match` while never reading `mode`. There is + // one door now, so every member of the options bag reaches it and the + // per-member carve-out is gone. The name is encoded like every other. expect(String(fetchMock.mock.calls[0][0])).toBe( - 'http://localhost:3000/api/v1/meta/object/views/all_leads', + 'http://localhost:3000/api/v1/meta/object/views%2Fall_leads', ); expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN); }); diff --git a/packages/client/src/meta-automation-descriptors.test.ts b/packages/client/src/meta-automation-descriptors.test.ts index de96679573..64a5459495 100644 --- a/packages/client/src/meta-automation-descriptors.test.ts +++ b/packages/client/src/meta-automation-descriptors.test.ts @@ -25,11 +25,27 @@ function createMockClient(body: any, status = 200) { } describe('client.meta (#3563 PR-5)', () => { - it('getPublished passes compound names through unencoded', async () => { + it('[#12195] getPublished ENCODES the name — one spelling, one door', async () => { + // ⚠️ Inverted by #12195. This required the slash to pass through RAW so + // the request reached the compound arity + // `GET /meta/:type/:section/:name/published`. #12176 retired + // compound-name addressing and that arity is un-mounted, so `%2F` — + // which Hono decodes back to `views/all_leads` on the surviving + // `/:type/:name/published` route — is the correct spelling now. const { client, fetchMock } = createMockClient({ success: true, data: { name: 'all_leads' } }); await client.meta.getPublished('lead', 'views/all_leads'); expect(String(fetchMock.mock.calls[0][0])).toBe( - 'http://localhost:3000/api/v1/meta/lead/views/all_leads/published', + 'http://localhost:3000/api/v1/meta/lead/views%2Fall_leads/published', + ); + }); + + it('[#12195] a LEGAL name reaches getPublished byte-identically', async () => { + // The control: encoding must be a no-op for every name #12194's + // grammar admits, so no working caller moved. + const { client, fetchMock } = createMockClient({ success: true, data: {} }); + await client.meta.getPublished('lead', 'all_leads'); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/lead/all_leads/published', ); }); diff --git a/packages/rest/src/meta-501-envelope.test.ts b/packages/rest/src/meta-501-envelope.test.ts index 6b7397c90a..7d6418c1cd 100644 --- a/packages/rest/src/meta-501-envelope.test.ts +++ b/packages/rest/src/meta-501-envelope.test.ts @@ -19,6 +19,12 @@ * mechanism"), so it carried the sibling-key shape too — the card's table * sampled one of the twins, not both. * + * ⚠️ [#12195] The compound `PUT /meta/:type/:section/:name` in that table is + * RETIRED (#12176 stage 3). The row is kept because it is the historical + * measurement this file exists to explain; the case that drove it is replaced + * by a pin that the arity stays unmounted, so a re-mount cannot quietly + * reintroduce a fourth envelope dialect. + * * The cost is not cosmetic. A client reading `err.error.code` — the one position * ADR-0112 declares — got `undefined` on three of the four routes, and * `undefined` takes the "no code" branch rather than an error branch. That is @@ -133,7 +139,14 @@ function boot() { /** The shape the other three converged ON — the ADR-0112 anchor. */ migrateStored: () => drive('POST', MIGRATE_PATH, { params: {} }), singleSave: () => drive('PUT', SINGLE_PATH, { params: { type: 'object', name: 'account' } }), - compoundSave: () => drive('PUT', COMPOUND_PATH, { params: { type: 'object', section: 'crm', name: 'account' } }), + /** + * [#12195] The compound arity's REGISTRATION, not a call to it. + * `route()` above THROWS on an unregistered path, so this reads + * the route list directly. + */ + compoundRoutes: () => (rest as any).getRoutes() + .filter((r: any) => String(r.path).includes(':section')) + .map((r: any) => `${String(r.method).toUpperCase()} ${r.path}`), reset: (query: Record = {}) => drive('DELETE', SINGLE_PATH, { params: { type: 'object', name: 'account' }, query }), }; @@ -187,21 +200,14 @@ describe('#7035 — the `/meta` 501 refusals all speak the ADR-0112 envelope', ( expectNestedEnvelope(answer, 'Save operation not supported by protocol implementation'); }); - it('PUT /meta/:type/:section/:name — the compound twin, same dialect, same fix', async () => { - const answer = await boot().compoundSave(); - expectNestedEnvelope(answer, 'Save operation not supported by protocol implementation'); - }); - - it('the two `PUT` twins answer byte-identical bodies — the pair is one contract', async () => { - // The file's own comment calls these "WORD FOR WORD the same mechanism". - // Pinning their equality is what stops the pair splitting again: a fix - // applied to whichever line the next author scrolled to would show up - // here rather than shipping as a fourth shape. - const stack = boot(); - const single = await stack.singleSave(); - const compound = await stack.compoundSave(); - expect(compound.status).toBe(single.status); - expect(compound.body).toEqual(single.body); + it('[#12195] the compound `PUT` twin is retired — no fourth dialect can reappear there', () => { + // This used to drive `PUT /meta/:type/:section/:name` and then assert + // the two twins answered BYTE-IDENTICAL bodies, because the pair + // splitting again is how a fourth shape would ship. The arity is + // retired, so the equality pin has no second side; what replaces it is + // the absence, which is what a re-mount (arriving with whatever + // envelope its author writes) would break. + expect(boot().compoundRoutes()).toEqual([]); }); it('one code path reads every refusal — `err.error.code` on all four routes', async () => { @@ -213,11 +219,12 @@ describe('#7035 — the `/meta` 501 refusals all speak the ADR-0112 envelope', ( await stack.migrateStored(), await stack.reset(), await stack.singleSave(), - await stack.compoundSave(), ]; - expect(answers.map((a) => a.status)).toEqual([501, 501, 501, 501]); + // [#12195] THREE routes, not four: the compound `PUT` twin that used to + // be the fourth is retired. + expect(answers.map((a) => a.status)).toEqual([501, 501, 501]); expect(answers.map((a) => a.body?.error?.code)).toEqual([ - 'NOT_IMPLEMENTED', 'NOT_IMPLEMENTED', 'NOT_IMPLEMENTED', 'NOT_IMPLEMENTED', + 'NOT_IMPLEMENTED', 'NOT_IMPLEMENTED', 'NOT_IMPLEMENTED', ]); // And every message is readable at the declared position — the bare-string // dialect made this one `undefined`. diff --git a/packages/rest/src/meta-compound-save-and-reset-capability-gate.test.ts b/packages/rest/src/meta-compound-save-and-reset-capability-gate.test.ts index 6629cea712..e1fcda9976 100644 --- a/packages/rest/src/meta-compound-save-and-reset-capability-gate.test.ts +++ b/packages/rest/src/meta-compound-save-and-reset-capability-gate.test.ts @@ -149,20 +149,15 @@ function boot(opts: BootOptions) { /** Whether the single-name item's customization overlay still exists. */ hasOverlay: () => overlays.has('account'), - compoundGet: async () => { - const res = mockRes(); - await route('GET', COMPOUND_PATH)!.handler( - { params: { type: 'object', section: 'crm', name: 'account' }, query: {}, headers: {} }, res, - ); - return { res, body: res.json.mock.calls.at(-1)?.[0] }; - }, - compoundPut: async (item: unknown) => { - const res = mockRes(); - await route('PUT', COMPOUND_PATH)!.handler( - { params: { type: 'object', section: 'crm', name: 'account' }, query: {}, headers: {}, body: item }, res, - ); - return { res, body: res.json.mock.calls.at(-1)?.[0] }; - }, + /** + * [#12195] The compound arity's REGISTRATIONS, not calls to them. These + * used to be `compoundGet()` / `compoundPut()`; the arity is retired, so + * what is assertable now is that nothing is mounted there. + */ + compoundRoutes: () => ['GET', 'PUT'].map((m) => route(m, COMPOUND_PATH)), + metaRouteKeys: () => (rest as any).getRoutes() + .map((r: any) => `${String(r.method).toUpperCase()} ${r.path}`) + .filter((k: string) => k.includes('/api/v1/meta')), del: async (query: Record = {}) => { const res = mockRes(); await route('DELETE', SINGLE_PATH)!.handler( @@ -173,92 +168,36 @@ function boot(opts: BootOptions) { }; } -describe('#7019 — compound-name PUT: the ADR-0106 round trip, one route over', () => { - it('refuses the restricted round-trip write, and the masked fields SURVIVE in the store', async () => { - const stack = boot({ - context: { userId: 'u_portal', systemPermissions: [] }, - readable: READABLE_TO_RESTRICTED, - }); - - // 1. The compound read is masked — the premise, asserted rather than - // assumed so this case cannot go quietly green if masking stops. - const read = await stack.compoundGet(); - expect(Object.keys(read.body.item.fields).sort()).toEqual(READABLE_TO_RESTRICTED); - expect(read.body.item.fields).not.toHaveProperty('salary_grade'); - expect(read.body.item.fields).not.toHaveProperty('bonus_formula'); - - // 2. The caller edits something unrelated and sends the body back — - // the exact sequence that was MEASURED to still lose fields here - // after #6603 gated the single-name door. - const write = await stack.compoundPut({ ...copy(read.body.item), label: 'Account (renamed)' }); - - // 3. Refused, with the envelope. - expect(write.res.statusCode).toBe(403); - expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); - - // 4. THE POINT: nothing was written. - expect(stack.saveMetaItem).not.toHaveBeenCalled(); - expect(stack.compoundFields()).toEqual(ALL_FIELDS); - expect(stack.compoundLabel()).toBe('Account'); - }); - - it('the refusal is the gate, not the masking: an UNRESTRICTED but uncapable caller is refused too', async () => { - // Everything readable ⇒ no field would have been lost. Still refused, - // because the second reason — any authenticated session could clobber - // any metadata item — is independent of ADR-0106. - const stack = boot({ context: { userId: 'u_staff', systemPermissions: [] }, readable: ALL_FIELDS }); - - const read = await stack.compoundGet(); - expect(Object.keys(read.body.item.fields).sort()).toEqual(ALL_FIELDS); - - const write = await stack.compoundPut({ ...copy(read.body.item), label: 'clobbered' }); - expect(write.res.statusCode).toBe(403); - expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); - expect(stack.compoundLabel()).toBe('Account'); - }); - - it('fires BEFORE the protocol is probed, so 403-vs-501 leaks no kernel capability', async () => { - const stack = boot({ context: { userId: 'u1', systemPermissions: [] }, withoutWriters: true }); - const write = await stack.compoundPut({ name: 'account' }); - // An authorized caller would get 501 here. - expect(write.res.statusCode).toBe(403); - expect(write.body).toMatchObject({ error: { code: 'FORBIDDEN' } }); - }); - - it('an anonymous caller never reaches the capability gate — 401 from the /meta umbrella', async () => { - const stack = boot({ context: undefined }); - const write = await stack.compoundPut({ name: 'account' }); - expect(write.res.statusCode).toBe(401); - expect(stack.saveMetaItem).not.toHaveBeenCalled(); - }); - - it.each([ - { held: 'no capabilities at all', systemPermissions: [] as string[], status: 403 }, - { held: '`studio.access` alone — ADR-0106 D4-exempt, but not an authoring capability', systemPermissions: ['studio.access'], status: 403 }, - { held: '`setup.access` alone — this is `organization_admin`', systemPermissions: ['setup.access'], status: 403 }, - { held: '`manage_metadata` alone', systemPermissions: ['manage_metadata'], status: 200 }, - { held: 'the shipped `admin_full_access` shape', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], status: 200 }, - ])('$held → $status', async ({ systemPermissions, status }) => { - const stack = boot({ context: { userId: 'u1', systemPermissions } }); - const write = await stack.compoundPut({ name: 'account', label: 'Account', fields: {} }); - expect(write.res.statusCode).toBe(status); - }); - - it('`isSystem` bypasses, matching every other capability gate on the platform', async () => { - const stack = boot({ context: { isSystem: true } }); - const write = await stack.compoundPut({ name: 'account', label: 'Account', fields: {} }); - expect(write.res.statusCode).toBe(200); - expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); +describe('[#7019 / #12195] the compound-name arity is retired', () => { + /** + * ⛔ REWORKED, not deleted. #7019 gated `PUT /meta/:type/:section/:name` on + * `manage_metadata` because leaving it ungated made it a BYPASS of the gate + * #6603 had just put on its single-segment twin — measured, not reasoned: + * with #6603 in place the identical ADR-0106 round trip (read a masked + * object schema, edit a label, PUT it back) still deleted the fields the + * caller was never allowed to see, through this door. + * + * #12176 retired the arity, so the bypass is closed by removal instead of + * by a second gate. The pin inverts to match: what must stay true is that + * the door is not mounted, because a re-mounted compound door arrives + * UNGATED unless whoever mounts it re-derives #6603/#7019 — which is + * exactly the history above. + * + * The surviving door's own capability gate is pinned by + * `meta-item-save-capability-gate.test.ts`; it is not duplicated here. + */ + it('⭐ mounts neither GET nor PUT at /meta/:type/:section/:name', () => { + expect( + boot({ systemPermissions: [] }).compoundRoutes(), + 'a compound-name arity is mounted again. It was #6603\'s gate bypass ' + + 'until #7019, and a fresh mount does not inherit that gate', + ).toEqual([undefined, undefined]); }); - it('leaves the compound READ alone — this card gates writes', async () => { - // The read side has its own posture (the ADR-0106 projection asserted - // in the headline case). Turning this into a blanket gate on the - // compound route pair would be a different, unruled change. - const stack = boot({ context: { userId: 'u_portal', systemPermissions: [] }, readable: READABLE_TO_RESTRICTED }); - const read = await stack.compoundGet(); - expect(read.res.statusCode).not.toBe(403); - expect(Object.keys(read.body.item.fields).sort()).toEqual(READABLE_TO_RESTRICTED); + it('⭐ mounts no compound `:section` arity of any method', () => { + expect( + boot({ systemPermissions: [] }).metaRouteKeys().filter((k: string) => k.includes(':section')), + ).toEqual([]); }); }); diff --git a/packages/rest/src/meta-compound-save-force-parity.test.ts b/packages/rest/src/meta-compound-save-force-parity.test.ts index 7a64e7fa82..bf02336994 100644 --- a/packages/rest/src/meta-compound-save-force-parity.test.ts +++ b/packages/rest/src/meta-compound-save-force-parity.test.ts @@ -1,52 +1,46 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#11095] `?force=true` on the compound-name `PUT /api/v1/meta/:type/:section/:name` - * — the third row of the destructive-409 face inventory, closed by threading - * the parameter rather than by rewording the sentence. + * [#11095 → #12195] `?force=true` on the `/meta` save doors — a two-door parity + * suite whose SECOND DOOR NO LONGER EXISTS. * - * ## ⚠️ #12194 reversed the compound door's WRITE outcome - * - * Stage 1 of #12176 (maintainer ruling 2026-08-25): the item-name grammar - * refuses every slash-bearing name at `saveMetaItem`, BEFORE the destructive - * gate this file was written about. The compound door still folds - * `:section/:name` into `crm/task` and still threads `force` (the seam pins - * below stay true), but the fold's output is now refused `400 INVALID_REQUEST` - * with the dotted prescription — no `?force` spelling can acknowledge a - * grammar violation. The compound-door cases below pin that refusal; the - * single-segment twin keeps the full #11095 contract, and the twins now - * DIVERGE BY DESIGN at the write (the route retirement itself is D3, #12195). - * - * ## The defect + * ## What this file is now, and why it was not deleted * * `saveMetaItem`'s Phase 3a-destructive gate raises ONE `409 * DESTRUCTIVE_CHANGE`, and its remedy clause ends `— re-submit with * ?force=true to proceed.` That clause was true of the single-segment * `PUT /meta/:type/:name`, which reads `?force` and threads it. It was FALSE - * here: this route built its `saveMetaItem` request field by field and `force` - * was not one of the fields, so a caller refused at this door, doing exactly - * what the refusal told them to do, got the identical refusal back — and - * nothing in the second answer said the parameter had been ignored. + * at the compound-name twin `PUT /meta/:type/:section/:name`, which built its + * `saveMetaItem` request field by field with `force` not among the fields — so + * a caller refused at that door, doing exactly what the refusal told them to + * do, got the identical refusal back, with nothing saying the parameter had + * been ignored. #11095 closed it by threading the parameter. + * + * #12176's maintainer ruling (2026-08-25) then retired compound metadata item + * names outright. Stage 1 (#12194) declared the item-name grammar and refuses + * every slash-bearing name at the publish door — BEFORE the destructive gate + * this file was written about — and stage 3 (#12195) un-mounts the arity. * - * ## Why threading, and not a face of its own + * ⛔ REWORKED rather than deleted. The guard worth keeping is against the arity + * coming BACK: a re-mounted compound door is a door that reads neither `?force` + * nor `?mode` until someone re-threads them, which is the whole divergence + * family this file and its `mode` sibling document (#6603/#7019's + * `manage_metadata` gate, #8805's write-side organization, #7035's 501 + * envelope, #11095's `?force`, #11712's `?mode`). So: * - * The maintainer ruled a SPLIT (2026-08-23) over the two doors #11015 left - * open, and this is the half that gains the parameter. The argument is #7019's, - * inherited with its reason rather than re-derived: the compound route is - * "word for word the same operation" as its single-segment twin — one generic - * `saveMetaItem`, reached by a name spelled in two segments instead of one — - * and gating only the twin was MEASURED to leave this door a bypass of the - * gate, not a narrower version of it. Every divergence found between the pair - * since has been closed on that same finding: #6603/#7019's `manage_metadata` - * gate, #8805's write-side organization, #7035's 501 envelope. A pair that - * disagrees about which risks a caller may acknowledge is that shape once more. + * 1. the compound arity is pinned ABSENT (§1) — the removal's own pin; + * 2. the surviving door keeps the FULL #11095 contract — destructive 409 with + * the remedy, honoured `?force=true`, the truthy table (§2); + * 3. #6877's repeated-parameter guard is re-pinned on the surviving door (§3); + * 4. the slash-bearing name a caller would once have spelled compound is + * pinned answering #12194's `400 INVALID_REQUEST` at the surviving door, + * with `?force` unable to acknowledge past it (§4). * - * ⛔ The other half of the ruling went the other way, and this file is not a - * precedent for it: `@objectstack/runtime`'s dispatcher `PUT /meta` does NOT - * gain `force` — it is reached with a path, a method and a body, so there is no - * query string for an acknowledgement to arrive on, and it states its own - * `writeFace` so the clause stops naming a parameter it does not have. See - * `packages/runtime/src/domains/meta-save-destructive-remedy.test.ts`. + * ⛔ Still not a precedent for the dispatcher: `@objectstack/runtime`'s + * `PUT /meta` does NOT gain `force` — it is reached with a path, a method and a + * body, so there is no query string for an acknowledgement to arrive on, and it + * states its own `writeFace` so the clause stops naming a parameter it does not + * have. See `packages/runtime/src/domains/meta-save-destructive-remedy.test.ts`. * * ## Why the REAL protocol and not a double * @@ -69,21 +63,18 @@ * * ## What the cases assert * - * `status` AND `code` (the ADR-0112 envelope) on every refusal, in BOTH - * directions, on BOTH doors. These handlers *send* rather than throw, so a - * `toThrow`-shaped assertion could not separate "refused with the wrong - * envelope" from "did not refuse at all" — and on the unfixed code the second - * answer is a 409 that looks exactly like the first. + * `status` AND `code` (the ADR-0112 envelope) on every refusal. These handlers + * *send* rather than throw, so a `toThrow`-shaped assertion could not separate + * "refused with the wrong envelope" from "did not refuse at all" — and on the + * unfixed code the second answer is a 409 that looks exactly like the first. * * ⚠️ TWO body shapes appear below, and they are the file's, not a typo. The - * refusals this card is about come out of `handleRouteError`, whose body is - * FLAT — `{ error: , code, issues }`, with the `code` at top - * level and `issues` beside it (row 2 of the face inventory calls that "a - * top-level `issues`", and this is what it means). The `400` from - * `refuseRepeatedQueryParams` is hand-built by the route and NESTED — - * `{ error: { code, message } }` — as are this file's sibling `403`/`501` - * refusals. Reading `body.error.code` off a `handleRouteError` answer yields - * `undefined`, and next to a status-only assertion that reads as a pass. + * destructive and grammar refusals come out of `handleRouteError`, whose body + * is FLAT — `{ error: , code, issues }`, with the `code` at top + * level. The `400` from `refuseRepeatedQueryParams` is hand-built by the route + * and NESTED — `{ error: { code, message } }`. Reading `body.error.code` off a + * `handleRouteError` answer yields `undefined`, and next to a status-only + * assertion that reads as a pass. */ import { describe, it, expect, vi } from 'vitest'; @@ -281,234 +272,200 @@ function boot() { fieldsOf: (id: string) => Object.keys(JSON.parse(rows.get(id)!.metadata).fields ?? {}).sort(), compoundFields: () => Object.keys(JSON.parse(rows.get('row_compound')!.metadata).fields ?? {}).sort(), singleFields: () => Object.keys(JSON.parse(rows.get('row_single')!.metadata).fields ?? {}).sort(), - /** The door under test. */ - compoundPut: (query: Record = {}) => - call(COMPOUND_PATH, { type: 'object', section: 'crm', name: 'task' }, query), - /** Its single-segment twin — the control, already correct before this card. */ + /** + * [#12195] The compound door's REGISTRATION, not a call to it. This + * used to be `compoundPut()`; the arity is retired, so what is + * assertable now is that nothing is mounted there. + */ + compoundRoute: () => route('PUT', COMPOUND_PATH), + /** Every `/meta` route key this server mounted, for absence sweeps. */ + metaRouteKeys: () => (rest as any).getRoutes() + .map((r: any) => `${String(r.method).toUpperCase()} ${r.path}`) + .filter((k: string) => k.includes(META)), + /** The surviving door. */ singlePut: (query: Record = {}) => call(SINGLE_PATH, { type: 'object', name: SINGLE_NAME }, query), + /** + * [#12195] The surviving door addressed with an ARBITRARY name — the + * shape a caller now uses for a slash-bearing one (percent-encoded on + * the wire, decoded by Hono before the handler runs). + */ + singlePutNamed: (name: string, query: Record = {}) => + call(SINGLE_PATH, { type: 'object', name }, query), }; } -/** The sentence the 409 ends with, and the thing this card had to make true. */ +/** The sentence the 409 ends with, and the thing #11095 had to make true. */ const PUT_REMEDY = 're-submit with ?force=true to proceed.'; // ═══════════════════════════════════════════════════════════════════════════ -// 1. The compound door, REFUSED — and the refusal tells the truth now +// 1. ⭐ [#12195] The compound door is GONE — the pin the removal owes // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11095 / #12194] compound-name PUT — refused at the item-name grammar gate', () => { - it('refuses the folded slash name with the ADR-0112 envelope, and writes NOTHING', async () => { - const stack = boot(); - - const answer = await stack.compoundPut(); - - // The grammar gate answers BEFORE the destructive gate ever computes a - // diff: 400 (the caller's addressing mistake), not the 409 this file - // used to pin. `handleRouteError`'s body is FLAT — `code` at top level. - expect(answer.status).toBe(400); - expect(answer.body?.code).toBe('INVALID_REQUEST'); - // THE POINT of a refusal case: "refused after writing" satisfies both - // assertions above and is still the bug. - expect(stack.compoundFields()).toEqual(STORED_FIELDS); +describe('[#11095 / #12195] the compound-name `PUT` arity is retired', () => { + /** + * ⛔ REWORKED, not deleted — same reasoning as the `mode` suite next door. + * #11095 threaded `?force` onto the compound door to close the fourth + * divergence on the pair; #12176 then retired the pair itself. The guard + * worth keeping is against the arity coming BACK, because a re-mounted + * compound door is a door that reads neither `?force` nor `?mode` unless + * someone re-threads them — the divergence family this file documents. + */ + it('⭐ mounts no `PUT /meta/:type/:section/:name` at all', () => { + expect( + boot().compoundRoute(), + 'the compound PUT arity is mounted again — #12176 retired compound ' + + 'metadata item names and #12194 refuses every slash-bearing name at the ' + + 'publish door, so it can only be reached by a name that cannot be created', + ).toBeUndefined(); }); - it('names the grammar and the dotted prescription — guidance, not a bare no', async () => { - const stack = boot(); - - const answer = await stack.compoundPut(); - - expect(answer.body?.error).toContain('is not a legal metadata item name'); - expect(answer.body?.error).toContain('crm_lead.pipeline'); - }); - - it('and does NOT prescribe `?force=true` — force cannot acknowledge a grammar violation', async () => { - const stack = boot(); - - const answer = await stack.compoundPut(); - - // The destructive 409's remedy clause must not ride on this refusal: - // re-submitting with the parameter changes nothing (pinned below), so - // prescribing it here would be the #11095 defect resurrected — a - // sentence the door cannot make true. - expect(answer.body?.error).not.toContain(PUT_REMEDY); + it('⭐ mounts no compound `:section` arity of any method', () => { + expect(boot().metaRouteKeys().filter((k: string) => k.includes(':section'))).toEqual([]); }); }); // ═══════════════════════════════════════════════════════════════════════════ -// 2. ⭐ The compound door, ACCEPTED — the case that fails without the fix +// 2. The surviving door keeps the FULL #11095 contract // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11095 / #12194] compound-name PUT — no `?force` spelling bypasses the grammar gate', () => { - it('⭐ re-submitting with `?force=true` changes NOTHING — same refusal, store untouched', async () => { +describe('[#11095] the single-segment `PUT` — destructive refusal and its remedy', () => { + it('⭐ refuses a destructive change 409 with the remedy, and writes NOTHING', async () => { const stack = boot(); - // 1. Refused at the grammar gate. - const refused = await stack.compoundPut(); - expect(refused.status).toBe(400); - expect(refused.body?.code).toBe('INVALID_REQUEST'); - - // 2. `force` acknowledges a DESTRUCTIVE diff; it is not a bypass of the - // name grammar. The pre-#12194 direction here was 200 + the shrunk - // store — the acceptance this pin replaces. - const forced = await stack.compoundPut({ force: 'true' }); + const answer = await stack.singlePut(); - expect(forced.status).toBe(400); - expect(forced.body?.code).toBe('INVALID_REQUEST'); - expect(stack.compoundFields()).toEqual(STORED_FIELDS); + expect(answer.status).toBe(409); + expect(answer.body?.code).toBe('DESTRUCTIVE_CHANGE'); + expect(answer.body?.error).toContain(PUT_REMEDY); + expect(stack.singleFields()).toEqual(STORED_FIELDS); }); - it('threads `force: true` into the protocol request, and only when asked', async () => { + it('⭐ honours `?force=true` — acknowledged, 200, landed', async () => { const stack = boot(); - await stack.compoundPut(); - await stack.compoundPut({ force: 'true' }); - - // The seam itself. The pre-fix door reached `saveMetaItem` on BOTH of - // these calls — it simply never named `force` in either request, which - // is why a store-only assertion could not localise the defect. - expect(stack.seen).toHaveLength(2); - expect(stack.seen[0].force).toBeUndefined(); - expect(stack.seen[1].force).toBe(true); - // The rest of the request is untouched by this card — same face, same - // compound name assembled from the two segments. - expect(stack.seen[1].name).toBe(COMPOUND_NAME); - expect(stack.seen[1].writeFace).toBe('meta-envelope'); + const answer = await stack.singlePut({ force: 'true' }); + + expect(answer.status).toBe(200); + expect(stack.singleFields()).toEqual(SHRUNK_FIELDS); }); - it.each([ - { spelling: 'true' }, { spelling: '1' }, { spelling: 'yes' }, { spelling: 'on' }, { spelling: 'TRUE' }, - ])('the `$spelling` spelling is refused the same way — the truthy table buys no bypass', async ({ spelling }) => { - const stack = boot(); + it('threads `force: true` into the protocol request, and only when asked', async () => { + const forced = boot(); + const plain = boot(); - const answer = await stack.compoundPut({ force: spelling }); + await forced.singlePut({ force: 'true' }); + await plain.singlePut(); - expect(answer.status).toBe(400); - expect(answer.body?.code).toBe('INVALID_REQUEST'); - expect(stack.compoundFields()).toEqual(STORED_FIELDS); + expect(forced.seen).toHaveLength(1); + expect(forced.seen[0]).toMatchObject({ + type: 'object', name: SINGLE_NAME, force: true, writeFace: 'meta-envelope', + }); + // The default is the ABSENCE of the flag, never `force: false`. + expect(plain.seen).toHaveLength(1); + expect(plain.seen[0].force).toBeFalsy(); }); - it('`?force=false` earns the SAME grammar refusal — the gate reads the name, never the flag', async () => { + it.each(['true', '1', 'yes', 'on', 'TRUE'])( + 'the `%s` spelling acknowledges the same way — the truthy table is unchanged', + async (spelling) => { + const stack = boot(); + + const answer = await stack.singlePut({ force: spelling }); + + expect(answer.status).toBe(200); + expect(stack.singleFields()).toEqual(SHRUNK_FIELDS); + }, + ); + + it('`?force=false` does NOT acknowledge — the destructive refusal stands', async () => { const stack = boot(); - const answer = await stack.compoundPut({ force: 'false' }); + const answer = await stack.singlePut({ force: 'false' }); - expect(answer.status).toBe(400); - expect(answer.body?.code).toBe('INVALID_REQUEST'); - expect(stack.compoundFields()).toEqual(STORED_FIELDS); + expect(answer.status).toBe(409); + expect(answer.body?.code).toBe('DESTRUCTIVE_CHANGE'); + expect(stack.singleFields()).toEqual(STORED_FIELDS); }); }); // ═══════════════════════════════════════════════════════════════════════════ -// 3. [#6877] ⛔ The inversion this card had to avoid re-opening on a new door +// 3. [#6877] The repeated-parameter guard. GREEN BOTH SIDES of this card. // ═══════════════════════════════════════════════════════════════════════════ describe('[#11095 / #6877] a REPEATED `?force` is refused, never read as force-ON', () => { /** - * #6877's sharpest measured case is on this exact parameter one route over: - * `?force=false&force=false` arrives as an ARRAY, the `typeof` ternary falls - * through to `!!forceRaw`, and a non-empty array is truthy — so a caller - * repeating an explicit opt-OUT turned the destructive guard ON, on a - * destructive verb, answered 200. - * - * Threading `force` here without adding it to this door's - * `refuseRepeatedQueryParams` list would have re-opened that inversion on a - * door that never had it. The parameter and the guard landed in one stroke; - * this is the case that says so. + * #6877's inversion: a repeated `?force=false&force=false` arrives as an + * ARRAY, and a non-empty array is truthy — so a spelled-out opt-OUT would + * turn the guard ON. The route refuses multiplicity before reading intent. */ it('⛔ `?force=false&force=false` is a 400 — NOT a silent force-ON', async () => { const stack = boot(); - const answer = await stack.compoundPut({ force: ['false', 'false'] }); + const answer = await stack.singlePut({ force: ['false', 'false'] }); expect(answer.status).toBe(400); expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); - // The inversion, stated as the assertion that would have caught it: the - // save must not have happened at all, let alone succeeded. expect(stack.seen).toHaveLength(0); - expect(stack.compoundFields()).toEqual(STORED_FIELDS); + expect(stack.singleFields()).toEqual(STORED_FIELDS); }); it('⛔ `?force=true&force=true` is refused too — multiplicity, not intent', async () => { const stack = boot(); - const answer = await stack.compoundPut({ force: ['true', 'true'] }); + const answer = await stack.singlePut({ force: ['true', 'true'] }); expect(answer.status).toBe(400); expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); - expect(stack.compoundFields()).toEqual(STORED_FIELDS); + expect(stack.seen).toHaveLength(0); + expect(stack.singleFields()).toEqual(STORED_FIELDS); }); it('one occurrence encoded as an array still REACHES the door — the guard unwraps, it does not blanket-refuse', async () => { const stack = boot(); - const answer = await stack.compoundPut({ force: ['true'] }); + const answer = await stack.singlePut({ force: ['true'] }); - // The guard's own verdict would be the nested VALIDATION_ERROR before - // the protocol is called (`seen` empty, as the repeated cases above - // pin). A single array-encoded occurrence unwraps and travels: the - // request reaches `saveMetaItem` — recorded at the seam — where the - // #12194 grammar gate is what answers now. + expect(answer.status).toBe(200); expect(stack.seen).toHaveLength(1); expect(stack.seen[0].force).toBe(true); - expect(answer.status).toBe(400); - expect(answer.body?.code).toBe('INVALID_REQUEST'); - expect(stack.compoundFields()).toEqual(STORED_FIELDS); + expect(stack.singleFields()).toEqual(SHRUNK_FIELDS); }); }); // ═══════════════════════════════════════════════════════════════════════════ -// 4. ⭐ [#7019] The twins agree — the ruling this card inherits, executable +// 4. ⭐ [#12194] A slash-bearing name is refused at the GRAMMAR gate, before +// the destructive gate — and `?force` cannot acknowledge past it. // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11095 / #7019 / #12194] the two `PUT` doors now DIVERGE by design at the write', () => { - /** - * #7019's "one operation, two spellings" premise is what #12176 retired: - * the compound spelling is no longer a legal way to say the operation. The - * single-segment door keeps the FULL #11095 contract — destructive 409 - * with the remedy, honoured `?force=true` — and the compound door refuses - * before the destructive gate runs. Both directions are pinned so this - * fails if EITHER door moves. - */ - it('single door: destructive 409 with the remedy — compound door: grammar 400', async () => { +describe('[#12194 / #12195] a slash-bearing name is refused at the surviving door', () => { + it('⭐ answers 400 INVALID_REQUEST and stores NOTHING', async () => { const stack = boot(); - const compound = await stack.compoundPut(); - const single = await stack.singlePut(); + const answer = await stack.singlePutNamed(COMPOUND_NAME); - expect(single.status).toBe(409); - expect(single.body?.code).toBe('DESTRUCTIVE_CHANGE'); - expect(single.body?.error).toContain(PUT_REMEDY); - expect(compound.status).toBe(400); - expect(compound.body?.code).toBe('INVALID_REQUEST'); + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('INVALID_REQUEST'); expect(stack.compoundFields()).toEqual(STORED_FIELDS); - expect(stack.singleFields()).toEqual(STORED_FIELDS); }); - it('⭐ `?force=true` is honoured ONLY where the name is legal', async () => { + it('⭐ `?force=true` changes NOTHING — force cannot acknowledge a grammar violation', async () => { const stack = boot(); - const compound = await stack.compoundPut({ force: 'true' }); - const single = await stack.singlePut({ force: 'true' }); + const refused = await stack.singlePutNamed(COMPOUND_NAME); + const forced = await stack.singlePutNamed(COMPOUND_NAME, { force: 'true' }); - // The single door's #11095 fix stands: acknowledged, 200, landed. - expect(single.status).toBe(200); - expect(stack.singleFields()).toEqual(SHRUNK_FIELDS); - // The compound door refuses the NAME before reading the flag. - expect(compound.status).toBe(400); + expect(refused.status).toBe(400); + expect(forced.status).toBe(400); + expect(forced.body?.code).toBe('INVALID_REQUEST'); expect(stack.compoundFields()).toEqual(STORED_FIELDS); }); - it('and the twin is UNTOUCHED — its request shape is what it always was', async () => { + it('and does NOT prescribe `?force=true` — the remedy belongs to the destructive gate alone', async () => { const stack = boot(); - await stack.singlePut({ force: 'true' }); + const answer = await stack.singlePutNamed(COMPOUND_NAME); - // The fence. This card threads a parameter on the compound door; 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, force: true, writeFace: 'meta-envelope', - }); + expect(String(answer.body?.error ?? answer.body?.message ?? '')).not.toContain(PUT_REMEDY); }); }); diff --git a/packages/rest/src/meta-compound-save-mode-parity.test.ts b/packages/rest/src/meta-compound-save-mode-parity.test.ts index b3ff291fc1..3c3d6a0d4a 100644 --- a/packages/rest/src/meta-compound-save-mode-parity.test.ts +++ b/packages/rest/src/meta-compound-save-mode-parity.test.ts @@ -1,30 +1,16 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#11712] `?mode=draft` on the compound-name `PUT /api/v1/meta/:type/:section/:name` - * — the FIFTH divergence closed on this door pair, and the first one whose - * harmful direction is a silent WRITE rather than a silent refusal. + * [#11712 → #12195] `?mode=draft` on the `/meta` save doors — a two-door parity + * suite whose SECOND DOOR NO LONGER EXISTS. * - * ## ⚠️ #12194 reversed the compound door's WRITE outcome + * ## What this file is now, and why it was not deleted * - * Stage 1 of #12176 (maintainer ruling 2026-08-25): the item-name grammar - * refuses every slash-bearing name at `saveMetaItem`, so the compound door's - * folded `crm/task` is now refused `400 INVALID_REQUEST` before the lifecycle - * split this file was written about is reached — `?mode=draft` cannot stage a - * slash-named draft any more. The route still folds and still threads `mode` - * (the seam pins below stay true); the single-segment twin keeps the full - * #11712 contract; the repeated-parameter guard still answers first. The - * compound-door cases pin the refusal; the twins now DIVERGE BY DESIGN at the - * write (the route retirement itself is D3, #12195). - * - * ## The defect, as measured - * - * ADR-0005's per-item lifecycle stages a write when the caller sends - * `?mode=draft`; `POST /meta/:type/:name/publish` promotes it later. The - * single-segment `PUT /meta/:type/:name` reads that parameter and threads it. - * The compound-name twin built its `saveMetaItem` request field by field and - * `mode` was not one of the fields, so it fell to `saveMetaItem`'s `'publish'` - * default. Driven through the real registered handlers against one store: + * #11712 measured the fifth and worst divergence between + * `PUT /meta/:type/:name` and its compound-name twin + * `PUT /meta/:type/:section/:name`: the twin built its `saveMetaItem` request + * field by field, `mode` was not one of the fields, and it fell to the + * `'publish'` default. One name, spelled two ways: * * ``` * COMPOUND PUT /meta/object/crm/task?mode=draft → 200 {"state":"active"} @@ -34,34 +20,28 @@ * r_2 name=crm_task state=draft label=NEW_LABEL ← staged * ``` * - * One name, spelled two ways, and the parameter is not refused at either door — - * it is honoured at one and dropped at the other, with a `200` both times. The - * caller asked for a staging buffer and got a publish. - * - * ## Why threading, and not refusing the parameter here + * The caller asked for a staging buffer and got a publish, with a `200` and no + * signal at the call site. * - * #7019's ruling, inherited with its reason for the fifth time: this route is - * "word for word the same operation" as its twin — one generic `saveMetaItem` - * reached by a name spelled in two segments — and every divergence found on the - * pair has been closed on that finding (#6603/#7019's `manage_metadata` gate, - * #8805's write-side organization, #7035's 501 envelope, #11095's `?force`). - * #11095's carve-out is for the runtime DISPATCHER, which has no query string - * at all; it does not describe this door, which has one and already reads two - * parameters off it. + * #12176's maintainer ruling (2026-08-25) retired compound metadata item names + * outright. Stage 1 (#12194) declared the item-name grammar and refuses every + * slash-bearing name at the publish door; stage 3 (#12195) un-mounts the arity. + * So the divergence is not fixed — the door it needed is GONE. * - * The fork triage left open — "the draft door may have a real reason to stay - * single-segment only" — was measured and is CLOSED in the negative: + * ⛔ The file is REWORKED rather than deleted, deliberately. "The divergence is + * gone" and "the door is gone" are different facts, and only the second is + * true; a deleted file would take with it the guard against the arity being + * mounted again, which is precisely how the silent-live-publish returns. So: * - * • `saveMetaItem` keys the draft on `type`/`name`/organization/package and - * passes `state` to `repo.put`. Nothing in that path reads the name's - * SHAPE, so `crm/task` is a draft key exactly like `crm_task` is. - * • The ADR-0033 read half is already mounted in BOTH arities — - * `GET /:type/:section/:name/published` (#7526), whose own comment cites the - * SDK's `getPublished('lead', 'views/all_leads')` and calls a compound name - * "how every other read on this surface addresses a sub-resource". - * - * A compound draft is a shape this surface already serves on the read side. - * Only the write door was missing. + * 1. the compound arity is pinned ABSENT (§1) — the removal's own pin; + * 2. the surviving door keeps the FULL #11712 `?mode` contract, asserted per + * spelling (§2) — these were the CONTROL half of the old parity cases and + * are unchanged; + * 3. #6877's repeated-parameter guard is re-pinned on the surviving door (§3); + * 4. the slash-bearing name a caller would once have spelled compound is + * pinned answering #12194's `400 INVALID_REQUEST` at the surviving door + * (§4) — the capability that REPLACED the compound arity, and the case that + * answered `200` + published-live before this retirement. * * ## Why the REAL protocol and not a double * @@ -69,7 +49,7 @@ * is what the write DID. A double that only recorded the request would pass * against a door that names `mode` and a store that ignores it; and a * status-only assertion passes against the UNFIXED door, which answers `200` - * while publishing live — this card's defect exactly. So the gate is the real + * while publishing live — the original defect exactly. So the gate is the real * `ObjectStackProtocolImplementation` over a `sys_metadata`-backed engine and * every case reads the STORE: which row is live, which row is staged, and which * body each of them carries. @@ -86,7 +66,8 @@ * `meta-compound-save-force-parity.test.ts` documents. The `200` save answer is * the protocol's own `{ success, version, seq, state, message }`. The `400` * from `refuseRepeatedQueryParams` is hand-built by the route and NESTED: - * `{ error: { code, message } }`. + * `{ error: { code, message } }`. #12194's grammar refusal is the ADR-0112 + * envelope with a TOP-LEVEL `code` (`INVALID_REQUEST`). */ import { describe, it, expect, vi } from 'vitest'; @@ -288,14 +269,28 @@ function boot() { * naming the outcome — see §5. */ 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 under test. */ - compoundPut: (query: Record = {}) => - call(COMPOUND_PATH, { type: 'object', section: 'crm', name: 'task' }, query), - /** Its single-segment twin — the control, already correct before this card. */ + /** + * [#12195] The compound door's REGISTRATION, not a call to it. This + * used to be `compoundPut()`, driving `PUT COMPOUND_PATH`; the arity is + * retired, so what is assertable now is that nothing is mounted there. + */ + compoundRoute: () => route('PUT', COMPOUND_PATH), + /** Every `/meta` route key this server mounted, for absence sweeps. */ + metaRouteKeys: () => (rest as any).getRoutes() + .map((r: any) => `${String(r.method).toUpperCase()} ${r.path}`) + .filter((k: string) => k.includes(META)), + /** The surviving door. */ singlePut: (query: Record = {}) => call(SINGLE_PATH, { type: 'object', name: SINGLE_NAME }, query), + /** + * [#12195] The surviving door addressed with an ARBITRARY name — the + * shape a caller now uses for a slash-bearing one. Hono decodes `%2F` + * before the handler runs, so the handler sees the raw name and this + * helper hands it over directly, which is the same value. + */ + singlePutNamed: (name: string, query: Record = {}) => + call(SINGLE_PATH, { type: 'object', name }, query), }; } @@ -305,271 +300,181 @@ const STAGED = [LIVE_LABEL, SUBMITTED_LABEL]; const PUBLISHED = [SUBMITTED_LABEL, undefined]; // ═══════════════════════════════════════════════════════════════════════════ -// 1. ⭐ The compound door, `?mode=draft` — the case that fails without the fix +// 1. ⭐ [#12195] The compound door is GONE — the pin the removal owes // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11712 / #12194] compound-name PUT — `?mode=draft` is refused at the grammar gate', () => { - it('⭐ refuses the folded slash name: live row untouched, NOTHING staged', async () => { - const stack = boot(); - - const answer = await stack.compoundPut({ mode: 'draft' }); - - // The grammar gate answers before the lifecycle split is reached: a - // slash-named DRAFT is as refused as a slash-named publish, or the - // staging buffer would become the one channel that still mints slash - // rows (they would surface at promote time instead). - expect(answer.status).toBe(400); - expect(answer.body?.code).toBe('INVALID_REQUEST'); - expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); - }); - - it('⭐ the refusal names the grammar and the dotted prescription', async () => { - const stack = boot(); - - const answer = await stack.compoundPut({ mode: 'draft' }); - - // `handleRouteError`'s body is FLAT — the message string is `error`. - expect(answer.body?.error).toContain('is not a legal metadata item name'); - expect(answer.body?.error).toContain('crm_lead.pipeline'); - }); - - it('threads `mode: \'draft\'` into the protocol request, and only when asked', async () => { - const stack = boot(); - - await stack.compoundPut(); - await stack.compoundPut({ mode: 'draft' }); - - // The seam itself. The pre-fix door reached `saveMetaItem` on BOTH of - // these calls — it simply never named `mode` in either request, which - // is why a request-shape assertion localises the defect that a - // status-only assertion cannot see at all. - expect(stack.seen).toHaveLength(2); - expect(stack.seen[0].mode).toBeUndefined(); - expect(stack.seen[1].mode).toBe('draft'); - // The rest of the request is untouched by this card — same face, same - // compound name assembled from the two segments. - expect(stack.seen[1].name).toBe(COMPOUND_NAME); - expect(stack.seen[1].writeFace).toBe('meta-envelope'); +describe('[#11712 / #12195] the compound-name `PUT` arity is retired', () => { + /** + * ⛔ This file's original subject — "`?mode=draft` is honoured at one door + * and dropped at the other" — is DISSOLVED, not fixed. #12176 retired + * compound metadata item names; #12194 refuses every slash-bearing name at + * the publish door; this stage un-mounts the arity that used to serve them. + * + * The pins are REWORKED rather than deleted, because "the divergence is + * gone" and "the door is gone" are different facts and only the second one + * is true. A deleted file would also delete the guard against the arity + * being mounted again — which is exactly how the #11712 defect (a silent + * live publish where the caller asked for a draft) would return. + */ + it('⭐ mounts no `PUT /meta/:type/:section/:name` at all', () => { + expect( + boot().compoundRoute(), + 'the compound PUT arity is mounted again — it cannot read `?mode`, so a ' + + '`{ mode: "draft" }` save through it publishes LIVE and answers 200 (#11712)', + ).toBeUndefined(); }); - it('the `DRAFT` spelling is refused the same way — case-folding buys no bypass', async () => { - const stack = boot(); - - const answer = await stack.compoundPut({ mode: 'DRAFT' }); - - expect(answer.status).toBe(400); - expect(answer.body?.code).toBe('INVALID_REQUEST'); - expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + it('⭐ mounts no compound `:section` arity of any method', () => { + expect(boot().metaRouteKeys().filter((k: string) => k.includes(':section'))).toEqual([]); }); }); // ═══════════════════════════════════════════════════════════════════════════ -// 2. The fence — publishing is still the default, and still what everything -// that is not `draft` means. GREEN BOTH SIDES: a regression guard, not a -// red-before case. +// 2. The surviving door keeps the FULL #11712 contract — asserted per +// spelling, never assumed. These cases were the CONTROL half of the old +// parity suite and are unchanged. // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11712 / #12194] no `mode` spelling changes the compound refusal — the gate reads the name', () => { +describe('[#11712] the single-segment `PUT` honours every `?mode` spelling', () => { it.each([ - { label: 'no `mode` at all', query: {} }, - { label: 'an explicit `mode=publish`', query: { mode: 'publish' } }, - { label: 'an unrecognised `mode=staged`', query: { mode: 'staged' } }, - { label: 'an empty `mode=`', query: { mode: '' } }, - ])('$label is refused identically — 400, store untouched', async ({ query }) => { + { label: 'no `mode`', query: {}, expected: PUBLISHED }, + { label: '`mode=draft`', query: { mode: 'draft' }, expected: STAGED }, + { label: '`mode=DRAFT`', query: { mode: 'DRAFT' }, expected: STAGED }, + { label: '`mode=publish`', query: { mode: 'publish' }, expected: PUBLISHED }, + { label: '`mode=staged` (unrecognised)', query: { mode: 'staged' }, expected: PUBLISHED }, + ])('⭐ $label', async ({ query, expected }) => { const stack = boot(); - const answer = await stack.compoundPut(query); + const answer = await stack.singlePut(query); - expect(answer.status).toBe(400); - expect(answer.body?.code).toBe('INVALID_REQUEST'); - expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); - // The request REACHED the door (the refusal is the protocol's, not the - // route's), and none of these spellings threaded a `mode`. - expect(stack.seen[0].mode).toBeUndefined(); + expect(answer.status).toBe(200); + expect(stack.singleOutcome()).toEqual(expected); + }); + + it('threads `mode: \'draft\'` into the protocol request, and only when asked', async () => { + const staged = boot(); + const published = boot(); + + await staged.singlePut({ mode: 'draft' }); + await published.singlePut({}); + + expect(staged.seen).toHaveLength(1); + expect(staged.seen[0]).toMatchObject({ + type: 'object', name: SINGLE_NAME, mode: 'draft', writeFace: 'meta-envelope', + }); + // The default is the ABSENCE of the key, not `mode: 'publish'` — a save + // without the option must hand the protocol what it always did. + expect(published.seen).toHaveLength(1); + expect(published.seen[0].mode).toBeUndefined(); }); }); // ═══════════════════════════════════════════════════════════════════════════ -// 3. [#6877] The repeated-parameter guard — the second limb of this card +// 3. [#6877] The repeated-parameter guard on the surviving door. GREEN BOTH +// SIDES of this card — a regression guard, not evidence of the removal. // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11712 / #6877] a REPEATED `?mode` is refused, never read as publish-anyway', () => { +describe('[#11712 / #6877] a REPEATED query parameter is refused, never read as publish-anyway', () => { /** - * #6877's mechanism, unchanged, aimed at the parameter this card threads: a - * repeated `?mode=draft&mode=draft` arrives as an ARRAY, the - * `typeof req.query?.mode === 'string'` test is FALSE for it, and the save - * falls silently back to publishing live — the exact outcome this card - * exists to stop, re-entered through the door the fix opens. The twin has - * listed `mode` since #6877; this door listed two names because it read two - * parameters. Threading the third without naming it here would have shipped - * the guard gap on the same line as the repair. - * - * ⚠️ This narrows the accepted set: a repeated `mode` is answered 200 today - * and 400 after this card. That is the Clause-② limb the changeset states. + * #6877's mechanism: a repeated `?mode=draft&mode=draft` arrives as an + * ARRAY, `typeof req.query?.mode === 'string'` is FALSE for it, and the + * save would fall silently back to publishing live. The guard answers 400 + * BEFORE the door, so nothing reaches `saveMetaItem`. */ it('⛔ `?mode=draft&mode=draft` is a 400 — NOT a silent publish', async () => { const stack = boot(); - const answer = await stack.compoundPut({ mode: ['draft', 'draft'] }); + const answer = await stack.singlePut({ mode: ['draft', 'draft'] }); expect(answer.status).toBe(400); expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); - // Stated as the assertion that would have caught the fall-back: the - // save must not have happened at all, let alone gone live. expect(stack.seen).toHaveLength(0); - expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + expect(stack.singleOutcome()).toEqual([LIVE_LABEL, undefined]); }); it('⛔ `?mode=draft&mode=publish` is refused too — multiplicity, not intent', async () => { const stack = boot(); - const answer = await stack.compoundPut({ mode: ['draft', 'publish'] }); + const answer = await stack.singlePut({ mode: ['draft', 'publish'] }); expect(answer.status).toBe(400); expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); - expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + expect(stack.singleOutcome()).toEqual([LIVE_LABEL, undefined]); }); it('one occurrence encoded as an array still REACHES the door — the guard unwraps, it does not blanket-refuse', async () => { const stack = boot(); - const answer = await stack.compoundPut({ mode: ['draft'] }); + const answer = await stack.singlePut({ mode: ['draft'] }); - // The guard's own verdict would be the nested VALIDATION_ERROR with - // `seen` empty (as the repeated cases above pin). A single - // array-encoded occurrence unwraps and travels: the request reaches - // `saveMetaItem` with `mode: 'draft'` threaded — recorded at the seam — - // where the #12194 grammar gate is what answers now. + expect(answer.status).toBe(200); expect(stack.seen).toHaveLength(1); expect(stack.seen[0].mode).toBe('draft'); - expect(answer.status).toBe(400); - expect(answer.body?.code).toBe('INVALID_REQUEST'); - expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); - }); - - it('the twin refuses a repeated `mode` the same way — it always did', async () => { - const stack = boot(); - - const answer = await stack.singlePut({ mode: ['draft', 'draft'] }); - - expect(answer.status).toBe(400); - expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(stack.singleOutcome()).toEqual(STAGED); }); -}); -// ═══════════════════════════════════════════════════════════════════════════ -// 4. The guard's existing entries must not have moved. GREEN BOTH SIDES — -// this describe block passes before and after the fix, and is reported as a -// regression guard rather than as evidence of the repair. -// ═══════════════════════════════════════════════════════════════════════════ - -describe('[#11712 / #11095] adding `mode` to the list did not disturb `force` or `package`', () => { it('⛔ a repeated `?force` is still a 400, and still writes nothing (#6877 inversion)', async () => { const stack = boot(); - const answer = await stack.compoundPut({ force: ['false', 'false'] }); + const answer = await stack.singlePut({ force: ['false', 'false'] }); expect(answer.status).toBe(400); expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); expect(stack.seen).toHaveLength(0); - expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + expect(stack.singleOutcome()).toEqual([LIVE_LABEL, undefined]); }); it('⛔ a repeated `?package` is still a 400 (#6877, where the guard started)', async () => { const stack = boot(); - const answer = await stack.compoundPut({ package: ['pkg_a', 'pkg_b'] }); + const answer = await stack.singlePut({ 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([LIVE_LABEL, undefined]); + expect(stack.singleOutcome()).toEqual([LIVE_LABEL, undefined]); }); it('a single `?package` still binds the row, and composes with `?mode=draft`', async () => { const stack = boot(); - await stack.compoundPut({ package: 'pkg_a', mode: 'draft' }); + await stack.singlePut({ package: 'pkg_a', mode: 'draft' }); - // Both parameters reached the protocol from the same query string: this - // card added a reader beside the two that were already here, it did not - // replace them. expect(stack.seen).toHaveLength(1); - expect(stack.seen[0]).toMatchObject({ packageId: 'pkg_a', mode: 'draft', name: COMPOUND_NAME }); + expect(stack.seen[0]).toMatchObject({ packageId: 'pkg_a', mode: 'draft', name: SINGLE_NAME }); }); }); // ═══════════════════════════════════════════════════════════════════════════ -// 5. ⭐ [#7019] The twins agree — the ruling this card inherits, executable +// 4. ⭐ [#12194] A slash-bearing name is refused at the GRAMMAR gate — the +// capability that replaced the compound door, pinned where callers meet it. // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11712 / #7019 / #12194] the two `PUT` doors now DIVERGE by design at the write', () => { +describe('[#12194 / #12195] a slash-bearing name is refused at the surviving door', () => { /** - * #7019's "one operation, two spellings" premise is what #12176 retired: - * the compound spelling is no longer a legal way to say the operation. - * The single-segment twin keeps the FULL #11712 `?mode` contract (asserted - * per spelling, never assumed), while every compound write is refused at - * the grammar gate before `mode` matters. Both directions are pinned so - * this fails if EITHER door moves. One agreement survives: a REPEATED - * `mode` is the route guard's own 400 at both doors, because the guard - * runs before either door's verdict. + * With the compound arity gone, `crm/task` reaches the single-segment door + * percent-encoded (`%2F`) — the spelling the SDK now sends for every name, + * and the one Hono decodes back to `crm/task` before the handler sees it. + * What answers is #12194's grammar refusal, with the ADR-0112 envelope. + * + * This is the pin that makes the removal safe to read: the old compound + * door answered `200` and published live for this exact input. */ - it.each([ - { label: 'no `mode`', query: {}, singleOutcome: PUBLISHED }, - { label: '`mode=draft`', query: { mode: 'draft' }, singleOutcome: STAGED }, - { label: '`mode=DRAFT`', query: { mode: 'DRAFT' }, singleOutcome: STAGED }, - { label: '`mode=publish`', query: { mode: 'publish' }, singleOutcome: PUBLISHED }, - { label: '`mode=staged` (unrecognised)', query: { mode: 'staged' }, singleOutcome: PUBLISHED }, - ])('⭐ $label: single door keeps the #11712 contract, compound door refuses', async ({ query, singleOutcome }) => { - const compoundStack = boot(); - const singleStack = boot(); - - const compound = await compoundStack.compoundPut(query); - const single = await singleStack.singlePut(query); - - expect(single.status).toBe(200); - expect(singleStack.singleOutcome()).toEqual(singleOutcome); - expect(compound.status).toBe(400); - expect(compound.body?.code).toBe('INVALID_REQUEST'); - expect(compoundStack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); - }); - - it('a REPEATED `mode` is still refused identically at both doors — the guard answers first', async () => { - const compoundStack = boot(); - const singleStack = boot(); - - const compound = await compoundStack.compoundPut({ mode: ['draft', 'draft'] }); - const single = await singleStack.singlePut({ mode: ['draft', 'draft'] }); - - expect(compound.status).toBe(single.status); - expect(compound.status).toBe(400); - // The guard's NESTED body at both doors — neither request reached a door. - expect(compound.body?.error?.code).toBe(single.body?.error?.code); - expect(compound.body?.error?.code).toBe('VALIDATION_ERROR'); - expect(compoundStack.seen).toHaveLength(0); - expect(singleStack.seen).toHaveLength(0); - }); - - it('and the twin is UNTOUCHED — its request shape is what it always was', async () => { + it('⭐ answers 400 INVALID_REQUEST and stores NOTHING', async () => { const stack = boot(); - await stack.singlePut({ mode: 'draft' }); + const answer = await stack.singlePutNamed(COMPOUND_NAME, { mode: 'draft' }); - // The fence. This card threads a parameter on the compound door; 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, mode: 'draft', writeFace: 'meta-envelope', - }); + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('INVALID_REQUEST'); + // The seeded compound row is untouched: refused before any write. + expect(stack.outcome(COMPOUND_NAME)).toEqual([LIVE_LABEL, undefined]); }); - it('a refused compound write leaves the single door\'s staging untouched — one store, one refusal', async () => { + it('and the twin\'s staging beside it is untouched — one store, one refusal', async () => { const stack = boot(); - // Same fixture, both doors: the compound attempt is refused at the - // grammar gate and must not disturb the twin's staging beside it in - // the same store. - await stack.compoundPut({ mode: 'draft' }); + await stack.singlePutNamed(COMPOUND_NAME, { mode: 'draft' }); await stack.singlePut({ mode: 'draft' }); expect(stack.outcome(COMPOUND_NAME)).toEqual([LIVE_LABEL, undefined]); diff --git a/packages/rest/src/meta-item-layered-route.test.ts b/packages/rest/src/meta-item-layered-route.test.ts index 8f4af1c47f..7c1a8d0494 100644 --- a/packages/rest/src/meta-item-layered-route.test.ts +++ b/packages/rest/src/meta-item-layered-route.test.ts @@ -119,22 +119,28 @@ describe('#5882 GET /meta/:type/:name/layers — the declared layered resource', expect(routeFor(rest, LAYERS_PATH)).toBeDefined(); }); - it('is registered BEFORE the routes that would otherwise capture its path', async () => { - // `/:type/:name` cannot match a 3-segment path, but - // `/:type/:section/:name` CAN — it would bind section=, - // name="layers" and answer an ordinary metadata read for an item called - // "layers". Under a first-match router, registration order is the only - // thing preventing that, so the order is the assertion. + it('has no three-segment catch-all left to capture its path', async () => { + // [#12195] This used to be an ORDER pin. `/:type/:name` cannot match a + // 3-segment path, but `/:type/:section/:name` COULD — it would bind + // section=, name="layers" and answer an ordinary metadata read + // for an item called "layers". Under a first-match router, registration + // order was the only thing preventing that. + // + // The compound arity is retired, so the hazard is gone rather than + // ordered around. The pin inverts: what must stay true is that no + // three-segment catch-all is mounted at all — which is both the fact + // that holds now and the thing a re-mount would break. const rest = new RestServer(mockServer() as any, baseProtocol() as any, ANON_API as any); rest.registerRoutes(); const paths = (rest as any).getRoutes() .filter((r: any) => r.method === 'GET') .map((r: any) => r.path); - const layers = paths.indexOf(LAYERS_PATH); - const compound = paths.indexOf('/api/v1/meta/:type/:section/:name'); - expect(layers).toBeGreaterThanOrEqual(0); - expect(compound).toBeGreaterThanOrEqual(0); - expect(layers).toBeLessThan(compound); + expect(paths.indexOf(LAYERS_PATH)).toBeGreaterThanOrEqual(0); + expect( + paths.filter((p: string) => p.includes(':section')), + 'a compound `:section` arity is mounted again — it captures ' + + `${LAYERS_PATH} as an item read for a metadata item called "layers"`, + ).toEqual([]); }); it('answers the three-layer projection, with the layers SEPARATE', async () => { diff --git a/packages/rest/src/meta-object-fls.test.ts b/packages/rest/src/meta-object-fls.test.ts index 58fb61dcad..68cec49374 100644 --- a/packages/rest/src/meta-object-fls.test.ts +++ b/packages/rest/src/meta-object-fls.test.ts @@ -201,18 +201,11 @@ const EXITS: ObjectSchemaMaskExit[] = [ ); }, }, - { - name: 'GET /meta/:type/:section/:name — compound-name read', - run: (testCase) => { - const { rest } = boot({ testCase, cached: true }); - return outcomeOf( - (res) => routeFor(rest, COMPOUND_PATH)!.handler( - { params: { type: 'object', section: 'crm', name: 'account' }, query: {}, headers: {} }, res, - ), - (body) => body?.item, - ); - }, - }, + // [#12195] The compound-name read `GET /meta/:type/:section/:name` was the + // fourth exit in this table until its arity was retired. Every name reaches + // the single-item read above now, and that exit carries the same ADR-0106 + // masking contract — so the removal costs this table no coverage, it costs + // it a duplicate. { name: 'GET /meta/object — list read', run: (testCase) => { diff --git a/packages/rest/src/meta-plural-i18n.test.ts b/packages/rest/src/meta-plural-i18n.test.ts index f2f480b135..27fa1ee82b 100644 --- a/packages/rest/src/meta-plural-i18n.test.ts +++ b/packages/rest/src/meta-plural-i18n.test.ts @@ -221,13 +221,6 @@ async function itemOf(rest: RestServer, type: string, name: string) { return lastBody(res); } -async function compoundOf(rest: RestServer, type: string, section: string, name: string) { - const res = mockRes(); - await routeFor(rest, '/api/v1/meta/:type/:section/:name').handler( - { method: 'GET', params: { type, section, name }, query: {}, body: {}, headers: ZH }, res, - ); - return lastBody(res); -} /** First element of whichever list shape `getMetaItems` produced. */ const firstItem = (body: any) => (Array.isArray(body) ? body : body?.items ?? [])[0]; @@ -335,23 +328,27 @@ describe('#6349 §2 single item `GET /meta/:type/:name`', () => { }); // --------------------------------------------------------------------------- -// §3 — compound name: GET /meta/:type/:section/:name +// §3 — [#12195] the compound-name read is RETIRED // --------------------------------------------------------------------------- -describe('#6349 §3 compound name `GET /meta/:type/:section/:name`', () => { - it('translates the plural spelling, identically to the singular', async () => { - const rest = makeRest(baseProtocol({ - getMetaItem: vi.fn(async ({ type, name }: any) => ({ - type: canonicalType(type), name, item: documentFor(type), lock: 'none', - })), - })); - - const singular = await compoundOf(rest, 'page', 'portal', 'home'); - const plural = await compoundOf(rest, 'pages', 'portal', 'home'); - - expect(singular.item).toMatchObject({ label: '首页' }); - expect(plural.item).toMatchObject({ label: '首页', description: '门户首页' }); - expect(plural).toEqual(singular); +describe('#6349 §3 — the compound-name arity no longer exists', () => { + /** + * This section drove `GET /meta/:type/:section/:name` and asserted the + * plural type spelling translated identically to the singular there. The + * arity is retired (#12176 stage 3), so the translation surface it covered + * is served by §2's single-item read — which folds the type through the + * same `canonicalMetaUrlType` and runs the same translator. + * + * What is left to pin is the absence, so a re-mounted compound arity cannot + * quietly reappear WITHOUT the plural fold (the #6349 defect: one spelling + * translated, the other not). + */ + it('mounts no compound `:section` arity to translate', () => { + const rest = makeRest(baseProtocol()); + const compound = (rest as any).getRoutes() + .map((r: any) => String(r.path)) + .filter((path: string) => path.includes(':section')); + expect(compound).toEqual([]); }); }); diff --git a/packages/rest/src/meta-published-overlay.test.ts b/packages/rest/src/meta-published-overlay.test.ts index 207d09e506..751b99f6f6 100644 --- a/packages/rest/src/meta-published-overlay.test.ts +++ b/packages/rest/src/meta-published-overlay.test.ts @@ -48,7 +48,6 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco import { RestServer } from './rest-server.js'; const PUBLISHED = '/api/v1/meta/:type/:name/published'; -const PUBLISHED_COMPOUND = '/api/v1/meta/:type/:section/:name/published'; interface Row { id: string; @@ -377,16 +376,27 @@ describe('[#8278] REST `/meta/:type/:name/published` resolves from the published expect(Array.from(rows.values()).filter((r) => r.state === 'active')).toHaveLength(1); + // [#12195] Read through the SINGLE-SEGMENT door, which is where a + // pre-grammar residue row is addressed now. This used to drive the + // compound arity `GET /:type/:section/:name/published` with + // `{ section: 'views', name: 'all_leads' }`, which the handler folded + // back into `views/all_leads`. That arity is retired. + // + // The capability is NOT lost, and this case is the pin for it: a caller + // percent-encodes the name, `%2F` matches `/:type/:name/published` + // (Hono does not split on an encoded slash — measured), and Hono decodes + // the parameter back to `views/all_leads` before the handler runs. So + // the handler receives exactly the value passed below, and #12194's + // "any stored junk name remains listable and clearable" still holds. const res = await callPublished( setup(protocol, metadata), - { type: 'lead', section: 'views', name: 'all_leads' }, - PUBLISHED_COMPOUND, + { type: 'lead', name: 'views/all_leads' }, ); expect(res.statusCode).toBe(200); expect(res.body).toMatchObject({ label: 'All Leads' }); - // The compound name was reassembled and used as ONE key — not split, - // and not truncated to its last segment. + // The name was used as ONE key — not split, and not truncated to its + // last segment. expect(Array.from(rows.values())[0]!.name).toBe('views/all_leads'); }, 60_000); }); diff --git a/packages/rest/src/meta-route-registration-order.test.ts b/packages/rest/src/meta-route-registration-order.test.ts index c07eef4aa2..a6fd7b5f87 100644 --- a/packages/rest/src/meta-route-registration-order.test.ts +++ b/packages/rest/src/meta-route-registration-order.test.ts @@ -6,13 +6,17 @@ * The `/meta` family is the one place on this server where a route's position * in the registration sequence decides whether it can ever run. Hono is * first-match-wins (measured in `plugin-hono-server`'s - * `mounted-route-introspection.test.ts`), and this family carries two + * `mounted-route-introspection.test.ts`), and this family used to carry two * catch-alls that shadow their literal siblings: * * * `GET /meta/:type` swallows every one-segment path — `diagnostics`, * `_drafts`, `types` — that is not registered ahead of it; - * * `GET /meta/:type/:section/:name` swallows every three-segment path — - * `/history`, `/audit`, `/diff`, `/published` — likewise. + * * `GET /meta/:type/:section/:name` swallowed every three-segment path — + * `/history`, `/audit`, `/diff`, `/published` — likewise. [#12195] That + * one is RETIRED with compound-name addressing, so the hazard is gone + * rather than ordered around; the pin below inverted to match, because a + * re-mount is how the hazard comes back and an order pin phrased against + * the retired route would go green while the retirement is undone. * * `GET /meta/types` and `GET /meta/:type/:name/published` were both DEAD in * shipped builds for the second reason apiece: one was never registered at @@ -90,10 +94,35 @@ describe('/meta registration order', () => { } }); - it('registers every three-segment literal BEFORE the compound-name catch-all', () => { + // [#12195] The three compound arities are RETIRED, so the two pins that used + // to live here — "every three-segment literal precedes the compound-name + // catch-all" and "the FSM state read precedes the compound `/published` + // twin" — no longer have a second route to order against. + // + // ⛔ They are NOT deleted as satisfied. Both pinned a SHADOWING hazard, and + // the way that hazard returns is a compound arity being mounted again, at + // which point an order pin phrased against it would go green while the + // retirement it guards is undone. So the pin is INVERTED: the constraint is + // now that these paths are not registered AT ALL, which is the statement + // that actually holds after the removal and the one a re-mount breaks. + it('mounts NO compound `:section` arity — the retired catch-all stays retired', () => { const order = metaRoutesInOrder(); - const catchAll = indexOf(order, 'GET /api/v1/meta/:type/:section/:name'); + const compound = order.filter((key) => key.includes(':section')); + expect( + compound, + 'a compound-name arity is mounted again. #12176 retired compound metadata ' + + 'item names and #12194 refuses every slash-bearing name at the publish door, ' + + 'so this route can only be reached by a name that cannot be created — and as ' + + 'a three-segment catch-all it shadows /history, /audit, /diff, /published ' + + 'and /layers on the way', + ).toEqual([]); + }); + it('keeps every three-segment literal mounted after the catch-all above them went away', () => { + // The companion half: removing the shadowing route must not have removed + // the routes it used to shadow. Without this, the pin above is satisfied + // by deleting the whole family. + const order = metaRoutesInOrder(); for (const literal of [ 'GET /api/v1/meta/:type/:name/published', 'GET /api/v1/meta/:type/:name/history', @@ -102,27 +131,18 @@ describe('/meta registration order', () => { 'GET /api/v1/meta/:type/:name/references', 'GET /api/v1/meta/:type/:name/layers', ]) { - expect( - indexOf(order, literal), - `${literal} is registered AFTER GET /api/v1/meta/:type/:section/:name and is therefore shadowed — ` - + 'it answers the compound-name read instead, which for `published` was a stub identical ' - + 'before publish AND for a bogus name', - ).toBeLessThan(catchAll); + expect(order, `${literal} is no longer registered`).toContain(literal); } }); - it('registers the FSM state read before the compound `/published` twin they collide on', () => { + it('keeps the FSM state read mounted — it is now the ONLY reading of its path', () => { const order = metaRoutesInOrder(); - // The single colliding path is `/meta/object/x/state/published`. Two - // literal segments beat one, so the state-machine reading must win it. - // - // #9180 step 2 deleted the plural twin's arm of this pin along with the - // plural registration — but NOT the pin: the collision is between the FSM - // read and the compound `/published` route, and it outlives the spelling - // that was retired. Deleting the whole pin would have un-guarded the - // surviving route against exactly the #7526 defect it exists to catch. - expect(indexOf(order, 'GET /api/v1/meta/object/:name/state/:field')) - .toBeLessThan(indexOf(order, 'GET /api/v1/meta/:type/:section/:name/published')); + // `/meta/object/x/state/published` used to be a collision: the compound + // `/published` twin read it as "the published version of the compound name + // object/x/state", and only the two literal segments winning kept the FSM + // reading. With the twin retired nothing else matches four segments. + indexOf(order, 'GET /api/v1/meta/object/:name/state/:field'); + expect(order.filter((k) => k.includes(':section') && k.endsWith('/published'))).toEqual([]); }); it('mounts the three routes #7526 found dead', () => { 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..4bf5e89e49 100644 --- a/packages/rest/src/meta-write-door-capability-enumeration.test.ts +++ b/packages/rest/src/meta-write-door-capability-enumeration.test.ts @@ -119,13 +119,10 @@ const DOORS: readonly Door[] = [ protocolMethod: 'rollbackMetaItem', params: { type: 'object', name: 'account' }, body: { toVersion: 1 }, }, - { - label: 'PUT /meta/:type/:section/:name — compound-name save', - method: 'PUT', path: `${META}/:type/:section/:name`, - protocolMethod: 'saveMetaItem', - params: { type: 'object', section: 'views', name: 'all_leads' }, - body: { name: 'all_leads' }, - }, + // [#12195] `PUT /meta/:type/:section/:name — compound-name save` was + // enumerated here until its arity was retired. It reached the same + // `saveMetaItem` as the single-segment save above and carried the same + // capability gate, so the door set loses a spelling, not a capability. ]; /** Every protocol method any door reaches — all present, so 501 is never the answer. */ diff --git a/packages/rest/src/rest-server-meta-org-scope-url-spelling.test.ts b/packages/rest/src/rest-server-meta-org-scope-url-spelling.test.ts index 7f0b94c3aa..38cb6ad584 100644 --- a/packages/rest/src/rest-server-meta-org-scope-url-spelling.test.ts +++ b/packages/rest/src/rest-server-meta-org-scope-url-spelling.test.ts @@ -183,12 +183,6 @@ describe('#10340 the /meta doors decide org scope on the FOLDED type, not the ra }); expect(requestFrom(b2.getMetaItemLayered).organizationId).toBe(ORG); - const b3 = boot(AUTHORIZED); - await b3.drive('GET', `${META}/:type/:section/:name`, { - params: { type: plural, section: 'core', name: 'greeting' }, - }); - expect(requestFrom(b3.getMetaItem).organizationId).toBe(ORG); - const b4 = boot(AUTHORIZED); await b4.drive('DELETE', `${META}/:type/:name`, { params: { type: plural, name: 'greeting' }, @@ -208,12 +202,11 @@ describe('#10340 the /meta doors decide org scope on the FOLDED type, not the ra }); expect(requestFrom(b6.rollbackMetaItem).organizationId).toBe(ORG); - const b7 = boot(AUTHORIZED); - await b7.drive('PUT', `${META}/:type/:section/:name`, { - params: { type: plural, section: 'core', name: 'greeting' }, - body: { label: 'Greeting' }, - }); - expect(requestFrom(b7.saveMetaItem).organizationId).toBe(ORG); + // [#12195] The compound-name GET and PUT were driven here too, + // as the doors most likely to be left org-BLIND while their + // twins were fixed (#9454). Their arity is retired, so the + // spelling map is exercised through the single-segment doors + // above — the same fold, the same scope decision. }); } }); diff --git a/packages/rest/src/rest-server-meta-write-org-scope.test.ts b/packages/rest/src/rest-server-meta-write-org-scope.test.ts index 0f0af16d55..3429079073 100644 --- a/packages/rest/src/rest-server-meta-write-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-write-org-scope.test.ts @@ -117,7 +117,10 @@ function boot(execCtx: any) { return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; }; - return { ...calls, drive }; + /** [#12195] Every mounted route, for absence sweeps. */ + const routes = () => (rest as any).getRoutes(); + + return { ...calls, drive, routes }; } /** The request object the route handed to the protocol. */ @@ -197,16 +200,23 @@ describe('#8805 the REST /meta write doors carry the caller organization', () => }); }); - describe('PUT /meta/:type/:section/:name (compound name)', () => { - it('scopes the compound twin too — gating one door and scoping the other leaves a bypass', async () => { + describe('[#12195] the compound-name PUT twin is retired', () => { + /** + * This drove `PUT /meta/:type/:section/:name` and asserted it carried + * the caller's organization, because #8805 measured that scoping one + * door and not its twin leaves the twin as a bypass — a tenant writing + * through the unscoped spelling reached the env-wide row. + * + * The arity is retired, so the bypass is closed by removal. The pin + * inverts to the absence: a re-mounted compound door arrives org-BLIND + * unless whoever mounts it re-derives #8805. + */ + it('mounts no compound `:section` arity to leave unscoped', () => { const b = boot(AUTHORIZED); - await b.drive('PUT', `${META}/:type/:section/:name`, { - params: { type: OVERRIDABLE, section: 'lead', name: 'all_leads' }, - body: { label: 'All leads' }, - }); - const request = requestFrom(b.saveMetaItem); - expect(request.organizationId).toBe(ORG); - expect(request.name).toBe('lead/all_leads'); + const compound = b.routes() + .map((r: any) => String(r.path)) + .filter((path: string) => path.includes(':section')); + expect(compound).toEqual([]); }); }); diff --git a/packages/rest/src/rest-server-query-multiplicity.test.ts b/packages/rest/src/rest-server-query-multiplicity.test.ts index f4a04d012f..ebd57dee11 100644 --- a/packages/rest/src/rest-server-query-multiplicity.test.ts +++ b/packages/rest/src/rest-server-query-multiplicity.test.ts @@ -307,15 +307,10 @@ describe('#6877 §1 — a repeated single-valued parameter is refused, not resol expect(protocol.diffMetaItem).not.toHaveBeenCalled(); }); - it('GET /meta/:type/:section/:name?package — the compound-name read', async () => { - const { drive, protocol } = boot(); - const answer = await drive('GET', `${META}/:type/:section/:name`, { - params: { type: 'object', section: 'crm', name: 'account' }, - query: { package: ['com.a', 'com.b'] }, - }); - expectRefusal(answer, 'package'); - expect(protocol.getMetaItem).not.toHaveBeenCalled(); - }); + // [#12195] The compound-name read `GET /meta/:type/:section/:name?package` + // was pinned here until its arity was retired. The single-segment read + // above carries the same `?package` refusal, and it is the door every name + // reaches now. it('GET /data/import/jobs?status — the filter that silently stopped filtering', async () => { const { drive, protocol } = boot(); 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..18f4695a7d 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 @@ -338,7 +338,10 @@ const DISPOSITIONS: 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.' }, '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.' }, + // [#12195] `PUT /api/v1/meta/:type/:section/:name` had a disposition here + // until the compound-name arity was retired. Removed rather than kept: this + // file's own stale-entry check treats a disposition for a route that no + // longer exists as a defect, which is exactly the right reading. 'POST /api/v1/email/send': { kind: 'no-record-echo', why: 'Send receipt (message id / status), no object row.' }, diff --git a/packages/runtime/src/domains/meta-put-falsy-body.test.ts b/packages/runtime/src/domains/meta-put-falsy-body.test.ts index e335160bf6..c80188d24c 100644 --- a/packages/runtime/src/domains/meta-put-falsy-body.test.ts +++ b/packages/runtime/src/domains/meta-put-falsy-body.test.ts @@ -175,11 +175,17 @@ describe('#8842 — dispatcher PUT /meta/:type/:name with a falsy body', () => { expect(stack.getMetaItem).not.toHaveBeenCalled(); }); - it('the compound-name form too — a name in two segments is the same operation', async () => { + it('[#12195] the ENCODED spelling too — one two-segment name, same operation', async () => { const stack = boot(); + // This drove `/lead/views/all_leads` — the compound arity, which + // folded the trailing segments into `views/all_leads`. That arity + // is retired (#12176 stage 3), so the same name is addressed + // percent-encoded, which keeps the path at two segments and lands + // on the same `saveMetaItem`. The #8842 falsy-body hole this file + // exists for is a property of that handler, not of the spelling. const res = await stack.dispatcher.handleMetadata( - '/lead/views/all_leads', + '/lead/views%2Fall_leads', ctx(AUTHOR), 'PUT', null, @@ -191,6 +197,20 @@ describe('#8842 — dispatcher PUT /meta/:type/:name with a falsy body', () => { type: 'lead', name: 'views/all_leads', item: {}, }); }); + + it('[#12195] and the retired compound spelling is a located ROUTE_NOT_FOUND', async () => { + const stack = boot(); + + const res = await stack.dispatcher.handleMetadata( + '/lead/views/all_leads', ctx(AUTHOR), 'PUT', null, + ); + + // ADR-0112: code AND status. The falsy-body hole cannot hide behind + // the retired arity — nothing reaches `saveMetaItem` at all. + expect(res.response?.status).toBe(404); + expect(res.response?.body?.error?.code).toBe('ROUTE_NOT_FOUND'); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); }); /** diff --git a/packages/runtime/src/domains/meta-save-capability-gate.test.ts b/packages/runtime/src/domains/meta-save-capability-gate.test.ts index 360f9c014d..2f1bb8acf6 100644 --- a/packages/runtime/src/domains/meta-save-capability-gate.test.ts +++ b/packages/runtime/src/domains/meta-save-capability-gate.test.ts @@ -109,14 +109,19 @@ describe('#7019 — dispatcher PUT /meta/:type/:name: the capability gate', () = expect(stack.storedLabel()).toBe('Account'); }); - it('refuses the compound-name form too — a name in two segments is the same operation', async () => { - // `/metadata/lead/views/all_leads` → type `lead`, name `views/all_leads`. - // The dispatcher reaches ONE `saveMetaItem` for both name shapes, so a - // gate that only covered single-segment names would be no gate at all. + it('[#12195] refuses the ENCODED spelling too — one name, one gate', async () => { + // This drove `/lead/views/all_leads`, the compound arity that folded + // the trailing segments into `views/all_leads`. That arity is retired + // (#12176 stage 3); the same name is addressed percent-encoded, which + // keeps the path at two segments and reaches the same `saveMetaItem`. + // + // The point of the case is unchanged: ONE gate covers every name shape. + // A gate that only covered names without a slash would be no gate at + // all for a pre-grammar residue row. const stack = boot(); const res = await stack.dispatcher.handleMetadata( - '/lead/views/all_leads', + '/lead/views%2Fall_leads', ctx({ userId: 'u_portal', systemPermissions: [] }), 'PUT', { density: 'compact' }, diff --git a/packages/runtime/src/domains/meta-state-plural-tolerance.test.ts b/packages/runtime/src/domains/meta-state-plural-tolerance.test.ts index 322733d01d..16dac90f66 100644 --- a/packages/runtime/src/domains/meta-state-plural-tolerance.test.ts +++ b/packages/runtime/src/domains/meta-state-plural-tolerance.test.ts @@ -168,12 +168,44 @@ describe('dispatcher /meta FSM state read — the deliberate plural tolerance (# 'GET', `/meta/${segment}/task/state/status`, undefined, { from: 'todo' }, CTX(), ); + // [#12195] `ROUTE_NOT_FOUND`, not `RESOURCE_NOT_FOUND`, and the + // change is the retirement showing through. `/meta/objectss/task/ + // state/status` is four segments; the FSM branch requires the two + // literals, so it used to fall into the compound fold, which + // re-joined the tail into the item name `task/state/status` and + // answered a metadata READ that missed — a statement about an + // ITEM. With the fold retired nothing matches four segments, so + // the dispatcher says what is actually true: no such route. + // + // Both directions still refuse, which is what this case is for; + // the envelope is now the more accurate of the two. expect(res.response?.status).toBe(404); - expect(res.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); + expect(res.response?.body?.error?.code).toBe('ROUTE_NOT_FOUND'); expect(res.response?.body?.data).toBeUndefined(); }, ); + it('⭐ [#12195] a compound `/meta` path answers the ROUTE_NOT_FOUND envelope', async () => { + // The retirement's wire pin, on the harness that can reach routing: + // this file resolves a REAL session, so `dispatch()` gets past identity + // instead of answering 401 first. + // + // ADR-0112: code AND status. A bare 404 assertion could not tell this + // apart from the metadata read the old fold answered for the same path + // when no row matched (`RESOURCE_NOT_FOUND`, about an item). + const { dispatcher } = boot(); + + const res = await dispatcher.dispatch( + 'GET', '/meta/lead/views/all_leads', undefined, {}, CTX(), + ); + + expect(res.response?.status).toBe(404); + expect(res.response?.body?.error?.code).toBe('ROUTE_NOT_FOUND'); + // Located, not anonymous: the path is named back to the caller, who is + // most likely still spelling a compound name. + expect(String(res.response?.body?.error?.message ?? '')).toContain('/meta/lead/views/all_leads'); + }); + it('the plural is a TOLERANCE, not a second contract — an unknown object still 404s under it', async () => { // Keeps the pin honest in the other direction: the plural arm is the // same handler with the same refusals, not a laxer door. diff --git a/packages/runtime/src/domains/meta-verb-fallthrough.test.ts b/packages/runtime/src/domains/meta-verb-fallthrough.test.ts index b47b4ab300..3b04d26364 100644 --- a/packages/runtime/src/domains/meta-verb-fallthrough.test.ts +++ b/packages/runtime/src/domains/meta-verb-fallthrough.test.ts @@ -192,11 +192,14 @@ describe('#8848 — an unsupported verb on /metadata/:type/:name', () => { expect(JSON.stringify(res.response?.body)).not.toContain('fields'); }); - it('the compound-name form too — a name in two segments is the same address', async () => { + it('[#12195] the ENCODED spelling too — a slash-bearing name is one address', async () => { const stack = boot(); + // This drove `/meta/lead/views/all_leads`, the compound arity. It + // is retired (#12176 stage 3), so the same name is addressed + // percent-encoded — two segments, same verb dispatch. const res = await stack.dispatcher.dispatch( - 'DELETE', '/meta/lead/views/all_leads', undefined, {}, SESSION(), + 'DELETE', '/meta/lead/views%2Fall_leads', undefined, {}, SESSION(), ); expect(res.response?.status).toBe(405); @@ -204,6 +207,21 @@ describe('#8848 — an unsupported verb on /metadata/:type/:name', () => { expect(stack.getMetaItem).not.toHaveBeenCalled(); }); + it('[#12195] and the retired compound spelling answers ROUTE_NOT_FOUND, not 405', async () => { + const stack = boot(); + + // The two refusals are different facts and must stay + // distinguishable: 405 says "this address exists, that verb does + // not"; 404 ROUTE_NOT_FOUND says "there is no such address". + const res = await stack.dispatcher.dispatch( + 'DELETE', '/meta/lead/views/all_leads', undefined, {}, SESSION(), + ); + + expect(res.response?.status).toBe(404); + expect(res.response?.body?.error?.code).toBe('ROUTE_NOT_FOUND'); + expect(stack.getMetaItem).not.toHaveBeenCalled(); + }); + it('refuses a caller holding no authoring capability the same way', async () => { // The refusal is about the VERB, not about the caller — a reader // must not be able to tell the two apart, and must not be handed diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index b2d0d8ccf9..c3e7a63cc2 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -964,6 +964,22 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin } return { handled: true, response: deps.success({ types: ['object', 'app', 'plugin'] }) }; } - - return { handled: false }; + + // [#12195] A LOCATED refusal, not a bare `{ handled: false }`. + // + // This tail was unreachable until this card: the branches above covered + // zero segments, one segment, and — through the compound fold — every path + // with two or MORE. Retiring the fold makes it reachable for the first + // time, and what reaches it is a `/meta` path with no route: three or more + // segments that is not `/published` or the four-segment FSM `/state/:field`. + // + // `{ handled: false }` would leave the answer to the adapter, which turns + // an unhandled result into a generic `404 'Not Found'` — losing both the + // path and the ADR-0112 code, on the very shape this retirement newly + // produces. "Absence must be loud" (AGENTS.md, Route & surface ownership + // §3): the caller most likely to land here is one still spelling a + // compound name, and they should be told the route does not exist rather + // than be handed an anonymous 404. Same shape `domains/ai.ts` and + // `domains/share-links.ts` already use for their own unmatched sub-paths. + return { handled: true, response: deps.routeNotFound(`/meta${path.startsWith('/') ? '' : '/'}${path}`) }; } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 786dd7181e..b19b97cec0 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -172,34 +172,43 @@ describe('HttpDispatcher', () => { }); }); - it('should handle PUT with compound name (3+ path segments)', async () => { + it('[#12195] should NOT handle a compound name — the 3+ segment fold is retired', async () => { const context = METADATA_AUTHOR(); const body = { density: 'compact' }; - // /metadata/lead/views/all_leads → type='lead', name='views/all_leads' + // `/metadata/lead/views/all_leads` used to resolve as type='lead', + // name='views/all_leads' — the dispatcher's own compound arity, + // folding every trailing segment into one slash-bearing key. + // + // #12176 retired compound metadata item names (maintainer ruling + // 2026-08-25); #12194 refuses every slash-bearing name at the + // publish door, so the fold could only address names that can no + // longer be created; #12195 removes it. The domain now DECLINES, + // and nothing is written. const path = '/lead/views/all_leads'; const result = await dispatcher.handleMetadata(path, context, 'PUT', body); + // A LOCATED refusal: ADR-0112 code AND status, never a bare 404. expect(result.handled).toBe(true); - expect(result.response?.status).toBe(200); - expect(mockProtocol.saveMetaItem).toHaveBeenCalledWith({ - type: 'lead', - name: 'views/all_leads', - item: body, - // [#10888] The compound-name door states the same face — it is - // the same handler and the same envelope. - // - // ⚠️ [#11095] "The compound-name door" here means the - // DISPATCHER's own compound arity (`/lead/views/all_leads`), - // which is the same `if (method === 'PUT')` branch as the case - // above — not `@objectstack/rest`'s `PUT /meta/:type/:a/:b`, - // which is a different file, a different transport, and the one - // that GAINED `?force` under this card. Two unrelated things - // called "the compound-name door" one paragraph apart is exactly - // how a later reader talks themselves into threading `force` - // here too, so: this arity has no query string either. - writeFace: 'meta-dispatch', - }); + expect(result.response?.status).toBe(404); + expect(result.response?.body?.error?.code).toBe('ROUTE_NOT_FOUND'); + expect(mockProtocol.saveMetaItem).not.toHaveBeenCalled(); + }); + + it('[#12195] still handles the ENCODED spelling as ONE two-segment name', async () => { + const context = METADATA_AUTHOR(); + const body = { density: 'compact' }; + // The replacement spelling. This dispatcher splits the RAW path and + // nothing decodes for it, so `%2F` keeps the path at two segments + // and `decodeMetaNameSegment` restores the stored key — which is + // what keeps a pre-grammar residue row addressable here, per + // #12194's "any stored junk name remains listable and clearable". + const result = await dispatcher.handleMetadata('/lead/views%2Fall_leads', context, 'PUT', body); + + expect(result.handled).toBe(true); + expect(mockProtocol.saveMetaItem).toHaveBeenCalledWith( + expect.objectContaining({ type: 'lead', name: 'views/all_leads' }), + ); }); it('should fallback to MetadataService when protocol is missing saveMetaItem', async () => { diff --git a/packages/runtime/src/meta-compound-arity-mint-door.test.ts b/packages/runtime/src/meta-compound-arity-mint-door.test.ts index b74dfac92b..2785baa494 100644 --- a/packages/runtime/src/meta-compound-arity-mint-door.test.ts +++ b/packages/runtime/src/meta-compound-arity-mint-door.test.ts @@ -214,36 +214,63 @@ describe('#8421 — the compound `/meta` arity is not a metadata-type claim', () vi.spyOn(console, 'error').mockImplementation(() => {}); }); - it('refuses the compound-arity write at the wire with the grammar envelope (#12194)', async () => { + it('⭐ [#12195] does not HANDLE the compound arity at all — the fold is gone', async () => { const { engine, dispatcher } = makeStack(); - const res = responseOf(await dispatcher.handleMetadata( + const result = await dispatcher.handleMetadata( '/lead/views/all_leads', ctx(), 'PUT', { name: 'all_leads', label: 'All Leads', columns: ['name'] }, - )); + ); - // The refusal reaches the WIRE as the caller's mistake, not a server - // fault: 400 + code, with the grammar and the dotted prescription in - // the message — and the stored ROW proves nothing was minted under - // the slash key (the pre-#12194 direction stored it as one opaque key). - expect(res.status).toBe(400); - expect(res.body?.error?.code).toBe('INVALID_REQUEST'); - expect(String(res.body?.error?.message ?? '')).toMatch(/is not a legal metadata item name/); - expect(String(res.body?.error?.message ?? '')).toMatch(/crm_lead\.pipeline/); + // The domain answers a LOCATED not-found rather than serving the path. + // Until #12195 it folded `views/all_leads` out of the trailing segments + // and answered — first by minting the row under the slash key + // (pre-#12194), then by refusing it at the grammar gate (#12194). + // Neither happens now: there is no three-segment metadata route. + // + // ADR-0112: code AND status. A bare 404 assertion could not tell this + // apart from the metadata READ the old fold answered for this same path + // when no row matched — that was a `RESOURCE_NOT_FOUND` about an ITEM, + // not a statement about the route. + expect(result.handled).toBe(true); + expect(responseOf(result).status).toBe(404); + expect(responseOf(result).body?.error?.code).toBe('ROUTE_NOT_FOUND'); expect(metaRow(engine, 'lead', 'views/all_leads')).toBeUndefined(); expect(metaRow(engine, 'lead', 'all_leads')).toBeUndefined(); }); - it('…and a deeper compound name is refused the same way', async () => { + it('…and a deeper compound name is declined the same way', async () => { const { engine, dispatcher } = makeStack(); - const res = responseOf(await dispatcher.handleMetadata( + const result = await dispatcher.handleMetadata( '/lead/views/all_leads/columns', ctx(), 'PUT', { name: 'columns', label: 'Columns' }, + ); + + expect(responseOf(result).status).toBe(404); + expect(responseOf(result).body?.error?.code).toBe('ROUTE_NOT_FOUND'); + expect(metaRow(engine, 'lead', 'views/all_leads/columns')).toBeUndefined(); + }); + + it('⭐ [#12195] the ENCODED spelling still reaches the grammar gate — the capability that replaced the arity', async () => { + const { engine, dispatcher } = makeStack(); + + // A caller who genuinely means the name `views/all_leads` percent-encodes + // it, which keeps the path at TWO segments. `decodeMetaNameSegment` + // restores the stored spelling, and #12194's grammar is what answers — + // 400 with the dotted prescription, the caller's mistake named as such. + // + // This is the pin that separates "the route is gone" from "the name is + // illegal": they are different facts and they answer differently. + const res = responseOf(await dispatcher.handleMetadata( + '/lead/views%2Fall_leads', ctx(), 'PUT', + { name: 'all_leads', label: 'All Leads', columns: ['name'] }, )); expect(res.status).toBe(400); expect(res.body?.error?.code).toBe('INVALID_REQUEST'); - expect(metaRow(engine, 'lead', 'views/all_leads/columns')).toBeUndefined(); + expect(String(res.body?.error?.message ?? '')).toMatch(/is not a legal metadata item name/); + expect(String(res.body?.error?.message ?? '')).toMatch(/crm_lead\.pipeline/); + expect(metaRow(engine, 'lead', 'views/all_leads')).toBeUndefined(); }); it('ANTI-VACUITY — the same object name at the SIMPLE arity is still refused', async () => { @@ -278,13 +305,16 @@ describe('#8421 — the compound `/meta` arity is not a metadata-type claim', () expect(metaRow(engine, 'webhook', 'midnight_hook')).toBeDefined(); }); - it('CONTROL — the capability gate still fires first on the compound form', async () => { + it('CONTROL — the capability gate still fires first at the SIMPLE arity', async () => { // #7019's gate is what masked this site, and it must keep masking an - // UNAUTHORIZED caller: the fix moved the door behind it, not the gate. + // UNAUTHORIZED caller. [#12195] Driven at the simple arity now: the + // compound form this used to use is no longer handled at all, so it + // would answer ROUTE_NOT_FOUND before any gate — which would make this + // a control over nothing. const { engine, dispatcher } = makeStack(); const res = responseOf(await dispatcher.handleMetadata( - '/lead/views/all_leads', + '/lead/all_leads', { request: { headers: {} }, environmentId: 'env_1', executionContext: { userId: 'u', systemPermissions: [] } } as any, 'PUT', { name: 'all_leads', label: 'All Leads' }, @@ -292,6 +322,6 @@ describe('#8421 — the compound `/meta` arity is not a metadata-type claim', () expect(res.status).toBe(403); expect(res.body?.error?.code).toBe('PERMISSION_DENIED'); - expect(metaRow(engine, 'lead', 'views/all_leads')).toBeUndefined(); + expect(metaRow(engine, 'lead', 'all_leads')).toBeUndefined(); }); }); From 2f568fd0e9858c54da995a141305a0524d3cdf63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 22:39:57 +0000 Subject: [PATCH 3/3] test(rest,dogfood): pay the six new TEST_DEBT errors and rework the dogfood compound-door rows (#12195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin rework left four dead constants and two off-type boot calls in the rest test layer — TEST_DEBT measured 161 against the frozen 155; all six are fixed, none ledgered. The dogfood anonymous-deny table drops the retired compound-save row (its registered-door leg asserts .not.toBe(404), which is what a retired route answers) and gains the retired-door case: the compound spelling must 404 for anonymous and member alike, since an auth floor only speaks for a door that exists. The authz-conformance matrix note re-tallies six -> five with the retirement named. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01H9StxQgG2DPA26XzZZqnJB --- .../dogfood/test/authz-conformance.matrix.ts | 2 +- ...se-anonymous-deny-surfaces.dogfood.test.ts | 43 +++++++++++++++---- packages/rest/src/meta-501-envelope.test.ts | 1 - ...und-save-and-reset-capability-gate.test.ts | 11 ++--- packages/rest/src/meta-object-fls.test.ts | 1 - 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 7afd28f437..7e7c5986e4 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -135,7 +135,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ enforcement: 'rest/rest-server.ts registerMetadataEndpoints guarded registrar (enforceAuth → shouldDenyAnonymous) — every /meta route inherits the gate; runtime/http-dispatcher.ts handleMetadata mirrors it for the dispatcher metadata catch-all', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', covers: ['meta:rest-server.ts:registerMetadataEndpoints', 'meta:http-dispatcher.ts:handleMetadata'], - note: '#11373 — for most of this row\'s life the cited proof drove ONE anonymous `GET /meta`, so the row read as covering a surface while only its read face was exercised. The six mutating doors (`_migrate-stored`, the single and compound saves, the reset, publish, rollback) are now driven there as real HTTP: measured 2026-08-23 on the booted showcase, all six answer 401 UNAUTHENTICATED in the rest-flat envelope, nothing persists, and the same URL/method/body with a session answers 403 (member) or runs the door (admin) — so the 401 is the floor and not a broken probe. The write half was previously pinned only in `rest/src/meta-write-door-capability-enumeration.test.ts`, which invokes `route.handler` over a `vi.fn()` transport and therefore could not show that the composed app routes a real request into the guarded registrar at all.' }, + note: '#11373 — for most of this row\'s life the cited proof drove ONE anonymous `GET /meta`, so the row read as covering a surface while only its read face was exercised. The mutating doors (`_migrate-stored`, the single save, the reset, publish, rollback — six when measured, five since #12176 D3 retired the compound save) are now driven there as real HTTP: measured 2026-08-23 on the booted showcase, every mounted door answers 401 UNAUTHENTICATED in the rest-flat envelope, nothing persists, and the same URL/method/body with a session answers 403 (member) or runs the door (admin) — so the 401 is the floor and not a broken probe; the retired compound spelling has its own case there pinning 404-for-everyone, since an auth floor only speaks for a door that exists. The write half was previously pinned only in `rest/src/meta-write-door-capability-enumeration.test.ts`, which invokes `route.handler` over a `vi.fn()` transport and therefore could not show that the composed app routes a real request into the guarded registrar at all.' }, // #5519 — the two DISPATCHER-mounted execution surfaces. `@objectstack/rest` // gated `/data` and `/meta`; these routes are mounted by a SECOND // registration path (dispatcher-plugin.ts, straight onto the host diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index 378a8f9e5d..e81e3df5e4 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -22,10 +22,11 @@ // the shape the leak it was built for had. objectui#5828 then asked the other // half — a guest / previewMode boot writes an `@anon` metadata seed, so does the // server refuse an anonymous WRITE? — and no artifact in this repo answered it -// end-to-end. The six mutating routes are now driven here as real HTTP. The -// reading is recorded in full at the door table below; the short version is that -// the umbrella already refused all six, so #11373 is a measurement plus its pin, -// not a fix. +// end-to-end. The mutating routes are now driven here as real HTTP (six when +// #11373 measured; five since #12176 D3 retired the compound save — see the +// retired-door case beside the table). The reading is recorded in full at the +// door table below; the short version is that the umbrella already refused all +// of them, so #11373 is a measurement plus its pin, not a fix. // // ── Why `/actions` and `/automation` are here (#5570) ──────────────────────── // @@ -91,7 +92,7 @@ const FLOW = 'showcase_reassign_wizard'; // // It was not that the refusal was unpinned — it was pinned in the WRONG LAYER. // `packages/rest/src/meta-write-door-capability-enumeration.test.ts` already -// asserts a 401 on all six doors, but it constructs a `RestServer` over a +// asserts a 401 on the mutating doors, but it constructs a `RestServer` over a // `vi.fn()` transport and invokes `route.handler(req, res)` directly. That // proves the handler wrapper refuses a context it was handed; it cannot prove // the composed app routes a real `PUT /api/v1/meta/...` into that wrapper at @@ -99,7 +100,7 @@ const FLOW = 'showcase_reassign_wizard'; // browser actually meets. // // So these cases drive the doors as HTTP, on the same booted showcase the rest -// of this file uses. Measured 2026-08-23 on this stack, all six doors: +// of this file uses. Measured 2026-08-23 on this stack, all doors then mounted: // anonymous → 401 UNAUTHENTICATED, rest-flat envelope // member (no manage_metadata) → 403 FORBIDDEN ← a DIFFERENT gate // dev admin, same URL/method/body→ the door runs; `PUT` persisted and the @@ -123,7 +124,17 @@ interface MetaWriteDoor { readonly body?: unknown; } -/** The six mutating `/meta` routes `registerMetadataEndpoints` composes. */ +/** + * The five mutating `/meta` routes `registerMetadataEndpoints` composes. + * + * There were six when #11373 measured: `PUT /meta/:type/:section/:name` (the + * compound save) was retired by #12176 D3 — the item-name grammar (#12194) + * refuses every slash-bearing name, so the arity addressed only names that can + * no longer exist. A retired door cannot sit in this table: the registered-door + * anti-vacuity leg below asserts `.not.toBe(404)`, which is exactly what a + * retired route answers. Its own case lives beside these — the compound + * spelling must now 404 for EVERYONE, anonymous or not. + */ const META_WRITE_DOORS: readonly MetaWriteDoor[] = [ { seam: 'POST /meta/_migrate-stored', method: 'POST', path: '/meta/_migrate-stored', body: {} }, { @@ -135,9 +146,11 @@ const META_WRITE_DOORS: readonly MetaWriteDoor[] = [ { seam: 'DELETE /meta/:type/:name (reset)', method: 'DELETE', path: `/meta/object/${META_PROBE_OBJECT}` }, { seam: 'POST /meta/:type/:name/publish', method: 'POST', path: `/meta/object/${META_PROBE_OBJECT}/publish`, body: {} }, { seam: 'POST /meta/:type/:name/rollback', method: 'POST', path: `/meta/object/${META_PROBE_OBJECT}/rollback`, body: { toVersion: 1 } }, - { seam: 'PUT /meta/:type/:section/:name (compound save)', method: 'PUT', path: `/meta/object/views/${META_PROBE_VIEW}`, body: { name: META_PROBE_VIEW } }, ]; +/** The retired compound-save spelling (#12176 D3) — routed nowhere, for anyone. */ +const RETIRED_COMPOUND_PATH = `/meta/object/views/${META_PROBE_VIEW}`; + // ── #5632 — the TWO declared anonymous-401 envelopes, as executable rules ─── // // `ANONYMOUS_DENY_BODY`'s docstring in `@objectstack/core` used to call itself @@ -302,6 +315,20 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => expect(body.code).toBe('RESOURCE_NOT_FOUND'); }); + it('[#12176 D3] the retired compound save routes NOWHERE — 404 for everyone, not a 401', async () => { + // The compound arity used to be the sixth row of the table above. Retired, + // it must answer the same 404 to an anonymous caller and to a member: a + // 401 here would mean the route is BACK (an auth floor only speaks for a + // door that exists), and #6603's history says a re-mounted compound door + // arrives ungated. The composed-app leg of that statement lives here; the + // registrar-level absence pins live in + // `rest/src/meta-compound-save-and-reset-capability-gate.test.ts`. + const anonRes = await anon('PUT', RETIRED_COMPOUND_PATH, { name: META_PROBE_VIEW }); + expect(anonRes.status, 'retired compound save must be unrouted, not auth-refused').toBe(404); + const memberRes = await stack.apiAs(memberToken, 'PUT', RETIRED_COMPOUND_PATH, { name: META_PROBE_VIEW }); + expect(memberRes.status, 'a session must not change the answer on a route that is gone').toBe(404); + }); + // ── /data (surface-level; served by @objectstack/rest, its sole owner) ── it('anonymous READ of the data surface is denied (401)', async () => { const r = await stack.api(OBJ, { method: 'GET' }); diff --git a/packages/rest/src/meta-501-envelope.test.ts b/packages/rest/src/meta-501-envelope.test.ts index 7d6418c1cd..602e4f639a 100644 --- a/packages/rest/src/meta-501-envelope.test.ts +++ b/packages/rest/src/meta-501-envelope.test.ts @@ -63,7 +63,6 @@ import { RestServer } from './rest-server.js'; const MIGRATE_PATH = '/api/v1/meta/_migrate-stored'; const SINGLE_PATH = '/api/v1/meta/:type/:name'; -const COMPOUND_PATH = '/api/v1/meta/:type/:section/:name'; function mockServer() { return { diff --git a/packages/rest/src/meta-compound-save-and-reset-capability-gate.test.ts b/packages/rest/src/meta-compound-save-and-reset-capability-gate.test.ts index e1fcda9976..61fe4760e7 100644 --- a/packages/rest/src/meta-compound-save-and-reset-capability-gate.test.ts +++ b/packages/rest/src/meta-compound-save-and-reset-capability-gate.test.ts @@ -43,11 +43,6 @@ import { RestServer } from './rest-server'; const copy = (value: T): T => JSON.parse(JSON.stringify(value)); -/** The four fields `FLS_CONTRACT_OBJECT` declares, sorted. */ -const ALL_FIELDS = ['bonus_formula', 'id', 'name', 'salary_grade']; -/** What the security double lets a restricted caller read. */ -const READABLE_TO_RESTRICTED = ['id', 'name']; - const COMPOUND_PATH = '/api/v1/meta/:type/:section/:name'; const SINGLE_PATH = '/api/v1/meta/:type/:name'; /** The compound name the section + name params spell. */ @@ -187,8 +182,10 @@ describe('[#7019 / #12195] the compound-name arity is retired', () => { * `meta-item-save-capability-gate.test.ts`; it is not duplicated here. */ it('⭐ mounts neither GET nor PUT at /meta/:type/:section/:name', () => { + // Mounting happens at boot, before any caller exists — the anonymous + // boot is the honest probe here, not a permissioned one. expect( - boot({ systemPermissions: [] }).compoundRoutes(), + boot({ context: undefined }).compoundRoutes(), 'a compound-name arity is mounted again. It was #6603\'s gate bypass ' + 'until #7019, and a fresh mount does not inherit that gate', ).toEqual([undefined, undefined]); @@ -196,7 +193,7 @@ describe('[#7019 / #12195] the compound-name arity is retired', () => { it('⭐ mounts no compound `:section` arity of any method', () => { expect( - boot({ systemPermissions: [] }).metaRouteKeys().filter((k: string) => k.includes(':section')), + boot({ context: undefined }).metaRouteKeys().filter((k: string) => k.includes(':section')), ).toEqual([]); }); }); diff --git a/packages/rest/src/meta-object-fls.test.ts b/packages/rest/src/meta-object-fls.test.ts index 68cec49374..3a352e4179 100644 --- a/packages/rest/src/meta-object-fls.test.ts +++ b/packages/rest/src/meta-object-fls.test.ts @@ -135,7 +135,6 @@ async function outcomeOf( const SINGLE_PATH = '/api/v1/meta/:type/:name'; const LIST_PATH = '/api/v1/meta/:type'; -const COMPOUND_PATH = '/api/v1/meta/:type/:section/:name'; const EXITS: ObjectSchemaMaskExit[] = [ {