Skip to content

fix(schema): check defineField options against the field type - #753

Open
maoberlehner wants to merge 7 commits into
fix/schema-deny-leaks-to-wirefrom
fix/schema-define-field-excess-property-checks
Open

fix(schema): check defineField options against the field type#753
maoberlehner wants to merge 7 commits into
fix/schema-deny-leaks-to-wirefrom
fix/schema-define-field-excess-property-checks

Conversation

@maoberlehner

@maoberlehner maoberlehner commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Important

Stacked on #752 (fix/schema-deny-leaks-to-wire) and targets that branch, not main.
Review that one first. This PR depends on it in two ways: component_denylist can only point at
deny once deny reaches the wire, and component_group_denylist's bucket depended on that
branch's folder-ref decision.

Two defects

1. No excess property checking. defineField accepted any key. The field literal is inferred
as the type parameter, so the only check was plain assignability to the constraint, which ignores
excess properties. Typos and options meaningless for the field type compiled clean, were pushed to
the Management API verbatim, and silently did nothing:

defineField('x', { type: 'bloks', component_group_whitlist: ['a'] }); // typo, ignored
defineField('x', { type: 'text',  component_group_whitelist: ['x'] }); // meaningless on text
defineField('x', { type: 'asset', restrict_components: true });        // meaningless on asset
defineField('x', { type: 'bloks', totally_bogus_key: 123 });           // anything goes

2. allow/deny and the raw wire keys were silently both legal. mapFieldToWire spreads
...rest and then assigns from allow, so a hand-written component_whitelist was overwritten
without warning. Setting both is always a mistake.

Approach

  • NoExtraKeys<T> gives excess property checking the concrete target it lacks, keyed off the matched
    Field variant so the check is per field type. type: 'custom' is exempt, since plugin option
    keys legitimately pass through verbatim.

  • NoRestrictionConflict<T> makes mixing allow/deny with the five keys they derive a compile
    error. validateSchema reports the same conflict as a conflicting_restriction issue for
    consumers authoring schemas in plain JavaScript. Both read from one DERIVED_RESTRICTION_KEYS
    list, so they cannot drift.

  • Rejected keys resolve to Invalid<TReason>, an unsatisfiable unique symbol branded type whose
    argument carries the reason into the error:

    Type '["teaser"]' is not assignable to type '["teaser"] & Invalid<"\"component_whitelist\" is
    derived from \"allow\"/\"deny\": set one or the other, not both">'.
    

The wire keys stay legal as a lower-level escape hatch. They are not removed, and
restrict_components: false and restrict_type: 'tags' plus the tag lists still compile.

Notes for review

@deprecated cannot produce an inline strikethrough, and that is a TypeScript limitation. Probed
with the language service on TS 6.0.3 and 5.8.3:

  • A key declared on both intersection members with only one tagged reports as not deprecated,
    order independent, unions included. TS requires every declaration to carry the tag. Hence
    FieldWithoutRestrictions, a distributive Omit over Field, so FieldInput is the only
    declaration site. All five keys now report; restrict_type and the tag keys correctly do not.
  • TS has no deprecation reporting for object literal property writes at all, only for reads.
    Confirmed across plain types, intersections, discriminated unions, and interfaces. So no type
    design yields a strikethrough on a written component_whitelist: key. What the tags do deliver:
    the struck-through autocomplete entry with its JSDoc example, a strikethrough on reads, and the
    reason in the conflict error. An inline nudge on the written key would need a lint rule.

Follow-on: schema init dropped the tag dimension

Manual QA against a QA space surfaced a related bug in packages/cli, fixed in the fix(cli)
commits here since it lands in the same feature area and this PR's docs point straight at it.

resolveFieldRestriction classified a tag-restricted field as kind: "none" — the branch for
fields with no restriction in force, which drops restrict_components and restrict_type as
byproducts allow/deny re-derive on push. Nothing re-derives them for the tag dimension, because
allow/deny cannot express tags at all. So schema initschema push pushed back a field whose
tag lists the editor never reads, silently restricting by block name instead.

