Skip to content

fix(form): refuse invented namespaced field widget ids, and cover the remaining secret spellings - #5448

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-5375-secret-widget-spellings
Aug 20, 2026
Merged

fix(form): refuse invented namespaced field widget ids, and cover the remaining secret spellings#5448
os-support-ai merged 2 commits into
mainfrom
claude/issue-5375-secret-widget-spellings

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#5375

Three field-type spellings still put a secret on screen in clear text on the form renderer's unregistered-widget branch. Measured on main at f2e11ae6f, the real form renderer on the built-in path (no registerAllFields()), before and after the earlier fix:

type registry hit rendered type
ui:password true text
secret false text
field:secret false text

For contrast, password renders a native masked input and field:password is refused outright.

Maintainer ruling of 2026-08-20, accepted verbatim (「其他接受你的建议。」): C + A, landed together.

The earlier fix closed this class only halfway — say so plainly

PR #5374 / issue #5322 is titled as if the "a secret shown in clear text" condition were shut. It is shut on the producer-reachable path only. mapFieldTypeToFormType maps the ObjectQL secret type to field:password, so every object-derived secret field is answered there. A hand-authored standalone FormSchema does not go through that mapping — the author is the producer and no normalizer sits in between — and the three spellings above walked straight past the fix. #5322 remains closed and is not re-opened by this PR; this is the difference, recorded so the next reader is not misled.

C (the primary) — an invented namespaced widget id is an authoring ERROR

packages/core/src/validation/schema-validator.ts

A form field's widget id may name the field: namespace or a bare name. Any other namespace resolves no field widget on the field path (that is #5254's ruling, and renderFieldComponent implements exactly it), so it fell through to the plain-text default arm and degraded silently. validateFormSchema now reports UNRESOLVABLE_FIELD_WIDGET_NAMESPACE as an error, so validateSchema(...).valid is false and assertValidSchema throws.

Why the class rather than another table entry: ui:password IS registered — as an SDUI node renderer for a top-level { type: 'email' }-style node — so an author who checks whether it resolves gets a yes, and still gets a clear-text box as a field. That is the shape where verifying does not protect you. A renderer-side table answers today's three spellings and leaves the next invented id the same silent degrade, which is literally how this card came to exist after #5322: that fix keyed on the field:-stripped type and ui:password walked past it. AI-authored metadata invents plausible-looking widget ids constantly; this makes inventing one fail loudly.

Deliberately not a warning — the ruling rules that out by name, because a warning is the same silent degrade under a new spelling.

What stays legal, and why:

What is decidable statically is the namespace, and it is a closed set of one.

The check reads widget then type, mirroring the renderer's own resolvedType precedence, so validator and renderer cannot disagree about which component will actually render.

The census — the ruling made arming this conditional on one

Scanned this repo's authored surface two ways: a textual pass over every authored file (bracket-matching each fields: [ ... ] region and collecting the type / widget literals inside it) and a structural pass that parses every JSON/YAML and walks form-field-shaped objects.

readingcount
authored files scanned4,397
form-field type/widget literals (textual)649
form-field entries parsed structurally (examples/schema-catalog)98
colon-qualified ids on the form-field path7
— of those, field:* (stay legal)5
— of those, a non-field: namespace2 occurrences, 1 source line, 1 distinct id
ui:password / bare secret / field:secret on the field path0

The five field:* are field:text, field:select and two template literals (field:${type}). The one non-field: id is ref:component, at packages/app-shell/src/views/metadata-admin/RefComponentWidget.test.tsx:174 (counted twice because it sits inside two nested fields: arrays). It is out of the gated shape: a metadata-admin designer SchemaForm spec form-VIEW field ({ field, widget } under sections[].fields[], node type simple), resolved by app-shell's own WIDGETS table, never by renderFieldComponent. This check is gated on type === 'form' with runtime fields[].name, so it never reaches it — pinned as a test.

