fix(plugin-detail): a masked row offers no copy affordance - #8685
Merged
Conversation
`@object-ui/fields` renders `password` and `secret` cells as `••••••`, and `DetailSection` offered click-to-copy on that same row anyway, handing the RAW credential to `navigator.clipboard.writeText` — silently, with no error and no visible sign. A masked cell that copies in the clear is worse than an unmasked one, because the reader believes the value is protected. Rows whose field type is masked are no longer copy-interactive at any of the five sites that reach the handler: the desktop row click, Enter/Space on it, the hover copy button, and the mobile grouped-inset row's own click and Enter/Space. Copying the mask itself was considered and refused. The refusal is a separate gate, deliberately NOT spelled into `canCopy`: that name is one of the readers of the shared emptiness authority (`hasCellValue`), and a masked row is not an empty one. Like the editability gates beside it, the new gate answers the narrow-only UNION of the authored view type and the object schema type, so a presentation override can withdraw the affordance but never restore it on a credential column. The masked set is mirrored, not queried: `@object-ui/fields` exports no way to ask whether a type is masked. That is recorded at the mirror and reported as a separate finding rather than settled here. Refs objectui#8440 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
os-justin
marked this pull request as ready for review
September 8, 2026 22:20
os-justin
enabled auto-merge
September 8, 2026 22:21
This was referenced Sep 9, 2026
Merged
This was referenced Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #8440
@object-ui/fieldsrenderspasswordandsecretcells as••••••— the celldeliberately refuses to show the value.
DetailSectionoffered the copy affordance onthat same row anyway and handed the raw credential to
navigator.clipboard.writeText:silently, with no error and no visible sign. A masked cell that copies in the clear is
worse than an unmasked one, because the reader believes the value is protected.
Maintainer ruling, 2026-09-08, option A — no copy affordance on masked field types.
Copying the bullets was considered and refused as a second silent wrong answer, so
nothing here writes a mask to the clipboard.
What the base actually looked like (the card's reading was stale)
The card quotes
String(value)at line 220 and the mask registrations atfields/src/index.tsx:2380-2381. On2609812d2the handler is the post-#8395 shape(objects are serialized as JSON, scalars keep
String(value)) and the mask registrationssit at 2465-2466. The premise held in every load-bearing respect:
canCopyis stillhasCellValue(value),canInlineEditFieldis still false for both types viaisInlineExcludedDetailFieldType, socopyInteractivewas still true on a credential row.Measured correction: there are five reach paths, not three. The card named the desktop
row click, Enter/Space on it, and the hover copy button.
DetailSectionalso renders amobile grouped-inset row whose click and Enter/Space are gated on
canCopyalone —two more sites reaching the same handler, on the target where a masked row is most likely
to be tapped. And the desktop hover button was gated on
canCopytoo, not oncopyInteractive. All five are withdrawn here.The change
One derived gate, five consumers:
isMaskedFieldcomes from a newisMaskedDetailFieldType(viewType, objectType)beside thegates it belongs with in
fieldEnrichment.ts. LikeisComputedFieldTypeandisInlineExcludedDetailFieldTypeit answers the narrow-only union of the two types, soa presentation override can withdraw the affordance but never restore it on a credential
column (objectui#3355's direction). The declared cost of the union is pinned: an authored
textover an object-schemasecretrenders the value in the clear and still refusesthe copy.
⛔ The refusal is deliberately not spelled into
canCopy. That name is one of thereaders of the shared emptiness authority
hasCellValue(objectui#8376/#8394) that mustagree on which rows are EMPTY, and a masked row is not an empty one.
The dispatch asked which of two things I did. I mirrored; I did not invent an authority.
Searched for an existing "is type T masked?" question in
@object-ui/fields: none exists.The mask is two anonymous renderers inside
getCellRenderer's standard map(
passwordmaps to a renderer drawing a SPAN of six bullets, andsecretto another), andnothing exports the set or a predicate over it. Per the fence I did not mint one in
plugin-detail: the new set is documented as a mirror, not an authority, with its twodeclared costs — a third masked type registered in
fieldswould not reach it, andregisterFieldRenderer('password', …)can replace the mask at runtime, which no static setcan see. Filed separately as "the mask has no queryable authority".
Measured while reading: the cell path does not resolve aliases (
resolveCellRendererTypeonly promotes a textual base type through a
formathint;getCellRendereris an exact-keylookup falling back to
TextCellRenderer), so exactly those two raw spellings draw the maskand matching raw spellings is the faithful mirror.
Verification — by content, at the spy
packages/plugin-detail/src/__tests__/DetailSection.maskedCopyRefusal-8440.test.tsx, 22 cases.The pin is absence-shaped, so every masked case carries its controls in the same mounted
tree: the masked cell is proved to have rendered the mask (and the raw value proved absent
from the document) before the absence is asserted, and the same interaction is fired on an
ordinary text row and required to reach the spy with that row's value. Absence is counted at
writeText.mock.calls, never with a text query — both masked rows draw the identical string.Ablation legs, each mutating a read site from the committed tree, proving the mutation
landed on disk (anchor counts both directions,
git hash-objectvs the HEAD blob, line-totalgate), restoring by state (
git diff HEADempty), classified per test from vitest's JSONreporter. No death string (
Element type is invalid,Failed Suites,No test suite found in file,Tests no tests,Transform failed) appeared in any leg.copyOffered = canCopy(the defect restored)copyOffered = false && …(caricature: nothing is ever copy-interactive)canCopycanCopyNon-regression, derived from the wrong fix rather than from the shape of the bug:
toggle still counts exactly the absent rows, and the masked row never draws the
No valueplaceholder (leg E is the proof this pin discriminates);
objectui#8395's own pins are untouched and green.
Declared narrowing. Lint was run for the affected package only (
eslint .insidepackages/plugin-detail, the same commandturbo run lintruns for it), not repo-wide: thediff touches files in that one package, and the flat config enables no type-aware linting
(
tseslint.configs.recommended, noparserOptions.project), so it cannot move the verdict ona file it does not contain. Repo-wide sweeps are CI's. Downstream was narrowed to
packages/app-shell/src/views/— the detail surface's consumers — after checking that no testelsewhere in the affected set asserts
DetailSectioncopy behaviour.Related, none of them touched here: objectui#4221 (the write direction — its exclusion is what
left this read direction exposed), objectui#8395 (object-valued payloads), objectui#8376 /
objectui#8394 (the emptiness authority).
🤖 Generated with Claude Code
https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Generated by Claude Code