The fix classifies a tag restriction as its own kind, keyed on restrict_type and a non-empty
tag list. restrict_type alone is not enough: the editor clears all six lists on a dimension switch,
and the Management API backstops that for bloks fields only, so a richtext can hold
restrict_type: 'tags' next to a live component_whitelist from legacy or direct API writes. The
editor reads the name list in exactly that case — allowedComponents.ts gates every dimension on a
non-empty list — so a tag dimension that selects something keeps its wire form with the flags intact,
while an empty one falls through to whichever list is actually in force.

Verified end to end against the has-restrictions scenario, which covers all nine restriction
flavors: the generated code typechecks clean under the stricter defineField, no field round-trips
to a semantically different value, and a second push reports every entity unchanged. The first
push after init is not a no-op on an editor-authored space — the editor always writes the paired
empty list, and init drops it — but every one of those removals is inert, since the editor gates
each list on ?.length. Regression tests cover the lossless round-trip, the stale-name-list case,
and the gate.

Known gaps, out of scope here

  • schema push never calls validateSchema (only schema validate does), so the new
    conflicting_restriction diagnostic protects JS authors only if they run validate first. The
    compile-time rejection is the real guard.
  • restrict_type is itself overwritten by allow/deny at push time (map-to-wire.ts), but is
    deliberately not in DERIVED_RESTRICTION_KEYS, so neither the type nor the validator flags
    allow + restrict_type: 'tags'. The tag list ends up inert. Worth a follow-up.
  • Deprecation never surfaces on an already-written object-literal key — TypeScript reports
    deprecations on reads only. The tags deliver a struck-through autocomplete entry with its reason
    and example, and nothing after that. Catching a written key would need a lint rule.

Fixes DX-550

@maoberlehner
maoberlehner force-pushed the fix/schema-define-field-excess-property-checks branch from 20aac04 to 4ed6407 Compare August 13, 2026 10:33
@pkg-pr-new

pkg-pr-new Bot commented Aug 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@storyblok/angular

npm i https://pkg.pr.new/@storyblok/angular@753

@storyblok/astro

npm i https://pkg.pr.new/@storyblok/astro@753

@storyblok/api-client

npm i https://pkg.pr.new/@storyblok/api-client@753

storyblok

npm i https://pkg.pr.new/storyblok@753

@storyblok/experiments

npm i https://pkg.pr.new/@storyblok/experiments@753

@storyblok/js

npm i https://pkg.pr.new/@storyblok/js@753

storyblok-js-client

npm i https://pkg.pr.new/storyblok-js-client@753

@storyblok/lint-config

npm i https://pkg.pr.new/@storyblok/lint-config@753

@storyblok/live-preview

npm i https://pkg.pr.new/@storyblok/live-preview@753

@storyblok/management-api-client

npm i https://pkg.pr.new/@storyblok/management-api-client@753

@storyblok/migrations

npm i https://pkg.pr.new/@storyblok/migrations@753

@storyblok/nuxt

npm i https://pkg.pr.new/@storyblok/nuxt@753

@storyblok/react

npm i https://pkg.pr.new/@storyblok/react@753

@storyblok/region-helper

npm i https://pkg.pr.new/@storyblok/region-helper@753

@storyblok/richtext

npm i https://pkg.pr.new/@storyblok/richtext@753

@storyblok/schema

npm i https://pkg.pr.new/@storyblok/schema@753

@storyblok/svelte

npm i https://pkg.pr.new/@storyblok/svelte@753

@storyblok/vue

npm i https://pkg.pr.new/@storyblok/vue@753

commit: d18a162

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

⚠️ Cross-package changes detected

This PR is titled as a fix(schema) commit but modifies files across 2 packages:

  • packages/cli/
  • packages/schema/

When this PR is squash-merged, all changes become a single fix(schema) commit.
Nx release uses file changes (not the commit scope) to determine which packages to bump,
so every package listed above will get a version bump on the next release.

If the changes to other packages are cosmetic (README fixes, dependency bumps, config cleanup),
please split them into a separate PR with a chore: title. chore commits don't trigger version bumps.