Counter-probe, and it caught a real methodology bug. The first run reported zero hits under examples/. Probing for form fields I knew were present (examples/schema-catalog/src/schemas/components-form-form/*.json) showed the scan's key regex did not accept JSON-quoted keys ("type":), only unquoted ones. After the fix examples/schema-catalog reports 133 textual and 98 structural hits, including bare password three times. So the zero above is a real zero, not a broken probe.

Small count, all hits clearly out of scope ⇒ the refusal is armed, per step 2 of the ruling's gate.

A (defense in depth, same landing) — the known secret tables

packages/components/src/renderers/form/form.tsx

  • NATIVE_INPUT_FIELD_TYPES gains secret and ui:password, so both render the native masked input.
  • SECRET_FIELD_TYPES gains secret, so field:secret is refused outright exactly as field:password is — no input, no value in the DOM, and a role="alert" box naming the missing widget.

Zero migration: an existing author who wrote either spelling keeps a working field, and a host that never validates still does not leak. ui:password is keyed raw, not as a declared type, and deliberately so — normalizeFieldType strips only field:, and giving ui: a stripping rule of its own would re-open the cross-namespace resolution #5254 removed and would silently move ui:email, ui:date and every id this card never measured. A test pins ui:email still rendering type="text" so that widening cannot happen unnoticed.

Fences honoured

Verification, at 420811271

Both halves reverse-verified separately, direction predicted before running. No build sits between the edits and the tests: the root vitest.config.mtsalias block maps every @object-ui/* specifier to that package's src/, and both files reach their subject by relative path, so no dist/ is consulted on any leg. Each ablation was taken from the committed fix and restored by git checkout branch -- path, proven byte-identical with an empty git diff --stat HEAD.

legpredictedobserved
A ablated (form.tsx reverted)5 RED — ui:password and secret render type="text", field:secret renders an input instead of the refusal5 failed / 20 passed, exactly those
C ablated (validator reverted)7 RED — the invented id passes validation7 failed / 18 passed, headline expected true to be false on result.valid
both restoredgreengreen

The A-leg dump is the defect verbatim: type="text" value="hunter2" for a field:secret field.

Green readings on the merged head:

vitest packages/core/ packages/components/src/renderers/form/
Test Files 142 passed (142)
Tests 2258 passed (2258)
vitest packages/core/ 92 files / 1929 tests passed
vitest packages/components/ 171 files / 1560 tests passed
vitest packages/react/ packages/plugin-form/ 104 files / 1225 tests passed (direct consumers of validateSchema)
type-check @object-ui/core, @object-ui/components Done (both echoed `tsc --noEmit`)
eslint packages/core/src/validation, packages/components/src/renderers/form
0 errors, 265 warnings (all pre-existing `no-explicit-any`; the count
dropped from 267 — the new helper adds none)
check:control-bytes PASS check:phantom-deps PASS check:self-import PASS
check:esm-specifiers PASS check:i18n-keys PASS check:i18n-drift PASS
check-changeset-no-major PASS check-changeset-presence PASS
plus a direct control-byte scan of every changed file — clean

No test was skipped, disabled or quarantined.

A changeset is included (minor on both packages; the @object-ui/core half is a behaviour change — a schema that previously validated with an invented namespaced id is now invalid).


Generated by Claude Code

… remaining secret spellings
Three field-`type` spellings still put a secret on screen in clear text on the
form renderer's unregistered-widget branch. Measured on `main` at `f2e11ae6f`,
the real `form` renderer on the built-in path (no `registerAllFields()`), before
and after objectui#5322's fix:
type registry hit rendered type
ui:password true text
secret false text
field:secret false text
Maintainer ruling of 2026-08-20 — C + A, in one landing.
C (the primary, `@object-ui/core`): a form field's widget id that names a
namespace other than `field:` resolves NO field widget on the field path
(objectui#5254) and silently degrades to a plain text box. `validateFormSchema`
now reports it as an ERROR (`UNRESOLVABLE_FIELD_WIDGET_NAMESPACE`), so
`assertValidSchema` throws and `validateSchema(...).valid` is false. Deliberately
NOT a warning: a warning is the same silent degrade under a new name. `field:`
ids stay legal whether or not the widget is registered — registration is a
runtime fact — and bare names stay legal because they are an open set.
A (defense in depth, `@object-ui/components`): bare `secret` and `ui:password`
join `NATIVE_INPUT_FIELD_TYPES` (native masked input) and `secret` joins
`SECRET_FIELD_TYPES`, so `field:secret` is refused exactly as `field:password`
is. Existing authors need zero migration, and a host that never validates still
does not leak.
`ui:password` IS registered — as an SDUI node renderer for a top-level
`{ type: 'email' }`-style node — so an author who checked whether it resolved
got a yes and still got a clear-text box on the field path. That is why the
class, not another table entry, is the primary fix.
The census the ruling made this conditional on: 4,397 authored files, 649
form-field `type`/`widget` literals, 98 structurally parsed form-field entries in
`examples/schema-catalog`. Seven colon-qualified ids on the form-field path, five
of them `field:*`; the remaining two occurrences are one source line naming
`ref:component`, a metadata-admin designer widget on a different shape this check
never reaches. Zero occurrences of any of the three measured spellings.
No producer emits any of the three — all are reachable only through a
hand-authored standalone form schema. Not a p0.
Fixes#5375
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-BX718KbZ.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)507.00KB113.63KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.34KB
fields (index.js)237.21KB59.50KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.43KB32.92KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)124.19KB30.20KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)197.28KB53.06KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.64KB27.13KB
plugin-map (index.js)20.08KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.52KB20.67KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)36.10KB12.26KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-support-ai
os-support-ai marked this pull request as ready for review August 20, 2026 21:17
@os-support-ai
os-support-ai added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit 91783c4Aug 20, 2026
23 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5375-secret-widget-spellings branch August 20, 2026 21:18
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Two more secret-field spellings still render type="text" on the form's unregistered-widget branch: ui:password and a bare secret

2 participants

@os-support-ai@claude