Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol,spec): the plural /meta URL stops walking around the two-tier registry gate (#7894) - #8420
Conversation
…d 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
…ng 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
…a-plural-url-bypass
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
os-zhuang
commented
Aug 13, 2026
ACCEPT — PM review, |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7894
canonicalMetaType— the ONE canonical spelling of a metadata type at the/metaboundary (#4432) — folded plural to singular throughPLURAL_TO_SINGULAR. That map is a manifest-collection map: its keys are the properties an author writes indefineStack(), andkernel/metadata-authoring-lint.tsiterates it to decide which stack-level collections exist.Four registry types are legitimately absent from it, because none is a stack collection:
field(fields live insideObjectSchema.fields),seed,external_catalog,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. So the plural URL was a door around the singular URL's lock, and the row persisted undertype='fields', minting a second namespace.Shape implemented — options 2 AND 3, per the rulings on the card
Split the two roles. New
META_URL_TO_SINGULARin@objectstack/spec/sharedis the URL-spelling contract, built from three unioned limbs:DEFAULT_METADATA_TYPE_REGISTRYentry (Prime Directive Convert to monorepo with scoped packages #8 — derived, so a newly declared type can never again arrive unmapped);Limb 1 is why this is non-breaking. A purely registry-derived map would have dropped six spellings whose singular has no registry entry at all —
themes,webhooks,connectors,sharingRules,ragPipelines,analyticsCubes— which are plugin kinds carried only by the manifest map.PLURAL_TO_SINGULARitself is untouched, so the authoring lint gains nofields:collection (verified: its collection set isconnectors, views).Refuse at the boundary. An unrecognised plural of a declared type (
/meta/capabilitys) is now refusedINVALID_REQUEST/400, naming both the offending spelling and the canonical one, instead of answering 200. The rule is deliberately static — it fires only when a spelling's singular is a type the platform itself declares — so a plugin-registered kind can never trip it, whatever it is named and whenever it registers. The positive control therefore holds by construction, not merely by coverage.Both changes land in
canonicalMetaType/canonicalizeMetaRequestType, so all six/metaentry points inherit them with no edits at those sites.packages/specexportMETA_URL_TO_SINGULAR(pluscanonicalMetaUrlType,unmappedDeclaredTypeSpelling,restPluralOfMetaType,DECLARED_META_TYPES) is a new public surface on@objectstack/spec/shared, in a new modulepackages/spec/src/shared/metadata-url-spelling.ts.export-origins/shared.jsonandapi-surface/shared.jsonare regenerated accordingly.Flagging it explicitly because this is a cross-lane touch into
domain:specterritory arriving from the metadata-protocol lane — the spec seat should see it here rather than discover it in a regenerated artifact. The placement was deliberate and reviewed: a URL-spelling contract belongs beside the manifest-collection map it was split out of, and the whole point of the card is that these are two different contracts that must stop sharing one map. Keeping the map inmetadata-protocolinstead would have put a spelling contract downstream of the registry it is derived from, andpackages/restneeds it too (RestServer.metaTypeSingularreads the same fold).Measurements — direction predicted before each run
PUT /meta/fields/showcase_task.titlePUT /meta/field/showcase_task.titletype='fields'fieldnamespace stays clean tootheme,themes, and a never-registeredmy_plugin_kindMeasurement 5 is unusually strong here and cost nothing: this file already carried a case that asserted the defect (
expect(res.status).toBe(200)plus a row undertype='fields') with instructions to flip it. Inverting that case proves the harness demonstrably reached this boundary before the fix — a fresh test asserting 403 could pass by never arriving.Reverse verification (revert + rebuild, since
packages/runtimeresolves@objectstack/metadata-protocolthrough itsdist):The
seedsline is the fold made visible: it now answers 422 because it is judged by the realSeedSchema. Before, an unmapped spelling had no schema to be judged by, so it sailed past validation as well as past authorization.Correction for the record: six dropped spellings, not seven
An earlier interim report of mine listed seven spellings that a purely registry-derived map would drop, including
skills. That was wrong, and the cause was a regex-based derivation ofDEFAULT_METADATA_TYPE_REGISTRYrather than the real value.skilldoes have a registry entry, soskillswould have survived a pure derivation and does not belong on that list. The same regex also undercounted the registry at 26 entries when it has 27 — it had misseddatasource.Everything in this PR is derived from the built package, re-checked after the miscount was found. Neither correction moves any verdict: the unmapped set is still exactly
field,seed,external_catalog,translationwith the same flags, so the card's blast-radius table stands unchanged. The miscount could not have leaked into the fix either, because the non-breaking invariant is enforced by quantifying over all 29 manifest keys rather than over any hand-written enumeration.Verification
Built first (stale
distlies in both directions), then, post-merge withorigin/main:@objectstack/spec391 files / 10345 tests;@objectstack/metadata-protocol80 / 1170;@objectstack/runtime150 / 2310@objectstack/objectql196 / 3496;@objectstack/rest110 / 1817 — the plural-sensitive consumerstest-typecheck-debt.jsonnot grown (one inferred-literalSetwidened at its construction site instead)check:nul-bytes,check:cross-package-test-inputs,check:changeset-gate-self-tests,check:spec-parsed-alias,check:type-source-resolution,check:adr-anchors,check:merge-driver,check:objectui-changeset,check:query-options-erasure,check:type-check-coverage,check:error-code-casing,check:export-origins,check-changeset-no-major— all pass.check:i18nfirst reported "Nothing was checked" for want of a@objectstack/clibuild; after building it,EXIT=0.export-origins/andapi-surface/regenerated for the new exports.No new error code:
INVALID_REQUESTalready exists in the ledger under@objectstack/metadata-protocol.Known residue, deliberately not closed — tracked as #8421
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 and mints a namespace under a type that does not exist. It is the same second-namespace class as #4432 but narrower: it shadows no locked type, so unlike the bypass this PR closes there is no authorization escalation.Closing it needs the live registered-type set at the boundary, which has a different risk profile — a live check that runs before a plugin registers its kind would refuse a legitimate type, turning a hygiene fix into a plugin-registration outage. That is why the rule shipped here is static.
Filed separately as #8421 so the gap is findable outside this PR, and pinned in
packages/spec/src/shared/metadata-url-spelling.test.ts("documents its residue rather than pretending to be total") so it cannot go quiet. If #8421 is ever fixed, that case inverts — flip it, do not delete it.Generated by Claude Code