@maoberlehner
maoberlehner marked this pull request as ready for review August 13, 2026 12:25
@maoberlehner
maoberlehner force-pushed the fix/schema-define-field-excess-property-checks branch from 58c7282 to d7d1c12 Compare August 13, 2026 15:21
@maoberlehner
maoberlehner force-pushed the fix/schema-define-field-excess-property-checks branch from d7d1c12 to f9d7d12 Compare August 14, 2026 08:55
@maoberlehner
maoberlehner force-pushed the fix/schema-define-field-excess-property-checks branch from f9d7d12 to a0fb628 Compare August 14, 2026 09:17
@maoberlehner
maoberlehner force-pushed the fix/schema-define-field-excess-property-checks branch from a0fb628 to d18a162 Compare August 14, 2026 09:46
`defineField` accepted any key. Because the field literal is inferred as the
type parameter, the only check was plain assignability to the constraint, which
ignores excess properties. Typos and options belonging to a different field type
compiled clean, were pushed to the Management API verbatim, and silently did
nothing:

    defineField('x', { type: 'bloks', component_group_whitlist: ['a'] });
    defineField('x', { type: 'text', component_group_whitelist: ['x'] });
    defineField('x', { type: 'asset', restrict_components: true });

Intersect the parameter with `NoExtraKeys<T>`, a mapped type over the keys the
matched `Field` variant does not own, to give excess property checking the
concrete target it needs. `type: 'custom'` is exempt, since plugin option keys
pass through to the Management API verbatim.

Separately, `allow`/`deny` and the wire keys they derive were silently both
legal, and `mapFieldToWire` overwrites the hand-written value, so one of the two
was dropped without warning. `NoRestrictionConflict<T>` makes the combination a
compile error, and `validateSchema` reports the same conflict as a
`conflicting_restriction` issue for consumers without type checking. Both read
from one `DERIVED_RESTRICTION_KEYS` list so they cannot drift.

The five derived keys now carry `@deprecated` JSDoc pointing at `allow`/`deny`,
each stating why and what to use instead. `FieldInput` is built on a
distributive `Omit` over `Field` so it is their only declaration site:
TypeScript reports a property as deprecated only when every declaration carries
the tag, so inheriting the untagged `Field` declaration cancelled the tag out.
`restrict_type` and the two tag lists stay untagged, as `restrict_type: 'tags'`
is the one restriction dimension with no DSL equivalent.

Rejected keys resolve to `Invalid<TReason>`, an unsatisfiable `unique symbol`
branded type whose argument carries the reason into the compiler error.

Refs #750
`schema init` classified a tag-restricted field as `kind: "none"` — the branch
for fields with no restriction in force, which drops `restrict_components` and
`restrict_type` as byproducts `allow`/`deny` re-derive on push. Nothing
re-derives them for the tag dimension: `allow`/`deny` cannot express tags at
all. So the round-trip pushed back a field whose tag lists the editor never
reads, silently restricting by block name instead.

Classify a tag restriction as its own kind, keyed on `restrict_type` (the key
that puts the lists in force), and keep its wire form flags included. Checked
before the name and group dimensions so a stale list left behind by switching
dimensions in the editor is dropped rather than emitted as `allow`, which would
re-derive `restrict_type: ''` and move the same loss elsewhere.
`FieldInput` grafted all eight wire restriction keys onto every `Field` variant,
while `NoExtraKeys` checks a literal against the matched variant. So
`FieldInput`'s own `text` member carried `component_whitelist`, which `text` does
not own, and the exported type documented as "field config accepted by
`defineField`" failed that check:

    const f: FieldInput = { type: 'text', max_length: 10 };
    defineField('a', f);   // TS2345

Build `FieldInput` one variant at a time instead, re-picking only the wire keys
that variant actually declares. Both sides now derive from the same variant, so
they cannot disagree. The `@deprecated` tags survive the `Pick` (verified through
the compiler API: still reported on the five derived keys of the `bloks` member,
still absent on the tag keys and `restrict_type`), and the keys stay rejected on
variants that do not own them, which is what the check was added for.

