From a47de0e0c20f45b84e4f5578fbdc7e0bdd28514e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:10:20 +0000 Subject: [PATCH 1/3] feat(fields): export the masked-field-type authority; plugin-detail reads it (objectui#8686) `@object-ui/fields` gains `MASKED_FIELD_TYPES` and `isMaskedFieldType()`, the read-side twin of `isInlineExcludedFieldType()`. The standard cell table's masked entries are built from the set, and the predicate reads the live cell registry: the mask registered under a new type answers true, a shipped mask replaced by one of the package's own renderers answers false, and a shipped mask replaced by an unreadable host component keeps the declared answer. `plugin-detail`'s `isMaskedDetailFieldType` drops its local two-member copy and asks the authority, so a masked type registered in `fields` refuses the copy affordance with no edit there. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .../8686-masked-field-type-authority.md | 33 ++++ .../__tests__/isMaskedFieldType-8686.test.tsx | 183 ++++++++++++++++++ packages/fields/src/index.tsx | 69 ++++++- ...lSection.maskedTypeAuthority-8686.test.tsx | 158 +++++++++++++++ packages/plugin-detail/src/fieldEnrichment.ts | 61 ++---- 5 files changed, 458 insertions(+), 46 deletions(-) create mode 100644 .changeset/8686-masked-field-type-authority.md create mode 100644 packages/fields/src/__tests__/isMaskedFieldType-8686.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/DetailSection.maskedTypeAuthority-8686.test.tsx diff --git a/.changeset/8686-masked-field-type-authority.md b/.changeset/8686-masked-field-type-authority.md new file mode 100644 index 0000000000..cd61444ff1 --- /dev/null +++ b/.changeset/8686-masked-field-type-authority.md @@ -0,0 +1,33 @@ +--- +'@object-ui/fields': minor +'@object-ui/plugin-detail': patch +--- + +feat(fields): `isMaskedFieldType()` and `MASKED_FIELD_TYPES` answer "is this field type's cell drawn as a mask?" + +**New public API on `@object-ui/fields`:** `MASKED_FIELD_TYPES` (a `ReadonlySet`, +today `password` and `secret`) and `isMaskedFieldType(fieldType)`. They are the read-side +twin of `INLINE_EXCLUDED_FIELD_TYPES` / `isInlineExcludedFieldType()`. Additive; nothing +existing changes shape. + +Until now the fact lived only as two entries in `getCellRenderer`'s standard table, and +nothing outside the package could ask it. So the detail page's copy refusal +(objectui#8440) kept its own two-member list: a masked type added to the fields package +would render masked and stay one click from the clipboard there. + +- The standard table's masked entries are now built from `MASKED_FIELD_TYPES`, so the + set and the drawn mask are one fact. +- `isMaskedFieldType()` reads the LIVE cell registry. A type registered with the mask + (`registerFieldRenderer('api_token', getCellRenderer('password'))`) answers `true`. A + shipped mask replaced at runtime with one of this package's own renderers (for example + `TextCellRenderer`) answers `false`, since the cell now shows the value. A shipped mask + replaced with a host component the package cannot inspect keeps the declared answer + (`true`), on the side that withholds the value. +- It matches raw spellings only, as `getCellRenderer` does: `field:password` renders in + the clear and is not masked. + +`@object-ui/plugin-detail`: `isMaskedDetailFieldType` now asks `isMaskedFieldType()` +instead of keeping its own list. It stays the narrow-only union of the view's and the +object's type (objectui#3355). Behaviour changes only where the two used to disagree: +a masked type registered at runtime now refuses the copy affordance, and a shipped mask +that a host replaced with a package text renderer offers it again. diff --git a/packages/fields/src/__tests__/isMaskedFieldType-8686.test.tsx b/packages/fields/src/__tests__/isMaskedFieldType-8686.test.tsx new file mode 100644 index 0000000000..cf94897b6f --- /dev/null +++ b/packages/fields/src/__tests__/isMaskedFieldType-8686.test.tsx @@ -0,0 +1,183 @@ +/** + * 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. + */ + +/** + * `isMaskedFieldType()` is THE authority for "is this field type's cell drawn + * as a mask?" (objectui#8686), and `MASKED_FIELD_TYPES` is the set behind it. + * + * Before this card the fact had no queryable home: the mask lived as two table + * entries inside `getCellRenderer`'s standard map, so every consumer that had + * to honour it (the detail page's copy refusal, objectui#8440) kept its own + * list. The ruling on objectui#8686: the predicate reads the LIVE renderer + * registration where it can, falling back to the declared set. + * + * Three families of pins: + * + * - DECLARED — every member of the set answers `true` AND its cell really + * draws the mask, with a lit non-masked control. The set is tied to what + * the cell draws, not only to itself. + * - CENSUS — over every type `getCellRenderer` resolves to a renderer of its + * own, the predicate agrees with the resolver: `true` exactly where the + * resolved renderer is the mask. A future edit that grows the table with a + * second masked entry outside the set, or answers from a list again, goes + * red by name. + * - RUNTIME — the override behaviour the ruling asked to be decided: + * registering the mask under a NEW type masks it; replacing a declared + * type's mask with one of this package's renderers unmasks it; replacing it + * with a host component this package cannot read keeps the declared answer. + * + * ⚠️ `registerFieldRenderer` has no inverse and the RUNTIME cases mutate the + * registry, so this file is a `.tsx`: it lands in the `dom` project, which keeps + * `isolate: true` (the `unit` project shares one module graph across files). + * The pristine-state families are declared first and run first, and every + * override case restores the mask it replaced. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import * as React from 'react'; +import { + getCellRenderer, + isMaskedFieldType, + listCellRendererTypes, + MASKED_FIELD_TYPES, + registerFieldRenderer, + TextCellRenderer, + type CellRendererProps, +} from '../index'; + +afterEach(() => cleanup()); + +const MASK_TEXT = '••••••'; +const RAW = 'objectui-8686-raw-credential'; + +/** + * The mask renderer, captured through the public resolver BEFORE anything in + * this file touches the registry. It is not exported, and does not need to be: + * `getCellRenderer('password')` is how a host reaches it too. + */ +const MASK_RENDERER = getCellRenderer('password'); + +function renderCell(type: string, value: unknown): HTMLElement { + const Renderer = getCellRenderer(type); + const field = { type, name: type } as unknown as CellRendererProps['field']; + return render().container; +} + +describe('objectui#8686 — DECLARED: the set, the predicate and the drawn mask are one fact', () => { + it('the declared set is non-empty, so every loop below runs', () => { + expect(MASKED_FIELD_TYPES.size, 'an empty set would make the loops vacuous').toBeGreaterThan(0); + }); + + for (const type of MASKED_FIELD_TYPES) { + it(`\`${type}\` is masked: the predicate says so AND the cell draws the mask`, () => { + expect(isMaskedFieldType(type), `${type} is a declared masked type`).toBe(true); + const cell = renderCell(type, RAW); + expect(cell.textContent, `${type}: the cell drew the mask`).toBe(MASK_TEXT); + expect(cell.innerHTML, `${type}: the stored value never reaches the DOM`).not.toContain(RAW); + }); + } + + it('LIT CONTROL — `text` is not masked, and its cell draws the value', () => { + expect(MASKED_FIELD_TYPES.has('text'), 'control: `text` is not declared masked').toBe(false); + expect(isMaskedFieldType('text'), '`text` is not masked').toBe(false); + const cell = renderCell('text', RAW); + expect(cell.textContent, 'the same probe value IS drawn by a non-masked cell').toContain(RAW); + }); + + it('no type, and spellings the cell path does not resolve, are not masked', () => { + expect(isMaskedFieldType(undefined)).toBe(false); + expect(isMaskedFieldType('')).toBe(false); + expect(isMaskedFieldType('objectui-8686-never-registered')).toBe(false); + // The form-alias spelling renders in the clear: `getCellRenderer` is an + // exact-key lookup and does not resolve `field:` aliases. + expect(getCellRenderer('field:password'), 'control: the alias spelling falls to text').toBe( + TextCellRenderer, + ); + expect(isMaskedFieldType('field:password'), 'so it is not masked either').toBe(false); + }); +}); + +describe('objectui#8686 — CENSUS: the predicate agrees with the resolver on every registered type', () => { + it('`true` exactly where the resolved renderer is the mask, over the live registry reading', () => { + const types = listCellRendererTypes(); + // Lit halves: the population holds every declared member and at least one + // type that is not masked, so the agreement below compares both answers. + for (const t of MASKED_FIELD_TYPES) expect(types, `${t} is in the census`).toContain(t); + expect(types, 'a non-masked control type is in the census').toContain('text'); + + const disagreeing = types.filter( + (t) => isMaskedFieldType(t) !== (getCellRenderer(t) === MASK_RENDERER), + ); + expect(disagreeing, 'types where the predicate and the drawn cell disagree').toEqual([]); + + const masked = types.filter((t) => isMaskedFieldType(t)); + expect( + [...masked].sort(), + 'with nothing registered at runtime, the masked types are exactly the declared set', + ).toEqual([...MASKED_FIELD_TYPES].sort()); + }); +}); + +describe('objectui#8686 — RUNTIME: the live registration is read, the declared set is the fallback', () => { + it('registering THE mask under a NEW type masks it, with no declared-set edit', () => { + const type = 'objectui_8686_api_token'; + // Control leg — attributes the answer below to the registration. + expect(isMaskedFieldType(type), 'control: an unregistered type is not masked').toBe(false); + expect(renderCell(type, RAW).textContent, 'control: it draws the value').toContain(RAW); + cleanup(); + + registerFieldRenderer(type, getCellRenderer('password')); + + expect(MASKED_FIELD_TYPES.has(type), 'the declared set was not edited').toBe(false); + expect(isMaskedFieldType(type), 'the live registration is read').toBe(true); + expect(renderCell(type, RAW).textContent, 'and the cell draws the mask').toBe(MASK_TEXT); + }); + + it("replacing a declared type's mask with one of this package's renderers UNMASKS it", () => { + const type = 'password'; + expect(isMaskedFieldType(type), 'control: masked before the override').toBe(true); + try { + registerFieldRenderer(type, TextCellRenderer); + // The override took effect: the cell now shows the value. + expect(renderCell(type, RAW).textContent, 'the overridden cell draws the value').toContain( + RAW, + ); + expect(isMaskedFieldType(type), 'the predicate follows the cell').toBe(false); + } finally { + registerFieldRenderer(type, MASK_RENDERER); + } + expect(isMaskedFieldType(type), 'restored: masked again').toBe(true); + }); + + it("replacing a declared type's mask with a HOST component keeps the declared answer", () => { + const type = 'secret'; + // A host's own mask: this package cannot tell it from a component that + // prints the value, so it answers with the declared set, on the side that + // withholds the value. + const HostMask: React.FC = () => [hidden by host]; + expect(isMaskedFieldType(type), 'control: masked before the override').toBe(true); + try { + registerFieldRenderer(type, HostMask); + expect(getCellRenderer(type), 'the override took effect').toBe(HostMask); + expect(isMaskedFieldType(type), 'an unreadable override falls back to the declared set').toBe( + true, + ); + } finally { + registerFieldRenderer(type, MASK_RENDERER); + } + }); + + it('a HOST component under an undeclared type is not masked: the set is the only fallback', () => { + const type = 'objectui_8686_host_only'; + const HostCell: React.FC = () => host; + registerFieldRenderer(type, HostCell); + expect(getCellRenderer(type), 'control: the host component is registered').toBe(HostCell); + expect(isMaskedFieldType(type)).toBe(false); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 560d590f3b..5e03d8097d 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -3276,6 +3276,69 @@ function MaskedCellRenderer({ value }: CellRendererProps): React.ReactElement { return ••••••; } +/** + * The field types this package DECLARES masked: their standard cell is + * {@link MaskedCellRenderer}, which draws `••••••` in place of the value + * (objectui#8686). The read-side twin of the credential entry in + * `INLINE_EXCLUDED_FIELD_TYPES`, which lives in `FieldEditWidget`. + * + * ⭐ This set BUILDS the table's masked entries — `buildStandardCellRendererMap` + * spreads one {@link MaskedCellRenderer} entry per member — so adding a type + * here masks its cell AND makes {@link isMaskedFieldType} answer `true` for it, + * in one edit. There is no second list to keep in step. + * + * ⚠️ Membership answers "what ships masked", ⛔ not "what is masked right now": + * `registerFieldRenderer` can add or replace a masked type at runtime, and a + * set cannot see that. Consumers deciding what to do with a cell's value ask + * {@link isMaskedFieldType}. + * + * RAW spellings, deliberately: the cell path does not resolve form aliases + * (`getCellRenderer` is an exact-key lookup), so `field:password` renders in + * the clear and is not a member. + * + * Owned here for now. The objectui#8686 ruling made the protocol the first + * place to look: if `@objectstack/spec` comes to declare which field types are + * credentials, this set derives from that declaration instead of listing types. + */ +export const MASKED_FIELD_TYPES: ReadonlySet = new Set(['password', 'secret']); + +/** + * Is a cell of this field type drawn as a mask instead of as its value? The + * one authority for that question (objectui#8686): ask it rather than keep a + * list of types. The read-side twin of `isInlineExcludedFieldType()`. + * + * A LIVE reading of the cell registry, taken at call time, in the order + * {@link getCellRenderer} resolves: the runtime registry first, then the + * standard table. + * + * 1. The type resolves to THE mask ({@link MaskedCellRenderer}) → `true`, + * declared or not. That is how a host adds a masked type: + * `registerFieldRenderer('api_token', getCellRenderer('password'))`. + * 2. Otherwise, a type outside {@link MASKED_FIELD_TYPES} → `false`. + * 3. A declared type whose mask a host REPLACED at runtime + * (`registerFieldRenderer('password', X)`) → the override is read: + * - X is one of this package's own cell renderers (e.g. `TextCellRenderer`) + * → `false`. None of them masks, so the cell now shows the value. + * - X is the host's own component → `true`, the declared answer. Nothing + * here can tell whether an opaque component masks. Answering `false` + * would offer a credential to anything that trusts this predicate + * (the detail page's copy affordance, objectui#8440) while a custom mask + * hides it on screen. So an unreadable override falls back to the + * declared set, on the side that withholds. + * + * RAW spelling, no alias resolution, for the reason {@link MASKED_FIELD_TYPES} + * states. Side-effect free, like `isInlineExcludedFieldType()`: unlike + * {@link getCellRenderer}, it never reports a retired spelling. + */ +export function isMaskedFieldType(fieldType: string | undefined): boolean { + if (!fieldType) return false; + const standardMap = buildStandardCellRendererMap(); + const live = fieldRegistry.has(fieldType) ? fieldRegistry.get(fieldType) : standardMap[fieldType]; + if (live === MaskedCellRenderer) return true; + if (!MASKED_FIELD_TYPES.has(fieldType)) return false; + return !Object.values(standardMap).some((standard) => standard === live); +} + /** * `vector` / `grid` cell renderers: the placeholder literal for a value that is * stored, and the shared affordance for none (objectui#8678). Before this, both @@ -3374,8 +3437,10 @@ function buildStandardCellRendererMap(): Record [type, MaskedCellRenderer])), location: LocationCellRenderer, geolocation: LocationCellRenderer, address: AddressCellRenderer, diff --git a/packages/plugin-detail/src/__tests__/DetailSection.maskedTypeAuthority-8686.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.maskedTypeAuthority-8686.test.tsx new file mode 100644 index 0000000000..1a786ca580 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/DetailSection.maskedTypeAuthority-8686.test.tsx @@ -0,0 +1,158 @@ +/** + * 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 detail page's copy refusal reads the fields package's masked-type + * AUTHORITY, not a list of its own (objectui#8686). + * + * objectui#8440 made a masked row refuse the copy affordance, and had to name + * `password` / `secret` a second time in `fieldEnrichment.ts` to do it: a + * mirror, with the cost written beside it — a masked type added in + * `@object-ui/fields` would render masked and stay copy-interactive here until + * someone edited that mirror. The objectui#8686 ruling's pin is exactly that + * case: register a NEW masked type in `fields`, and this page refuses the copy + * with no edit to `plugin-detail`. + * + * The new type is registered the way a host does it, through the published + * `registerFieldRenderer` with the mask `getCellRenderer('password')` resolves + * to. Its spelling appears nowhere in `plugin-detail`, so only a live read of + * the authority can refuse it. + * + * Each case carries a CONTROL LEG in the same test: before the registration the + * very same row draws the value and copies it. The refusal after it is thereby + * attributable to the registration, and not to a row that never rendered or a + * spy that was never reachable. The five copy paths and the ordinary-row + * control follow `DetailSection.maskedCopyRefusal-8440`. + * + * ⚠️ `registerFieldRenderer` has no inverse. This file is a `.tsx` (the `dom` + * project, `isolate: true`), and every override of a shipped type is restored. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import * as React from 'react'; +import { getCellRenderer, registerFieldRenderer, TextCellRenderer } from '@object-ui/fields'; +import type { DetailViewSection } from '@object-ui/types'; +import { DetailSection } from '../DetailSection'; + +let writeText: ReturnType; + +const NEW_MASKED_TYPE = 'objectui_8686_api_token'; +const RAW = 'sk_live_objectui_8686'; +const MASK = '••••••'; +const CONTROL_VALUE = 'Plain String Value'; + +beforeAll(() => + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }), +); + +beforeEach(() => { + writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); +}); + +afterEach(cleanup); + +const renderRows = (credentialType: string) => { + const objectSchema = { + fields: { + token: { type: credentialType, label: 'Token' }, + name: { type: 'text', label: 'Name' }, + }, + }; + return render( + , + ); +}; + +/** The desktop row element carrying the click / Enter / Space handlers. */ +const desktopRow = (label: string): HTMLElement => { + const labelEl = screen.queryByText(label); + expect(labelEl, `CONTROL: a row labelled "${label}" is on screen`).not.toBeNull(); + return ((labelEl as HTMLElement).parentElement as HTMLElement).lastElementChild as HTMLElement; +}; + +const payloads = () => writeText.mock.calls.map((call) => call[0]); + +/** Every copy path a desktop row carries, the hover button included. */ +function fireEveryCopyPath(row: HTMLElement) { + fireEvent.click(row); + fireEvent.keyDown(row, { key: 'Enter', code: 'Enter' }); + fireEvent.keyDown(row, { key: ' ', code: 'Space' }); + const button = row.querySelector('button'); + if (button) fireEvent.click(button); +} + +describe('DetailSection — copy refusal reads the fields authority (#8686)', () => { + it('a masked type registered in `fields` refuses the copy here, with no edit to plugin-detail', () => { + // CONTROL LEG — before the registration the type is unknown to `fields`: + // the cell draws the value and the row copies it, on the same paths. + renderRows(NEW_MASKED_TYPE); + const before = desktopRow('Token'); + expect(before.textContent, 'CONTROL: unregistered, the cell draws the value').toContain(RAW); + writeText.mockClear(); + fireEvent.click(before); + expect(payloads(), 'CONTROL: unregistered, the row copies its value').toEqual([RAW]); + cleanup(); + + registerFieldRenderer(NEW_MASKED_TYPE, getCellRenderer('password')); + + renderRows(NEW_MASKED_TYPE); + const masked = desktopRow('Token'); + expect(masked.textContent, 'the registered type now draws the mask').toContain(MASK); + expect(document.body.textContent, 'the raw value is nowhere on screen').not.toContain(RAW); + + // CONTROL — the ordinary row still copies, in this same mounted tree. + writeText.mockClear(); + fireEvent.click(desktopRow('Name')); + expect(payloads(), 'CONTROL: the ordinary row copies its value').toEqual([CONTROL_VALUE]); + + // The pin. + writeText.mockClear(); + fireEveryCopyPath(masked); + expect(payloads(), 'no copy path on the masked row writes anything').toEqual([]); + expect(masked.querySelector('button'), 'and it offers no copy button').toBeNull(); + expect(masked.getAttribute('role'), 'and it is not advertised as a button').toBeNull(); + }); + + it("a host that replaces a shipped mask with one of the package's text renderers gets its copy back", () => { + // The runtime-override half of the ruling. The cell now shows the value, + // so withholding the copy would refuse what is already on screen. + const mask = getCellRenderer('password'); + // CONTROL LEG — the shipped mask refuses the copy. + renderRows('password'); + writeText.mockClear(); + fireEveryCopyPath(desktopRow('Token')); + expect(payloads(), 'CONTROL: the shipped `password` mask refuses the copy').toEqual([]); + cleanup(); + + try { + registerFieldRenderer('password', TextCellRenderer); + renderRows('password'); + const row = desktopRow('Token'); + expect(row.textContent, 'the override draws the value').toContain(RAW); + writeText.mockClear(); + fireEvent.click(row); + expect(payloads(), 'and the row copies what it shows').toEqual([RAW]); + } finally { + registerFieldRenderer('password', mask); + } + }); +}); diff --git a/packages/plugin-detail/src/fieldEnrichment.ts b/packages/plugin-detail/src/fieldEnrichment.ts index 7b7739fc48..7fd637ea4e 100644 --- a/packages/plugin-detail/src/fieldEnrichment.ts +++ b/packages/plugin-detail/src/fieldEnrichment.ts @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { isInlineExcludedFieldType } from '@object-ui/fields'; +import { isInlineExcludedFieldType, isMaskedFieldType } from '@object-ui/fields'; /** * Field types the PLATFORM computes — the value is machine-owned and no user @@ -144,49 +144,21 @@ function isExcludedForDetail(fieldType: unknown): boolean { return isInlineExcludedFieldType(fieldType); } -/** - * Field types whose CELL the fields package renders as a mask (`••••••`) - * instead of as the value — the read-side counterpart of the credential entry - * in `INLINE_EXCLUDED_FIELD_TYPES`. - * - * ## ⚠️ A MIRROR, NOT AN AUTHORITY — and that is a declared cost, not an oversight - * - * The mask itself is two anonymous renderers registered inside - * `getCellRenderer`'s standard map in `@object-ui/fields` - * (`password: () => ••••••`, and the same for `secret`). The fields - * package exports NO way to ask "is type T masked?" — searched for on this card - * and not found — so honouring the mask on this surface at all requires naming - * the types once more, here. Two consequences are accepted deliberately: - * - * - a THIRD masked type registered in `@object-ui/fields` tomorrow would render - * masked and stay copy-interactive here until someone edits this line; - * - `registerFieldRenderer('password', …)` can replace the mask at RUNTIME, and - * no static set can see that either. - * - * Both are properties of "the mask has no queryable authority", filed as its own - * card. ⛔ Do not grow this set into that authority: the fix is a predicate - * exported by the package that OWNS the registrations, and every consumer - * (this one included) reading it — exactly the shape - * {@link isInlineExcludedDetailFieldType} already has for the write direction. - * - * ## Why RAW spellings, with no alias resolution — measured - * - * Unlike the inline-edit tables, the cell path does NOT resolve aliases: - * `resolveCellRendererType` only promotes a textual base type through a - * `format` hint (never onto a credential type), and `getCellRenderer` is an - * exact-key lookup into the registry and then into its standard map, falling - * back to `TextCellRenderer`. So EXACTLY these two spellings draw the mask, and - * every other spelling — including the form-alias target `field:password` — - * renders in the clear. Matching raw spellings is therefore the faithful - * mirror, and an alias-aware widening here would withdraw the affordance from - * rows that show their value. - */ -const MASKED_CELL_FIELD_TYPES = new Set(['password', 'secret']); - /** * Is this row's cell drawn as a mask, given the type authored on the view entry * and the type declared on the object schema? * + * The rule itself is NOT restated here — it is `isMaskedFieldType()` from + * `@object-ui/fields`, the package that owns the mask registrations + * (objectui#8686). This file used to keep its own two-member copy of the + * masked types (objectui#8440), documented as a mirror with two accepted + * costs: a masked type added to the fields package would render masked and + * stay copy-interactive here, and `registerFieldRenderer('password', …)` + * could replace the mask at runtime without this copy seeing it. Reading the + * authority closes both: a type the fields package masks, declared or + * registered, refuses the copy here with no edit to this file. The same shape + * {@link isInlineExcludedDetailFieldType} already has for the write direction. + * * **Narrow-only, like {@link isComputedFieldType} and * {@link isInlineExcludedDetailFieldType}** — the answer is the UNION of the * two, so an authored display `type` can withdraw the copy affordance but never @@ -201,10 +173,11 @@ export function isMaskedDetailFieldType( viewFieldType: unknown, objectFieldType: unknown, ): boolean { - return ( - MASKED_CELL_FIELD_TYPES.has(viewFieldType as string) || - MASKED_CELL_FIELD_TYPES.has(objectFieldType as string) - ); + return isMaskedForDetail(viewFieldType) || isMaskedForDetail(objectFieldType); +} + +function isMaskedForDetail(fieldType: unknown): boolean { + return typeof fieldType === 'string' && isMaskedFieldType(fieldType); } /** From de6db2c31a2dfb39d307f85f9a878206b29ea42b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:38:30 +0000 Subject: [PATCH 2/3] docs(fields): document `isMaskedFieldType()` and `MASKED_FIELD_TYPES` in the README (objectui#8686) One paragraph beside the `listCellRendererTypes()` section: what the predicate answers, the live-registry reading (a package-renderer override unmasks, an opaque host override keeps the declared answer), and the host idiom for adding a masked type. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- packages/fields/README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/fields/README.md b/packages/fields/README.md index 2fa1a5778b..80c7892208 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -125,6 +125,32 @@ every type draws — reconcile their tables against this reading so a newly registered type fails them by name instead of slipping past a frozen population (objectui#8734). +### Asking whether a cell is masked + +`isMaskedFieldType(type)` answers whether a cell of that field type is drawn +as a mask (`••••••`) instead of its value. It is the read-side twin of +`isInlineExcludedFieldType()`, and a consumer that acts on a cell's value asks +it rather than keeping its own list. The detail page's copy affordance in +`@object-ui/plugin-detail` is one such consumer: it refuses to copy a masked +row (objectui#8686). `MASKED_FIELD_TYPES` is the declared set behind it, +`password` and `secret`. The standard cell of each member draws the mask, and +the predicate answers `true` for it. Like `listCellRendererTypes()`, the +answer is a live reading of the cell registry taken when you call it, and it +agrees with what `getCellRenderer` resolves for every type that function +lists. To add a masked type, register the package's own mask under it with +`registerFieldRenderer('api_token', getCellRenderer('password'))`, while +`password` still resolves to the shipped mask. The predicate then answers +`true` with no change to the declared set, and the detail page refuses to copy +that row. Overriding a declared type with one of this package's own renderers +unmasks it: after `registerFieldRenderer('password', TextCellRenderer)` the +cell shows the value, the predicate answers `false`, and the detail row copies +again. Overriding a declared type with a component of your own keeps the +declared answer, `true`, because the package cannot tell whether an opaque +component hides the value, so it errs toward withholding it. Your own +component under an undeclared type answers `false`. Spellings are matched raw, +the way `getCellRenderer` looks them up: `field:password` renders in the clear +and is not masked. + ### File uploads in line-item grids `GridField` (the master-detail line-items grid) supports `type: 'file'` columns: From 23030c656862be4faf9ed61e2e43f3f70daf3efa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 10:47:21 +0000 Subject: [PATCH 3/3] docs(fields): state only what the masked-type pins measure (objectui#8686) Two clauses over-stated, per the contract review of objectui#10568: - "none of them masks, so the cell now shows the value" held only for `TextCellRenderer`. Several package renderers draw a literal or a dash instead of the value. The docblock, README and changeset now say the predicate answers `false` because none of them is the mask, and that the cell draws what that renderer draws (for `TextCellRenderer`, the value). - The README's agreement with `getCellRenderer` now carries the qualifier "with nothing overridden at runtime". An opaque host override of a declared type is the one case where the two differ, as the paragraph itself says. Prose only: no code, test or bump-level change. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .../8686-masked-field-type-authority.md | 5 ++-- packages/fields/README.md | 24 ++++++++++--------- packages/fields/src/index.tsx | 5 ++-- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.changeset/8686-masked-field-type-authority.md b/.changeset/8686-masked-field-type-authority.md index cd61444ff1..24b644edfa 100644 --- a/.changeset/8686-masked-field-type-authority.md +++ b/.changeset/8686-masked-field-type-authority.md @@ -19,8 +19,9 @@ would render masked and stay one click from the clipboard there. set and the drawn mask are one fact. - `isMaskedFieldType()` reads the LIVE cell registry. A type registered with the mask (`registerFieldRenderer('api_token', getCellRenderer('password'))`) answers `true`. A - shipped mask replaced at runtime with one of this package's own renderers (for example - `TextCellRenderer`) answers `false`, since the cell now shows the value. A shipped mask + shipped mask replaced at runtime with one of this package's own renderers answers + `false`, since none of them is the mask; the cell then draws what that renderer draws + (for `TextCellRenderer`, the value). A shipped mask replaced with a host component the package cannot inspect keeps the declared answer (`true`), on the side that withholds the value. - It matches raw spellings only, as `getCellRenderer` does: `field:password` renders in diff --git a/packages/fields/README.md b/packages/fields/README.md index 80c7892208..6650ce9492 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -135,21 +135,23 @@ it rather than keeping its own list. The detail page's copy affordance in row (objectui#8686). `MASKED_FIELD_TYPES` is the declared set behind it, `password` and `secret`. The standard cell of each member draws the mask, and the predicate answers `true` for it. Like `listCellRendererTypes()`, the -answer is a live reading of the cell registry taken when you call it, and it -agrees with what `getCellRenderer` resolves for every type that function -lists. To add a masked type, register the package's own mask under it with +answer is a live reading of the cell registry taken when you call it, and, +with nothing overridden at runtime, it agrees with what `getCellRenderer` +resolves for every type that function lists. To add a masked type, register +the package's own mask under it with `registerFieldRenderer('api_token', getCellRenderer('password'))`, while `password` still resolves to the shipped mask. The predicate then answers `true` with no change to the declared set, and the detail page refuses to copy that row. Overriding a declared type with one of this package's own renderers -unmasks it: after `registerFieldRenderer('password', TextCellRenderer)` the -cell shows the value, the predicate answers `false`, and the detail row copies -again. Overriding a declared type with a component of your own keeps the -declared answer, `true`, because the package cannot tell whether an opaque -component hides the value, so it errs toward withholding it. Your own -component under an undeclared type answers `false`. Spellings are matched raw, -the way `getCellRenderer` looks them up: `field:password` renders in the clear -and is not masked. +makes the predicate answer `false`, since none of them is the mask, and the +cell draws what that renderer draws: after +`registerFieldRenderer('password', TextCellRenderer)` the cell shows the value +and the detail row copies again. Overriding a declared type with a component +of your own keeps the declared answer, `true`, because the package cannot +tell whether an opaque component hides the value, so it errs toward +withholding it. Your own component under an undeclared type answers `false`. +Spellings are matched raw, the way `getCellRenderer` looks them up: +`field:password` renders in the clear and is not masked. ### File uploads in line-item grids diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 5e03d8097d..204afc909b 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -3317,8 +3317,9 @@ export const MASKED_FIELD_TYPES: ReadonlySet = new Set(['passwor * 2. Otherwise, a type outside {@link MASKED_FIELD_TYPES} → `false`. * 3. A declared type whose mask a host REPLACED at runtime * (`registerFieldRenderer('password', X)`) → the override is read: - * - X is one of this package's own cell renderers (e.g. `TextCellRenderer`) - * → `false`. None of them masks, so the cell now shows the value. + * - X is one of this package's own cell renderers → `false`. None of them + * is the mask, so the predicate answers `false` and the cell draws what + * X draws (for `TextCellRenderer`, the value). * - X is the host's own component → `true`, the declared answer. Nothing * here can tell whether an opaque component masks. Answering `false` * would offer a credential to anything that trusts this predicate