Skip to content

fix(plugin-detail): a masked row offers no copy affordance - #8685

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-8440-masked-field-copy-refusal
Sep 9, 2026
Merged

fix(plugin-detail): a masked row offers no copy affordance#8685
os-justin merged 1 commit into
mainfrom
claude/issue-8440-masked-field-copy-refusal

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8440

@object-ui/fields renders password and secret cells as •••••• — the cell
deliberately refuses to show the value. DetailSection offered the copy affordance on
that 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 at
fields/src/index.tsx:2380-2381. On 2609812d2 the handler is the post-#8395 shape
(objects are serialized as JSON, scalars keep String(value)) and the mask registrations
sit at 2465-2466. The premise held in every load-bearing respect: canCopy is still
hasCellValue(value), canInlineEditField is still false for both types via
isInlineExcludedDetailFieldType, so copyInteractive was 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. DetailSection also renders a
mobile grouped-inset row whose click and Enter/Space are gated on canCopy alone —
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 canCopy too, not on
copyInteractive. All five are withdrawn here.

The change

One derived gate, five consumers:

canCopy       = hasCellValue(value)              // unchanged — the emptiness reader
copyOffered   = canCopy && !isMaskedField        // new
copyInteractive = copyOffered && !canInlineEditField

isMaskedField comes from a new isMaskedDetailFieldType(viewType, objectType) beside the
gates it belongs with in fieldEnrichment.ts. Like isComputedFieldType and
isInlineExcludedDetailFieldType it answers the narrow-only union of the two types, so
a 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
text over an object-schema secret renders the value in the clear and still refuses
the copy.

⛔ The refusal is deliberately not spelled into canCopy. That name is one of the
readers of the shared emptiness authority hasCellValue (objectui#8376/#8394) that must
agree on which rows are EMPTY, and a masked row is not an empty one.

⚠️ The second-authority question — reported, not settled

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
(password maps to a renderer drawing a SPAN of six bullets, and secret to another), and
nothing 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 two
declared costs — a third masked type registered in fields would not reach it, and
registerFieldRenderer('password', …) can replace the mask at runtime, which no static set
can see. Filed separately as "the mask has no queryable authority".

Measured while reading: the cell path does not resolve aliases (resolveCellRendererType
only promotes a textual base type through a format hint; getCellRenderer is an exact-key
lookup falling back to TextCellRenderer), so exactly those two raw spellings draw the mask
and 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-object vs the HEAD blob, line-total
gate), restoring by state (git diff HEAD empty), classified per test from vitest's JSON
reporter. No death string (Element type is invalid, Failed Suites, No test suite found in file, Tests no tests, Transform failed) appeared in any leg.

leg mutation result
A copyOffered = canCopy (the defect restored) 18 red / 4 pass — every masked + union case; the ordinary-row and emptiness pins stay green
B copyOffered = false && … (caricature: nothing is ever copy-interactive) 21 red / 1 pass — every control reddens loudly
C the desktop hover button alone reverted to canCopy 4 red — exactly the button pins
D the mobile block alone reverted to canCopy 6 red — exactly the mobile pins; a desktop-only fix is caught
E masked rows made to count as EMPTY (the wrong-fix family) 17 red, including the emptiness non-regression pin

Non-regression, derived from the wrong fix rather than from the shape of the bug:

  • emptiness classification is unchanged — a populated masked row is still FILLED, the
    toggle still counts exactly the absent rows, and the masked row never draws the No value
    placeholder (leg E is the proof this pin discriminates);
  • ordinary rows copy byte-for-byte what they copy today, object-valued cells included.
    objectui#8395's own pins are untouched and green.
pnpm exec vitest run packages/plugin-detail/                 → 149 files, 1346 tests passed
pnpm exec vitest run packages/app-shell/src/views/           → 405 files, 3893 passed, 1 skipped
pnpm --filter @object-ui/plugin-detail type-check            → exit 0 (closure built first;
                                                                the new test file is in the
                                                                program, proved by --listFiles)
pnpm --filter @object-ui/plugin-detail lint                  → 202 files, 0 errors
node scripts/check-changeset-presence.mjs                    → OK
pnpm run check:control-bytes                                 → OK (6870 files)
pnpm run check:vi-mock-specifiers / :vi-mock-inherit         → OK
pnpm run check:unreferenced-sources                          → OK
node scripts/check-governed-queue-guard.mjs --test PATHS     → NOT GOVERNED

Declared narrowing. Lint was run for the affected package only (eslint . inside
packages/plugin-detail, the same command turbo run lint runs for it), not repo-wide: the
diff touches files in that one package, and the flat config enables no type-aware linting
(tseslint.configs.recommended, no parserOptions.project), so it cannot move the verdict on
a 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 test
elsewhere in the affected set asserts DetailSection copy 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

`@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
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3477.6 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-D8YUTiuv.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 499.42KB 114.32KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 243.70KB 61.52KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 251.39KB 65.04KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.18KB 56.62KB
plugin-kanban (index.js) 55.44KB 15.73KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@os-justin
os-justin marked this pull request as ready for review September 8, 2026 22:20
@os-justin
os-justin enabled auto-merge September 8, 2026 22:21
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit e6ec217 Sep 9, 2026
35 checks passed
@os-justin
os-justin deleted the claude/issue-8440-masked-field-copy-refusal branch September 9, 2026 00:41
Sign up for free to 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.

DetailSection's click-to-copy writes a password / secret field's RAW value, while the cell beside it renders the mask

2 participants