A generic passthrough wrapper (`<F extends FieldInput>(f: F) => defineField(n, f)`)
still needs a cast. That is inherent to intersection-based excess property
checking — `F` cannot be proven assignable to `F & NoExtraKeys<F>` while it is
unresolved — and is not addressed here.
`resolveFieldRestriction` only recognised a tag restriction when a tag list was
non-empty. A field with `restrict_type: 'tags'` and empty lists fell through to
the name dimension and then to `kind: "none"`, which drops `restrict_type` and
`restrict_components` as byproducts `allow`/`deny` re-derive on push — except
there is no `allow`/`deny` here to re-derive them.

Reproduced against a real space: `schema init` emitted a bare

    defineField('content', { type: 'bloks' })

for a field the space stored as `restrict_components: true, restrict_type:
'tags'`, so the round-trip unrestricted the field.

Key the dimension on `restrict_type` alone, as the surrounding docs already
described. The non-empty guard was there to stop a stale name list left behind by
switching dimensions from being emitted as `allow`, but the Management API clears
the name and group lists when `restrict_type` is `'tags'` (verified against the
API: a `component_whitelist` written alongside `restrict_type: 'tags'` comes back
stripped), so there is no stale list to guard against.
Two related losses in `schema init`, both found by round-tripping a real space.

`restrict_components: true` with an empty `component_whitelist` was classified as
`kind: "none"` and dropped as a wire byproduct that `allow` re-derives on push.
But an empty list emits no `allow`, so nothing re-derived it and the round-trip
turned "restricted, nothing selected" into "unrestricted". Keep the flag, and the
dimension selector with it, when no list is in force.

Keeping it exposed a second problem. The Management API stores a component schema
as an opaque blob, so a space can hold restriction keys on a field type that does
not own them — a stray `restrict_components` on an `asset` field is stored
verbatim (verified against the API). `defineField` rejects an option the field
type does not own, so emitting those keys generates code that does not compile.
This was already reachable through the `disabled` branch before this change: a
space with `restrict_components: false` on an `asset` field made `schema init`
emit a file failing with

    error TS2322: Type 'false' is not assignable to type
      'false & Invalid<"unknown option "restrict_components" for this field type">'

Only emit the restriction keys for the field types that own them. That covers the
tag lists too, which reached the output through the untouched-key passthrough
rather than the restriction branches and so needed the same guard.

Verified over a 21-field matrix covering every restriction dimension and the full
editor option surface: the generated file type-checks, no field round-trips to a
*different* value, and a second push is a no-op. The only keys dropped are empty
lists and keys on field types that cannot read them.
The tag dimension claimed any field with `restrict_type: 'tags'`, including
one carrying a live `component_whitelist`. The editor clears all six lists on
a dimension switch and the Management API only backstops that for `bloks`
fields, so a `richtext` can hold that pairing from legacy or API writes, and
claiming it for a dimension that selects nothing dropped the only list in
force. The tag branch now needs a non-empty tag list; a tag dimension with
empty lists falls to `raw`, which keeps `restrict_type` and
`restrict_components` verbatim as before.

Also corrects three comments against editor-authored data: Management API
normalization is `bloks`-only and strips name lists only, the editor and the
backend genuinely disagree on an absent `restrict_components`, and allow plus
deny in one dimension is not reachable through the editor.
The regression test added with the fix covered `text` and `bloks`. `bloks`
legitimately owns the wire restriction keys, so it cannot fail the check — the
guard was really one variant wide, while `FieldInput` is now assembled one
variant at a time and a regression can hit any single one.

Checked against the pre-fix tree: 14 of the 17 variants reproduce the original
TS2345. The three that do not (`richtext`, `bloks`, `custom`) are the ones
that declare the keys themselves.

The annotation on the const is what makes this bite. Routing the same values
through a `(f: FieldInput) => ...` parameter compiles clean even on the broken
tree, so the consts are deliberate and noted as such.
@maoberlehner
maoberlehner force-pushed the fix/schema-define-field-excess-property-checks branch from d18a162 to ea002c2 Compare August 14, 2026 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant