From 33997e0c2844b59321ece18445e1364f2eda48c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 11:22:25 +0000 Subject: [PATCH 1/2] fix(metadata-protocol,spec): the plural /meta URL stops walking around the two-tier registry gate (#7894) canonicalMetaType folded plural to singular through PLURAL_TO_SINGULAR, which is a MANIFEST-COLLECTION map. Four registry types are legitimately absent from it -- field, seed, external_catalog, translation -- because none is a stack collection. At the URL boundary that absence read as "unknown type", and an unknown type takes the plugin-registered path, which every authorization gate is permissive toward by construction. PUT /meta/fields/showcase_task.title answered 200 and persisted a row under type='fields' while PUT /meta/field/... answered 403 NOT_OVERRIDABLE. Split the two roles: META_URL_TO_SINGULAR is the URL-spelling contract, derived from DEFAULT_METADATA_TYPE_REGISTRY and unioned with every manifest spelling, so a newly declared type cannot arrive unmapped and nothing that resolved before resolves differently. PLURAL_TO_SINGULAR is untouched, so the authoring lint gains no fields: collection. The boundary also refuses an unrecognised plural of a DECLARED type with INVALID_REQUEST/400 instead of forwarding it as a plugin type. The rule is static -- it fires only when a spelling's singular is a declared type -- so a plugin-registered kind can never trip it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --- .changeset/meta-plural-url-bypass.md | 67 ++++++ packages/metadata-protocol/src/protocol.ts | 60 +++++- .../src/meta-field-overlay-lock.test.ts | 163 +++++++++++--- packages/spec/api-surface/shared.json | 7 +- packages/spec/export-origins/shared.json | 7 +- packages/spec/src/shared/index.ts | 4 + .../src/shared/metadata-url-spelling.test.ts | 167 ++++++++++++++ .../spec/src/shared/metadata-url-spelling.ts | 203 ++++++++++++++++++ 8 files changed, 638 insertions(+), 40 deletions(-) create mode 100644 .changeset/meta-plural-url-bypass.md create mode 100644 packages/spec/src/shared/metadata-url-spelling.test.ts create mode 100644 packages/spec/src/shared/metadata-url-spelling.ts diff --git a/.changeset/meta-plural-url-bypass.md b/.changeset/meta-plural-url-bypass.md new file mode 100644 index 0000000000..8d8daf6892 --- /dev/null +++ b/.changeset/meta-plural-url-bypass.md @@ -0,0 +1,67 @@ +--- +"@objectstack/spec": patch +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol,spec): the plural `/meta` URL stops walking around the two-tier registry gate (#7894) + +`canonicalMetaType` — the ONE canonical spelling of a metadata type at the `/meta` +read/write/delete boundary (#4432) — folded plural to singular through +`PLURAL_TO_SINGULAR`. That map is a MANIFEST-COLLECTION map: its keys are the +properties an author writes in `defineStack()` (`objects: [...]`, `apps: [...]`), +and `kernel/metadata-authoring-lint.ts` iterates it to decide which stack-level +collections exist. + +Four registry types are legitimately absent from it, because none of them is a +stack collection: `field` (fields live inside `ObjectSchema.fields`), `seed`, +`external_catalog` and `translation`. At the URL boundary that absence did not +read as "not a collection" — it read as "unknown type", and an unknown type takes +the PLUGIN-REGISTERED path, which every authorization gate is permissive toward by +construction: `isRuntimeCreateAllowed` synthesises `allowRuntimeCreate: true`, +`orgScopedWriteRefusal` returns `null` for anything with no static registry entry, +and `SysMetadataRepository.assertAllowed` returns early. + +So, measured on a booted showcase with an admin bearer: + + PUT /api/v1/meta/field/showcase_task.title 403 NOT_OVERRIDABLE + PUT /api/v1/meta/fields/showcase_task.title 200 "Saved fields '...'" + +The plural URL was a door around the singular URL's lock, and the row persisted +under `type='fields'` — a second namespace for the same item, which is the defect +class #4432 was filed about. `field` was exploitable today; `seed` and +`external_catalog` were structurally exposed. + +**The fix splits the two roles.** A new `META_URL_TO_SINGULAR` in +`@objectstack/spec/shared` is the URL-spelling contract, DERIVED from +`DEFAULT_METADATA_TYPE_REGISTRY` (Prime Directive #8) and unioned with every +existing manifest spelling, so: + +- a newly DECLARED metadata type arrives with its URL spelling already mapped and + can never again fall through to the plugin path — hand-adding the four missing + keys would have fixed only today's four; +- no spelling that resolved before resolves differently now, including the six + that name plugin-registered kinds with no registry entry at all (`themes`, + `webhooks`, `connectors`, `sharingRules`, `ragPipelines`, `analyticsCubes`) and + the camelCase forms (`emailTemplates`); +- `external_catalog` and `email_template` become addressable in snake plural + (`external_catalogs`, `email_templates`) as well as camelCase. + +`PLURAL_TO_SINGULAR` is left untouched, so the authoring lint gains no `fields:` +collection — a top-level `fields: [...]` does not exist and would collide +conceptually with `ObjectSchema.fields`. + +The boundary also stops forwarding a spelling it cannot honour. An unrecognised +plural of a DECLARED type (`/meta/capabilitys`) is now refused with +`INVALID_REQUEST` / `400`, naming both the offending spelling and the canonical +one, instead of answering 200 and minting a namespace under the typo. The rule is +deliberately static — it fires only when a spelling's singular is a type the +platform itself declares — so a plugin-registered runtime kind can never trip it, +whatever it is named and whenever it registers. + +Behaviour change to be aware of when upgrading: `PUT`/`DELETE` against +`/meta/fields/...`, `/meta/seeds/...`, `/meta/translations/...` and +`/meta/external_catalogs/...` are now judged by the singular type's contract — +authorization gates AND its Zod schema. A call that previously succeeded because +the spelling was unknown may now be correctly refused. Rows already written under +a plural `type` are real and are not rewritten on upgrade (reads of data at rest +already try the other spelling). diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 52b3f44dc5..d1bae3c31f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -60,7 +60,7 @@ import { type QueryAliasConflict, type QueryAliasSlot, type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed, } from '@objectstack/spec/data'; -import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; +import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, unmappedDeclaredTypeSpelling, restPluralOfMetaType } from '@objectstack/spec/shared'; import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; import { type FormView, isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec/ui'; import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; @@ -147,13 +147,67 @@ const TYPE_TO_FORM: Readonly> = METADATA_FORM_REGISTRY; * not N dialects. Reads of data AT REST still try the other spelling as a * fallback — rows written under a plural `type` before this fix are real, and * nothing rewrites them on upgrade. + * + * ## [#7894] It folds through the URL map, NOT the manifest map + * + * This used to read `PLURAL_TO_SINGULAR`, which is a MANIFEST-COLLECTION map + * (`objects: [...]`, `apps: [...]`) that `metadata-authoring-lint.ts` iterates + * to decide which stack-level collections exist. Four registry types are + * legitimately absent from it because they are not stack collections — + * `field`, `seed`, `external_catalog`, `translation`. At this boundary that + * absence did not read as "not a collection", it read as "unknown type", and an + * unknown type takes the PLUGIN path, which every authorization gate is + * permissive toward by construction. So `PUT /meta/fields/showcase_task.title` + * answered 200 and persisted a row under `type='fields'` — a second namespace + * for the same item, the #4432 defect exactly — while `PUT /meta/field/...` + * answered 403 NOT_OVERRIDABLE. The plural URL was a door around the singular + * URL's lock. + * + * {@link canonicalMetaUrlType} reads a map DERIVED from + * `DEFAULT_METADATA_TYPE_REGISTRY` (unioned with every manifest spelling, so + * nothing that resolved before resolves differently), which is why a newly + * declared type cannot arrive unmapped. Adding the four missing keys to the + * manifest map by hand would have fixed only today's four — and would have + * advertised a `fields: [...]` stack collection that does not exist. + * + * ⛔ Do not "fix" a future instance of this by teaching a predicate one layer + * down (`isNestedArtifactField` and friends) to accept a plural. That is the + * spelling-tolerant lookup this comment has rejected since #4432, and it would + * still persist the row under the plural `type`. */ function canonicalMetaType(type: string): string { - return PLURAL_TO_SINGULAR[type] ?? type; + return canonicalMetaUrlType(type); } -/** {@link canonicalMetaType} applied to a `{ type }` request, without mutating the caller's object. */ +/** + * {@link canonicalMetaType} applied to a `{ type }` request, without mutating the + * caller's object — and the one checkpoint that REFUSES a spelling it cannot + * honour instead of forwarding it to the plugin path (#7894). + * + * Applied here rather than inside {@link canonicalMetaType} on purpose: this is + * the request boundary (all six `/meta` entry points funnel through it), while + * `canonicalMetaType` is also called on read EXITS by `governServedItem` and + * `stripServedSystemColumns`, where the type is already canonical and a throw + * would be a bug rather than a refusal. + * + * The refusal is deliberately narrow: {@link unmappedDeclaredTypeSpelling} + * fires only for a spelling whose singular is a type the platform itself + * DECLARES, so a plugin-registered runtime kind can never trip it. See that + * function for why the rule is static rather than a live-registry lookup. + */ function canonicalizeMetaRequestType(request: T): T { + const declared = unmappedDeclaredTypeSpelling(request.type); + if (declared) { + const err = new Error( + `[invalid_request] '${request.type}' is not a recognised spelling of metadata type ` + + `'${declared}'. Address it as '${declared}' or '${restPluralOfMetaType(declared)}'. ` + + `Refused rather than treated as a plugin-registered type, because forwarding an unrecognised ` + + `spelling of a declared type would create a second namespace under type='${request.type}'.`, + ); + (err as any).code = 'INVALID_REQUEST'; + (err as any).status = 400; + throw err; + } const type = canonicalMetaType(request.type); return type === request.type ? request : { ...request, type }; } diff --git a/packages/runtime/src/meta-field-overlay-lock.test.ts b/packages/runtime/src/meta-field-overlay-lock.test.ts index 117c958f13..08a321c82a 100644 --- a/packages/runtime/src/meta-field-overlay-lock.test.ts +++ b/packages/runtime/src/meta-field-overlay-lock.test.ts @@ -71,7 +71,14 @@ * view override 200 200, unchanged → GREEN * dashboard 200 200, unchanged → GREEN * job 403 403, unchanged → GREEN - * plural spelling 200 200, unchanged (KNOWN GAP #7894) → GREEN + * plural spelling 403 200, row under type='fields' (#7894) → RED + * + * The last row INVERTED when #7894 landed. It used to assert the defect (200, + * plus a row under the plural key) and carried instructions to flip it; the + * flip is done, and because the old case had really measured that 200, the new + * 403 cannot be passing by never reaching the boundary. See the #7894 block at + * the bottom of this file for the plural fold, its positive controls, and the + * refusal limb. * * The greens are NOT slack. Four of them are the negative direction the card * demanded: `object` / `view` / `dashboard` / `job` were measured as ALREADY @@ -478,34 +485,17 @@ describe('#7743 — PUT /meta/field/. honours the registry overla expect(metaRow(engine, 'job', JOB.name)).toBeUndefined(); }); - // ── A HOLE THIS CARD DOES NOT CLOSE, pinned so it cannot go quiet ───── - - it('KNOWN GAP #7894 — the PLURAL url spelling still walks around this lock', async () => { - // ⚠️ This case asserts a DEFECT, deliberately. It was written expecting - // 403, measured 200, and confirmed against the live showcase: with the - // fix in place, `/meta/field/showcase_task.title` is refused while - // `/meta/fields/showcase_task.title` is accepted and persists a row. - // - // The cause is one layer up and is NOT `field`-specific. - // `canonicalMetaType` (#4432) folds plural→singular through - // `PLURAL_TO_SINGULAR`, which is the MANIFEST COLLECTION map — and it - // has no `fields` key (nor `seeds`, `external_catalogs`, - // `translations`). An unmapped spelling is therefore read as an - // unregistered PLUGIN type, which every gate treats as permissive by - // construction: `assertAllowed` returns early on - // `!STATIC_REGISTRY_TYPES.has('fields')`, and `orgScopedWriteRefusal` - // says so in as many words. Each of those is correct for a type that - // really is plugin-registered; `'fields'` just is not one. - // - // ⛔ The one-word fix — teaching `isNestedArtifactField` to accept - // `'fields'` — is the WRONG shape and was rejected: it is a - // spelling-tolerant lookup below the boundary (the exact pattern - // #4432's own doc comment rejects) and would still mint the row under a - // second namespace, `type='fields'`. The remedy belongs at the boundary - // map and spans four types, so it is #7894's, not this card's. - // - // When #7894 lands this test goes RED. That is its job: flip it to - // `expectNotOverridable(res)` and delete the row assertion. + // ── #7894 — THE HOLE ABOVE, NOW CLOSED ──────────────────────────────── + // + // This block replaces a case that deliberately asserted the DEFECT + // (`expect(res.status).toBe(200)` plus a row under `type='fields'`) and + // instructed its successor to "flip it to `expectNotOverridable(res)` and + // delete the row assertion". That inversion is this file's anti-vacuity + // proof and it costs nothing to state: the harness demonstrably REACHED + // this boundary before the fix, because it measured the 200 here. A fresh + // test asserting 403 could pass by never arriving; this one cannot. + + it('#7894 — the PLURAL url spelling folds onto the same lock', async () => { const { engine, dispatcher } = makeStack(); const res = responseOf(await dispatcher.handleMetadata( @@ -513,12 +503,115 @@ describe('#7743 — PUT /meta/field/. honours the registry overla { name: 'title', label: 'Tampered', type: 'text' }, )); - expect(res.status).toBe(200); - // The mechanism, not just the status: the row lands under the PLURAL - // type key — a second namespace for the same item. - expect(metaRow(engine, 'fields', 'showcase_task.title')).toBeDefined(); - // …and the singular namespace stays clean, which is why the singular - // route's own refusal above is not weakened by this gap. + // Measurement 1. Note it converges on the SINGULAR route's answer + // rather than producing a refusal that names the spelling `fields`: + // folding is what closes this, so `/meta/fields/…` is now the same + // request as `/meta/field/…` and earns the same verdict. Plural REST + // paths are the documented legitimate spelling (`/meta/actions` folds + // and must keep folding), so refusing this one specifically would give + // `field` a URL contract unlike every other type's. + expectNotOverridable(res); + // Measurement 3, the mechanism rather than the status: NO second + // namespace is minted. This is the assertion the old case inverted. + expect(metaRow(engine, 'fields', 'showcase_task.title')).toBeUndefined(); expect(metaRow(engine, 'field', 'showcase_task.title')).toBeUndefined(); }); + + it('#7894 — the other three unmapped types answer as their singular does', async () => { + // `seed` / `external_catalog` / `translation` were unmapped alongside + // `field`. The assertion is deliberately body-AGNOSTIC: what the card is + // about is that the plural URL stops being a SEPARATE door, so the test + // is "both spellings reach the same verdict, and only the singular + // namespace can ever be minted" — not a hardcoded status per type. + // + // Worth recording, because it is the fold made visible: `seeds` with + // this body answers 422, because it is now judged by the real + // `SeedSchema` (which requires `object`/`records`). Before the fix it + // answered 200 — an unmapped spelling has no schema to be judged by, so + // it sailed past validation as well as past authorization. + for (const [plural, singular] of [ + ['seeds', 'seed'], + ['translations', 'translation'], + ['external_catalogs', 'external_catalog'], + ] as const) { + const body = { name: 'thing_x', label: 'X' }; + + const viaPlural = makeStack(); + const pluralRes = responseOf(await viaPlural.dispatcher.handleMetadata( + `/${plural}/thing_x`, ctx(), 'PUT', body, + )); + + const viaSingular = makeStack(); + const singularRes = responseOf(await viaSingular.dispatcher.handleMetadata( + `/${singular}/thing_x`, ctx(), 'PUT', body, + )); + + expect(pluralRes.status, `${plural} must answer exactly as ${singular} does`) + .toBe(singularRes.status); + // …and whatever the verdict, no second namespace is ever minted. + expect(metaRow(viaPlural.engine, plural, 'thing_x'), `${plural} must not mint a namespace`) + .toBeUndefined(); + } + }); + + // ── POSITIVE CONTROL — plugin registration must still work ──────────── + // + // Every gate #7894 names is CORRECT behaviour for a genuinely + // plugin-registered runtime type. A refusal that also caught those would be + // a worse defect than the bypass it closed, so this is pinned, not assumed. + + it('#7894 POSITIVE CONTROL — a plugin-registered runtime type is still permitted', async () => { + const { engine, dispatcher } = makeStack(); + + // `theme` has no `DEFAULT_METADATA_TYPE_REGISTRY` entry at all. + const singular = responseOf(await dispatcher.handleMetadata( + '/theme/midnight', ctx(), 'PUT', { name: 'midnight', label: 'Midnight' }, + )); + expect(singular.status).toBe(200); + expect(metaRow(engine, 'theme', 'midnight')).toBeDefined(); + + // …and via its plural spelling, which the URL map carries from the + // manifest map's limb — still one namespace, the singular one. + const plural = responseOf(await dispatcher.handleMetadata( + '/themes/twilight', ctx(), 'PUT', { name: 'twilight', label: 'Twilight' }, + )); + expect(plural.status).toBe(200); + expect(metaRow(engine, 'theme', 'twilight')).toBeDefined(); + expect(metaRow(engine, 'themes', 'twilight')).toBeUndefined(); + }); + + it('#7894 POSITIVE CONTROL — a kind the platform has never heard of is still permitted', async () => { + // The strongest form: a name in NO map and NO registry — exactly what a + // third-party plugin registering a novel kind looks like. The refusal + // must not fire, and it cannot, by construction: it only triggers when a + // spelling's singular is a type the platform itself DECLARES. + const { engine, dispatcher } = makeStack(); + + const res = responseOf(await dispatcher.handleMetadata( + '/my_plugin_kind/widget_a', ctx(), 'PUT', { name: 'widget_a', label: 'Widget A' }, + )); + + expect(res.status).toBe(200); + expect(metaRow(engine, 'my_plugin_kind', 'widget_a')).toBeDefined(); + }); + + // ── The refusal limb — an unrecognised spelling of a DECLARED type ──── + + it('#7894 — an unrecognised plural of a declared type is refused, not forwarded', async () => { + const { engine, dispatcher } = makeStack(); + + const res = responseOf(await dispatcher.handleMetadata( + '/capabilitys/some_cap', ctx(), 'PUT', { name: 'some_cap', label: 'Some Cap' }, + )); + + // ADR-0112 — code AND status, never "it threw". + expect(res.status).toBe(400); + expect(res.body?.error?.code).toBe('INVALID_REQUEST'); + // It names the offending spelling AND the canonical one. + expect(res.body?.error?.message).toContain('capabilitys'); + expect(res.body?.error?.message).toContain('capability'); + // Never answers 200, so no namespace is minted under the typo. + expect(metaRow(engine, 'capabilitys', 'some_cap')).toBeUndefined(); + expect(metaRow(engine, 'capability', 'some_cap')).toBeUndefined(); + }); }); diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index 93627fad19..421c00b6c1 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -13,6 +13,7 @@ "CorsConfigSchema (const)", "CronExpressionInput (type)", "CronExpressionInputSchema (const)", + "DECLARED_META_TYPES (const)", "EXTERNAL_ERROR_CODES (const)", "EXTERNAL_ERROR_HTTP_STATUS (const)", "EventName (type)", @@ -48,6 +49,7 @@ "KeySetGuidance (interface)", "MAP_SUPPORTED_FIELDS (const)", "METADATA_ALIASES (const)", + "META_URL_TO_SINGULAR (const)", "MapSupportedField (type)", "MetadataCollectionInput (type)", "MetadataFormat (type)", @@ -96,6 +98,7 @@ "ViewNameParsed (type)", "ViewNameSchema (const)", "applyProtection (function)", + "canonicalMetaUrlType (function)", "cel (function)", "cron (function)", "expression (function)", @@ -114,10 +117,12 @@ "pluralToSingular (function)", "renderDiffMessage (function)", "resilientFetch (function)", + "restPluralOfMetaType (function)", "safeParsePretty (function)", "singularToPlural (function)", "strictUnknownKeyError (function)", "suggestFieldType (function)", - "tmpl (function)" + "tmpl (function)", + "unmappedDeclaredTypeSpelling (function)" ] } diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 93a59de0a9..0fa624ecb9 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -13,6 +13,7 @@ "CorsConfigSchema": "src/shared/http.zod.ts#CorsConfigSchema (const)", "CronExpressionInput": "src/shared/expression.zod.ts#CronExpressionInput (type)", "CronExpressionInputSchema": "src/shared/expression.zod.ts#CronExpressionInputSchema (const)", + "DECLARED_META_TYPES": "src/shared/metadata-url-spelling.ts#DECLARED_META_TYPES (const)", "EXTERNAL_ERROR_CODES": "src/shared/external-errors.ts#EXTERNAL_ERROR_CODES (const)", "EXTERNAL_ERROR_HTTP_STATUS": "src/shared/external-errors.ts#EXTERNAL_ERROR_HTTP_STATUS (const)", "EventName": "src/shared/identifiers.zod.ts#EventName (type)", @@ -48,6 +49,7 @@ "KeySetGuidance": "src/shared/suggestions.zod.ts#KeySetGuidance (interface)", "MAP_SUPPORTED_FIELDS": "src/shared/metadata-collection.zod.ts#MAP_SUPPORTED_FIELDS (const)", "METADATA_ALIASES": "src/shared/metadata-collection.zod.ts#METADATA_ALIASES (const)", + "META_URL_TO_SINGULAR": "src/shared/metadata-url-spelling.ts#META_URL_TO_SINGULAR (const)", "MapSupportedField": "src/shared/metadata-collection.zod.ts#MapSupportedField (type)", "MetadataCollectionInput": "src/shared/metadata-collection.zod.ts#MetadataCollectionInput (type)", "MetadataFormat": "src/shared/metadata-types.zod.ts#MetadataFormat (type)", @@ -96,6 +98,7 @@ "ViewNameParsed": "src/shared/branded-types.zod.ts#ViewNameParsed (type)", "ViewNameSchema": "src/shared/branded-types.zod.ts#ViewNameSchema (const)", "applyProtection": "src/shared/protection.zod.ts#applyProtection (function)", + "canonicalMetaUrlType": "src/shared/metadata-url-spelling.ts#canonicalMetaUrlType (function)", "cel": "src/shared/expression.zod.ts#cel (function)", "cron": "src/shared/expression.zod.ts#cron (function)", "expression": "src/shared/expression.zod.ts#expression (function)", @@ -114,10 +117,12 @@ "pluralToSingular": "src/shared/metadata-collection.zod.ts#pluralToSingular (function)", "renderDiffMessage": "src/shared/external-errors.ts#renderDiffMessage (function)", "resilientFetch": "src/shared/resilient-fetch.ts#resilientFetch (function)", + "restPluralOfMetaType": "src/shared/metadata-url-spelling.ts#restPluralOfMetaType (function)", "safeParsePretty": "src/shared/error-map.zod.ts#safeParsePretty (function)", "singularToPlural": "src/shared/metadata-collection.zod.ts#singularToPlural (function)", "strictUnknownKeyError": "src/shared/suggestions.zod.ts#strictUnknownKeyError (function)", "suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)", - "tmpl": "src/shared/expression.zod.ts#tmpl (function)" + "tmpl": "src/shared/expression.zod.ts#tmpl (function)", + "unmappedDeclaredTypeSpelling": "src/shared/metadata-url-spelling.ts#unmappedDeclaredTypeSpelling (function)" } } diff --git a/packages/spec/src/shared/index.ts b/packages/spec/src/shared/index.ts index 3cc597b5f7..f400f4b514 100644 --- a/packages/spec/src/shared/index.ts +++ b/packages/spec/src/shared/index.ts @@ -15,6 +15,10 @@ export * from './suggestions.zod'; export * from './error-map.zod'; export * from './external-errors'; export * from './metadata-collection.zod'; +// [#7894] The URL-spelling half of the metadata type key, split OUT of +// metadata-collection.zod's manifest-collection map. Read by the `/meta` +// boundary fold only. +export * from './metadata-url-spelling'; export * from './lazy-schema'; export * from './expression.zod'; export * from './visibility'; diff --git a/packages/spec/src/shared/metadata-url-spelling.test.ts b/packages/spec/src/shared/metadata-url-spelling.test.ts new file mode 100644 index 0000000000..707f80b67e --- /dev/null +++ b/packages/spec/src/shared/metadata-url-spelling.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7894 — the URL-spelling map, pinned as the two INVARIANTS it exists to hold + * rather than as a snapshot of its contents. + * + * A snapshot test here would be worse than nothing: the whole point of deriving + * the map from `DEFAULT_METADATA_TYPE_REGISTRY` is that it CHANGES when a type + * is declared, so a test that froze its contents would have to be edited on + * every legitimate change and would teach the next author to re-bless it + * without reading. These assertions are quantified over the registry instead — + * declare a new type and they cover it automatically. + */ + +import { describe, expect, it } from 'vitest'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '../kernel/metadata-plugin.zod'; +import { PLURAL_TO_SINGULAR } from './metadata-collection.zod'; +import { + DECLARED_META_TYPES, + META_URL_TO_SINGULAR, + canonicalMetaUrlType, + restPluralOfMetaType, + unmappedDeclaredTypeSpelling, +} from './metadata-url-spelling'; + +describe('#7894 INVARIANT 1 — no spelling that worked before may stop working', () => { + it('folds every manifest spelling to exactly the singular it folded to before', () => { + // The manifest map is the complete population of spellings that resolved at + // the `/meta` boundary before this change, so quantifying over it IS the + // non-breaking proof. It includes six that name PLUGIN kinds with no static + // registry entry (`themes`, `webhooks`, `connectors`, `sharingRules`, + // `ragPipelines`, `analyticsCubes`) — a purely registry-derived map would + // have dropped those, which is why the derived limb is unioned rather than + // substituted. + for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) { + expect(canonicalMetaUrlType(plural), `${plural} must still fold to ${singular}`).toBe(singular); + } + }); + + it('refuses none of them', () => { + for (const plural of Object.keys(PLURAL_TO_SINGULAR)) { + expect(unmappedDeclaredTypeSpelling(plural), `${plural} works today and must not be refused`).toBeNull(); + } + }); + + it('carries the six plugin-kind spellings that no registry derivation could produce', () => { + // Named explicitly because losing them is the specific regression the + // union-vs-pure-derivation choice was made to avoid, and a reader needs to + // see the population rather than infer it. + const registryTypes = new Set(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)); + const pluginOnly = Object.entries(PLURAL_TO_SINGULAR).filter(([, s]) => !registryTypes.has(s)); + expect(pluginOnly.map(([p]) => p).sort()).toEqual( + ['analyticsCubes', 'connectors', 'ragPipelines', 'sharingRules', 'themes', 'webhooks'], + ); + for (const [plural, singular] of pluginOnly) { + expect(canonicalMetaUrlType(plural)).toBe(singular); + } + }); +}); + +describe('#7894 INVARIANT 2 — no unmapped spelling of a DECLARED type may answer 200', () => { + it('maps the REST plural of every declared registry type', () => { + // This is the limb that makes the defect non-recurring: it is quantified + // over the registry, so a newly declared type arrives already mapped and + // can never fall through to the permissive plugin path. + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + const plural = restPluralOfMetaType(entry.type); + expect(canonicalMetaUrlType(plural), `${plural} must fold to ${entry.type}`).toBe(entry.type); + expect(unmappedDeclaredTypeSpelling(plural)).toBeNull(); + } + }); + + it('leaves every declared singular as its own canonical form', () => { + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + expect(canonicalMetaUrlType(entry.type)).toBe(entry.type); + expect(unmappedDeclaredTypeSpelling(entry.type)).toBeNull(); + } + }); + + it('closes the four types the card measured as unmapped', () => { + // The regression coordinates themselves. Each of these folded to ITSELF + // before the fix — i.e. was treated as a plugin type — and `field` was the + // live authorization hole. + for (const type of ['field', 'seed', 'external_catalog', 'translation']) { + const plural = restPluralOfMetaType(type); + expect(PLURAL_TO_SINGULAR[plural], `${plural} must stay OUT of the manifest map`).toBeUndefined(); + expect(canonicalMetaUrlType(plural)).toBe(type); + } + }); + + it('handles the consonant-y irregular so `capability` is not spelled `capabilitys`', () => { + expect(restPluralOfMetaType('capability')).toBe('capabilities'); + expect(canonicalMetaUrlType('capabilities')).toBe('capability'); + }); + + it('addresses snake_case types in both snake and camel plural', () => { + expect(canonicalMetaUrlType('external_catalogs')).toBe('external_catalog'); + expect(canonicalMetaUrlType('externalCatalogs')).toBe('external_catalog'); + expect(canonicalMetaUrlType('email_templates')).toBe('email_template'); + expect(canonicalMetaUrlType('emailTemplates')).toBe('email_template'); + }); + + it('never lets one spelling name two types', () => { + // The module asserts this at load; assert it here too so the failure is + // attributable to a test rather than to an import side effect. + for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) { + expect(META_URL_TO_SINGULAR[plural]).toBe(singular); + } + }); +}); + +describe('#7894 — the refusal limb is narrow by construction', () => { + it('refuses an unrecognised plural of a declared type, naming that type', () => { + expect(unmappedDeclaredTypeSpelling('capabilitys')).toBe('capability'); + expect(unmappedDeclaredTypeSpelling('objectes')).toBe('object'); + expect(unmappedDeclaredTypeSpelling('fieldes')).toBe('field'); + }); + + it('POSITIVE CONTROL — cannot refuse a plugin kind, whatever it is named', () => { + // The safety property that lets this rule be STATIC instead of consulting + // the live registered-type set. A live lookup would refuse a plugin kind + // whenever registration had not happened yet — turning an authorization fix + // into a plugin-registration outage, a worse defect than the bypass. + // + // The `s`-final names are the sharp cases: a naive "looks plural" heuristic + // would refuse all four, and they are ordinary English words a plugin might + // well use for a kind. + for (const kind of [ + 'theme', 'sharing_rule', 'webhook', 'rag_pipeline', 'analytics_cube', 'connector', + 'my_plugin_kind', 'address', 'status', 'kudos', 'analysis', 'series', + ]) { + expect(unmappedDeclaredTypeSpelling(kind), `${kind} must not be refused`).toBeNull(); + expect(canonicalMetaUrlType(kind)).toBe(kind); + } + }); + + it('documents its residue rather than pretending to be total', () => { + // A spelling that is not a plural of anything is indistinguishable from a + // plugin kind by static means, so it still takes the plugin path. Pinned so + // the limitation is a stated fact rather than an unnoticed gap; closing it + // needs the live registered-type set at the boundary. + expect(unmappedDeclaredTypeSpelling('fieldz')).toBeNull(); + }); + + it('every declared type is in DECLARED_META_TYPES and no plugin kind is', () => { + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + expect(DECLARED_META_TYPES.has(entry.type)).toBe(true); + } + for (const kind of ['theme', 'webhook', 'connector', 'my_plugin_kind']) { + expect(DECLARED_META_TYPES.has(kind)).toBe(false); + } + }); +}); + +describe('#7894 — the manifest map keeps its own job', () => { + it('gains no `fields` collection, so the authoring lint advertises none', () => { + // `kernel/metadata-authoring-lint.ts` iterates `PLURAL_TO_SINGULAR` to + // decide which stack-level collections exist and which "did you mean" hints + // it may emit. A `fields` key here would advertise a top-level + // `fields: [...]` collection that does not exist and collides conceptually + // with `ObjectSchema.fields` — which is exactly why the four missing keys + // were NOT simply added to this map. + for (const key of ['fields', 'seeds', 'translations', 'external_catalogs', 'externalCatalogs']) { + expect(PLURAL_TO_SINGULAR[key], `${key} must not enter the manifest map`).toBeUndefined(); + } + }); +}); diff --git a/packages/spec/src/shared/metadata-url-spelling.ts b/packages/spec/src/shared/metadata-url-spelling.ts new file mode 100644 index 0000000000..16040ebe5a --- /dev/null +++ b/packages/spec/src/shared/metadata-url-spelling.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * URL SPELLING of a metadata type — the `/meta/:type` half of #4432's canonical + * type key, split out of {@link PLURAL_TO_SINGULAR} (#7894). + * + * ## Why this is a separate map and not four more keys in the other one + * + * `PLURAL_TO_SINGULAR` is a MANIFEST-COLLECTION map: its keys are the + * properties an author writes in `defineStack()` (`objects: [...]`, + * `apps: [...]`), and `kernel/metadata-authoring-lint.ts` iterates it to decide + * WHICH COLLECTIONS EXIST at stack level — every key becomes a collection the + * lint walks and a "did you mean" hint it can emit. A URL spelling map is a + * different contract that merely overlaps: its keys are path segments a client + * may send to `/meta/:type`. The two agree for `objects`/`apps`/`views`, and + * that coincidence is exactly what hid the bug. + * + * Four registry types — `field`, `seed`, `external_catalog`, `translation` — + * had no entry in the manifest map, because none of them is a stack-level + * collection (fields live inside `ObjectSchema.fields`, seeds inside `data`). + * At the `/meta` boundary that absence did not read as "not a collection", it + * read as "unknown type", and an unknown type is treated as PLUGIN-REGISTERED, + * which every authorization gate is permissive toward by construction: + * `isRuntimeCreateAllowed` synthesises `allowRuntimeCreate: true`, + * `orgScopedWriteRefusal` returns `null` for anything with no static registry + * entry, and `SysMetadataRepository.assertAllowed` returns early. So + * `PUT /meta/fields/showcase_task.title` answered 200 and minted a second + * namespace under `type='fields'` while `PUT /meta/field/...` answered + * 403 NOT_OVERRIDABLE — the plural URL was a door around the singular URL's + * lock. + * + * ⛔ The fix is NOT to add `fields:` to the manifest map. That would advertise a + * top-level `fields: [...]` stack collection which does not exist, and which + * collides conceptually with `ObjectSchema.fields`. + * + * ## How this map is built (Prime Directive #8 — derived, never hand-written) + * + * Three limbs, unioned, in this order: + * + * 1. **Manifest spellings** — every key of `PLURAL_TO_SINGULAR`, verbatim. + * These are spellings that already worked at the URL boundary, including + * the camelCase ones (`emailTemplates`, `sharingRules`, `analyticsCubes`, + * `ragPipelines`) and the six that name PLUGIN-registered kinds with no + * static registry entry at all (`themes`, `webhooks`, `connectors`, …). + * Keeping this limb whole is what makes the change non-breaking: no + * spelling that resolved before resolves differently now. + * 2. **Registry-derived spellings** — {@link restPluralOfMetaType} applied to + * every `DEFAULT_METADATA_TYPE_REGISTRY` entry. This is the limb that makes + * the defect non-recurring: a newly DECLARED type arrives with its URL + * spelling already mapped, so it can never again fall through to the + * plugin-type path. Hand-adding the four missing keys would have fixed only + * today's four. + * 3. **camelCase spellings for snake_case registry types** — `external_catalog` + * is addressable as `externalCatalogs` as well as `external_catalogs`, + * matching how the manifest map already spells every other multi-word type. + * + * Limb 2 cannot silently disagree with limb 1: `assertMetaUrlSpellingsAgree` + * is called at module load and throws if any spelling would resolve to two + * different singulars. + * + * ## What this map deliberately does NOT do + * + * It does not make the boundary tolerant. Folding happens at the boundary and + * only there (#4432, Prime Directive #12: one contract, not N dialects); the + * layers below keep reading the single canonical singular. Nothing here should + * ever be consulted by a predicate one layer down. + * + * @module + */ + +import { DEFAULT_METADATA_TYPE_REGISTRY } from '../kernel/metadata-plugin.zod'; +import { PLURAL_TO_SINGULAR } from './metadata-collection.zod'; + +/** + * The ONE pluralization rule for a metadata type's REST path segment. + * + * Deliberately small and total: metadata type names are snake_case ASCII + * (Prime Directive #3), so the only irregularity that occurs in practice is a + * consonant + `y` (`capability` → `capabilities`). The `(s|x|z|ch|sh)` limb is + * carried for correctness of future types rather than for any type declared + * today. Anything more clever would be a spelling GUESSER, which is precisely + * what the boundary must not contain. + */ +export function restPluralOfMetaType(type: string): string { + if (/[^aeiou]y$/.test(type)) return `${type.slice(0, -1)}ies`; + if (/(s|x|z|ch|sh)$/.test(type)) return `${type}es`; + return `${type}s`; +} + +/** `external_catalog` → `externalCatalog`. Identity for a type with no underscore. */ +function camelCaseOf(type: string): string { + return type.replace(/_([a-z])/g, (_m, c: string) => c.toUpperCase()); +} + +/** + * Every metadata type with a STATIC entry in `DEFAULT_METADATA_TYPE_REGISTRY`. + * + * "Declared" is the load-bearing word: a type in this set is one the platform + * itself ships a contract for, so an unresolvable spelling of it is a caller + * error rather than a plugin the platform has not heard of. That distinction is + * the whole basis of {@link unmappedDeclaredTypeSpelling}. + */ +export const DECLARED_META_TYPES: ReadonlySet = new Set( + DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), +); + +function buildMetaUrlMap(): Record { + const out: Record = {}; + // Limb 1 — every manifest spelling, verbatim. + for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) out[plural] = singular; + // Limbs 2 and 3 — derived from the registry. + for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) { + out[restPluralOfMetaType(entry.type)] = entry.type; + const camel = camelCaseOf(entry.type); + if (camel !== entry.type) out[restPluralOfMetaType(camel)] = entry.type; + } + return out; +} + +/** + * Plural (and camelCase) URL spelling → canonical singular metadata type. + * + * Read ONLY by the `/meta` boundary fold. See the module doc for why this is + * not `PLURAL_TO_SINGULAR`. + */ +export const META_URL_TO_SINGULAR: Readonly> = Object.freeze(buildMetaUrlMap()); + +/** + * Fail the build (well, the module load) rather than serve two answers for one + * spelling. A disagreement here would mean the derived limb and the manifest + * limb had drifted, which is the same class of silent divergence #7894 is about. + */ +function assertMetaUrlSpellingsAgree(): void { + for (const [plural, singular] of Object.entries(PLURAL_TO_SINGULAR)) { + const derived = META_URL_TO_SINGULAR[plural]; + if (derived !== singular) { + throw new Error( + `[metadata-url-spelling] '${plural}' resolves to '${derived}' in the URL map but '${singular}' in ` + + `PLURAL_TO_SINGULAR. One spelling may not name two types.`, + ); + } + } +} +assertMetaUrlSpellingsAgree(); + +/** + * Fold a `/meta/:type` path segment to its canonical singular. Returns the + * input unchanged when it is already canonical (or is a plugin-registered type, + * which has no plural spelling of its own). + */ +export function canonicalMetaUrlType(type: string): string { + return META_URL_TO_SINGULAR[type] ?? type; +} + +/** Candidate singulars for a spelling, by inverting {@link restPluralOfMetaType}. */ +function singularCandidates(type: string): string[] { + const out: string[] = []; + if (type.endsWith('ies')) out.push(`${type.slice(0, -3)}y`); + if (type.endsWith('es')) out.push(type.slice(0, -2)); + if (type.endsWith('s')) out.push(type.slice(0, -1)); + return out; +} + +/** + * The boundary refusal (#7894, maintainer ruling 2026-08-12: *if the platform + * cannot honour a declaration, refuse it at the latest checkpoint that can see + * the whole picture, name the offending key path, and never answer 200*). + * + * Returns the DECLARED type a spelling was evidently reaching for, or `null` + * when the spelling is none of the platform's business. + * + * ## Why this is a STATIC rule and not a live-registry lookup + * + * The tempting version asks "is this type registered right now?" and refuses + * everything else. That version is a hazard: it would refuse a genuinely + * plugin-registered runtime type whenever the registration had not happened + * yet, turning an authorization fix into a plugin-registration outage — a worse + * defect than the one being closed. This rule instead refuses ONLY a spelling + * whose singular is a type the platform itself declares. A plugin kind can + * therefore never be refused by it, no matter what it is named or when it + * registers — the positive control holds BY CONSTRUCTION rather than by test + * coverage. (The test exists anyway; construction and coverage are not + * substitutes.) + * + * Note what this means for a plugin kind whose singular happens to end in `s` + * (`address`, `status`): `singularCandidates` produces `addre`/`addres` and + * `statu`/`statue`, none of which is declared, so it is permitted. Good. + * + * ## Known residue, deliberately not closed here + * + * A spelling that is not a plural of anything — `/meta/fieldz` — is + * indistinguishable from a plugin kind by static means, so it still takes the + * plugin path. Closing that needs the live registered-type set at the boundary, + * which is a different change with a different risk profile. + */ +export function unmappedDeclaredTypeSpelling(type: string): string | null { + if (type in META_URL_TO_SINGULAR) return null; + if (DECLARED_META_TYPES.has(type)) return null; + for (const candidate of singularCandidates(type)) { + if (DECLARED_META_TYPES.has(candidate)) return candidate; + } + return null; +} From ead61a0f3a8d3c04a1d99db390ec1def6075e0b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 12:07:40 +0000 Subject: [PATCH 2/2] test(spec): widen the registry-type Set to string in the #7894 spelling test DEFAULT_METADATA_TYPE_REGISTRY.map(e => e.type) infers a literal union, so Set.has(someString) did not typecheck under tsconfig.test.json. Widened at the construction site rather than adding the file to test-typecheck-debt.json. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --- packages/spec/src/shared/metadata-url-spelling.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/shared/metadata-url-spelling.test.ts b/packages/spec/src/shared/metadata-url-spelling.test.ts index 707f80b67e..8cb3776c4c 100644 --- a/packages/spec/src/shared/metadata-url-spelling.test.ts +++ b/packages/spec/src/shared/metadata-url-spelling.test.ts @@ -47,7 +47,7 @@ describe('#7894 INVARIANT 1 — no spelling that worked before may stop working' // Named explicitly because losing them is the specific regression the // union-vs-pure-derivation choice was made to avoid, and a reader needs to // see the population rather than infer it. - const registryTypes = new Set(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)); + const registryTypes = new Set(DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)); const pluginOnly = Object.entries(PLURAL_TO_SINGULAR).filter(([, s]) => !registryTypes.has(s)); expect(pluginOnly.map(([p]) => p).sort()).toEqual( ['analyticsCubes', 'connectors', 'ragPipelines', 'sharingRules', 'themes', 'webhooks'],