Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .changeset/meta-plural-url-bypass.md
Original file line numberDiff line numberDiff line change
@@ -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).
60 changes: 57 additions & 3 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -147,13 +147,67 @@ const TYPE_TO_FORM: Readonly<Record<string, FormView>> = 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<T extends { type: string }>(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 };
}
Expand Down
163 changes: 128 additions & 35 deletions packages/runtime/src/meta-field-overlay-lock.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -478,47 +485,133 @@ describe('#7743 — PUT /meta/field/<object>.<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(
'/fields/showcase_task.title', ctx(), 'PUT',
{ 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();
});
});
7 changes: 6 additions & 1 deletion packages/spec/api-surface/shared.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand DownExpand Up@@ -48,6 +49,7 @@
"KeySetGuidance (interface)",
"MAP_SUPPORTED_FIELDS (const)",
"METADATA_ALIASES (const)",
"META_URL_TO_SINGULAR (const)",
"MapSupportedField (type)",
"MetadataCollectionInput (type)",
"MetadataFormat (type)",
Expand DownExpand Up@@ -96,6 +98,7 @@
"ViewNameParsed (type)",
"ViewNameSchema (const)",
"applyProtection (function)",
"canonicalMetaUrlType (function)",
"cel (function)",
"cron (function)",
"expression (function)",
Expand All@@ -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)"
]
}
Loading
Loading