diff --git a/.changeset/secret-widget-spellings-5375.md b/.changeset/secret-widget-spellings-5375.md new file mode 100644 index 0000000000..2f405f2118 --- /dev/null +++ b/.changeset/secret-widget-spellings-5375.md @@ -0,0 +1,40 @@ +--- +'@object-ui/components': minor +'@object-ui/core': minor +--- + +Three more secret-field spellings no longer render a secret in clear text on the form'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 +``` + +Two halves, per the maintainer ruling of 2026-08-20: + +- **`@object-ui/core` — an unresolvable namespaced widget id is now an authoring + ERROR.** A form field's widget id (`widget`, else `type`) may name the + `field:` namespace or a bare name; any other namespace resolves no field + widget (objectui#5254) and used to degrade silently to a plain text box. + `validateSchema` now reports `UNRESOLVABLE_FIELD_WIDGET_NAMESPACE` and + `assertValidSchema` throws. Behaviour change: a schema that previously + validated with e.g. `type: 'ui:password'` is now invalid — inventing a + plausible-looking widget id fails loudly instead of rendering clear text. + `field:` ids stay valid whether or not the widget is registered, since + registration is a runtime fact an authoring-time validator cannot see. +- **`@object-ui/components` — the known secret types cover the remaining + spellings.** Bare `secret` and `ui:password` render the native masked input, + and `field:secret` is refused outright like `field:password`. Existing authors + need no migration. + +`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. No producer emits +any of the three; all are reachable only through a hand-authored standalone +form schema, which is exactly the surface where the author is the producer and +no normalizer sits in between. diff --git a/packages/components/src/renderers/form/__tests__/form-secret-widget-spellings.test.tsx b/packages/components/src/renderers/form/__tests__/form-secret-widget-spellings.test.tsx new file mode 100644 index 0000000000..c483000bc8 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-secret-widget-spellings.test.tsx @@ -0,0 +1,211 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * The three remaining secret-field spellings on the unregistered-widget + * `default` branch — objectui#5375, maintainer ruling of 2026-08-20 (C + A). + * This file pins **half A**: the defense-in-depth table entries. Half C — the + * authoring-time refusal of a namespaced id that is not `field:` — is pinned in + * `packages/core/src/validation/__tests__/form-field-widget-namespace.test.ts`. + * + * ## What was measured on this card's branch point (`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 + * + * All three put the secret on screen in clear text. For contrast, `password` + * renders a native masked input and `field:password` is refused outright. + * + * ## Why each one missed, and what each gets now + * + * - **`ui:password`** — `renderFieldComponent` resolves a field's `type` only + * through the `field:` namespace (objectui#5254), so a `ui`-qualified id + * resolves nothing and falls here, where it missed + * `NATIVE_INPUT_FIELD_TYPES` (keyed on the `field:`-stripped type). It is + * the "verifying does not protect you" shape: `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. It now takes the + * native masked input, and half C makes writing it an authoring error. + * - **bare `secret`** — the ObjectQL field type. `mapFieldTypeToFormType` maps + * it to `field:password`, so an OBJECT-derived secret field was always safe; + * a hand-authored `{ name, type: 'secret' }` in a standalone `FormSchema` + * does not go through that mapping. It claims no registered widget, so this + * branch is its intended home: native masked input, same as bare `password`. + * - **`field:secret`** — a REGISTRY KEY naming a widget nothing registers, so + * reaching this branch with it proves the declared widget is absent. Refused + * outright, exactly as `field:password` is. + * + * ## Severity, stated honestly + * + * No producer emits any of the three; all are reachable only through a + * hand-authored form schema. That is lower severity than objectui#5322 — but a + * hand-authored standalone form is exactly the surface where the AUTHOR is the + * producer and no normalizer sits in between. + * + * ## Build artifacts + * + * None between the edit and the thing under test. The root `vitest.config.mts` + * `alias` block maps every `@object-ui/*` specifier to that package's `src/`, + * and the renderer under test is imported through a relative path + * (`../../../renderers`). No `dist/` is consulted on this leg. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +// Module scope, not `beforeAll` — the cold transform must not be billed to +// `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010). +import '../../../renderers'; + +/** Render the built-in branch: no `registerAllFields()`, so nothing resolves under `field:`. */ +function renderForm(fields: any[], extra: Record = {}) { + const Form = ComponentRegistry.get('form')!; + return render( +
, + ); +} + +const input = () => document.querySelector('input') as HTMLInputElement | null; + +afterEach(cleanup); + +describe('secret field spellings on the unregistered-widget default branch (objectui#5375)', () => { + describe('the routing premise', () => { + it('resolves NOTHING under `field:` on this path', () => { + // Counter-probe: without it the whole file would pass on a registry + // where the widgets happen to exist — never exercising the branch. + expect(ComponentRegistry.get('form')).toBeTruthy(); + expect(ComponentRegistry.get('field:secret')).toBeUndefined(); + expect(ComponentRegistry.get('field:password')).toBeUndefined(); + }); + + it('DOES resolve `ui:password` — the "verifying does not protect you" fact', () => { + // The whole reason half C is the primary fix: an author who checks + // whether this id resolves gets a yes, and still got a clear-text box. + // (`./input.tsx` registers `password` with `namespace: 'ui'`.) + expect(ComponentRegistry.get('ui:password')).toBeTruthy(); + }); + }); + + describe('`ui:password` — a registered SDUI node id that is not a field widget', () => { + it('renders a native MASKED input, not a clear-text box', () => { + // Pre-fix: type="text" — the secret on screen in clear text. + const { container } = renderForm([ + { name: 'pw', label: 'Password', type: 'ui:password' }, + ]); + const el = container.querySelector('input') as HTMLInputElement; + expect(el).toBeTruthy(); + expect(el.getAttribute('type')).toBe('password'); + }); + + it('does not resolve the registered `ui` node renderer as the field widget', () => { + // objectui#5254's ruling stands: the field path resolves colon-qualified + // ids ONLY under `field:`. If the cross-namespace fallback came back, the + // `ui` renderer would draw its own