Repository navigation
fix(console): in-place edit and list inline edit honour the caller's field write grant, and the owner field the transfer grant (objectui#12103) - #12117
Conversation
…ld write grant, and the owner field asks the transfer grant (objectui#12103) The record page's in-place edit (details body and highlights strip) and a list's in-cell edit now ask each field the `write` question of their row in the affordance-to-grant map (`recordEdit`, `listInlineEdit` gain `field: 'write'`), the same question an edit form asks through `fieldWriteGate`. A field the caller's permission set marks `editable: false` stays a read display / read cell. `MePermissionsProvider.checkField(o, owner, 'write')` now also needs the transfer grant (`objectPermissionGrants(objPerm, 'allowTransfer')` from `@objectstack/spec/security`), because the server refuses an update that writes the owner field (`SystemFieldName.OWNER_ID`) without it. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude <noreply@anthropic.com>
…, and the owner field's transfer grant (objectui#12103) - The map's enumeration pin holds `recordEdit` and `listInlineEdit` as field rows, pins that they answer every field as an edit form's `fieldWriteGate` does, and pins the owner field's transfer grant on every update-question row (absent / false / allowTransfer / modifyAllRecords, the explicit field refusal, the untouched insert question, fail-open). - The write census names the details body and the highlights strip as readers of the `recordEdit` row behind the in-place edit's save. - Surface pins through the real MePermissionsProvider and the measured envelope: the details body and the highlights strip, and ObjectGrid's in-cell edit, each with an editable-field control. - The changeset. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude <noreply@anthropic.com>
…it-mode pin (objectui#12103) Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude <noreply@anthropic.com>
…dName constant spells it (objectui#12103) check:spec-symbols reads a dotted citation of SystemFieldName beside a spec mention as a key the spec does not declare, because the spec exports SystemFieldName as both a const and a value-union type and the gate counts only the type's members. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude <noreply@anthropic.com>
…onfig (objectui#12103) Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude <noreply@anthropic.com>
✅ 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
|
Contract reviewServed-tier: Inputs read: card objectui#12103 (body and all three comments: the unlock scan, the Claim, the ① Derived judgmentsPublic surface, each change named and judged:
Measure-first (the card's stop rule): the PR body records the live probe on objectstack ② Semver levelChangeset
③ Boundary flagsOpen question (the dev's one): keep the owner transfer rule in Deviations, each answered:
Out-of-scope findings, each answered or escalated:
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #12103
Clause-②: yes
Refs: #12082 (the affordance-to-grant family). #12082 is not addressed here: its object-level census backlog is untouched, including the record overlay's
OVERLAY_INLINE_EDITentries.Measured first: the server's per-caller answer agrees with enforcement
The card's stop rule did not fire. Measured on a live backend before any code: objectstack
mainatbf515e72,examples/app-showcase,objectstack dev --seed-admin --freshon a private port, run from a separate read-only objectstack worktree. The test user holds thecontributorposition, so their sets areshowcase_contributor,showcase_member_defaultandmember_default. Their field entry is{ readable: true, editable: false }, and the object carries no transfer grant.GET /api/v1/auth/me/permissionsobjects.showcase_projectallowEdit: true,allowTransfer: false,modifyAllRecords: falsefields["showcase_project.budget"]{"readable": true, "editable": false}PATCH /api/v1/data/showcase_project/IDwith{"budget":123}[Security] Field write denied: not permitted to edit [budget] on 'showcase_project'PATCHwith{"owner_id": "REP_ID"}'owner_id' on 'showcase_project' is system-managed — changing record ownership on update requires the transfer grant (allowTransfer or modifyAllRecords)PATCHwith{"name": "…"}PATCHwith{"owner_id": "REP_ID"}allowTransfer: true)So
/me/permissionsreportseditable: falseandallowTransfer: false, and enforcement refuses both writes. The client can follow an answer the server already gives, and no half of this belongs to objectstack.What changed
The family's direction is one map from each affordance to the grant it reads. This PR therefore adds no per-component permission logic. Each surface asks its own row's field question through
resolveFieldAffordance.@object-ui/core,AFFORDANCE_GRANTS.recordEditandlistInlineEditgainfield: 'write'. That is the same update questioneditFormFieldsasks, so the record page's in-place edit and a list's in-cell edit ask each field exactly what an edit form asks throughfieldWriteGate. The testunder a … grant each answers every field exactly as an edit form's gate doespins that it is the same predicate.@object-ui/plugin-detail,DetailSection(the details body) andHeaderHighlight(the highlights strip). Each field'sfieldEditablenow ANDs inresolveFieldAffordance('recordEdit', perms, objectName, field). A refused field shows no pencil, does not enter the session on double-click, and stays a read display in edit mode. The object-level gate (onEnterInlineEdit/inline.canEdit) is unchanged. With noobjectName, or no provider mounted, nothing is asked.@object-ui/plugin-grid,ObjectGrid. The column enrichment pass that already marks read-only, computed or binary columnseditable: falsealso marks a column whose field the caller may not write, usingresolveFieldAffordance('listInlineEdit', …). The cell keeps its read display while the rest of the row edits.inlineEditable, the object-levellistInlineEditverdict, still decides whether the grid edits at all. So the5330afddefault (an absentuserActions.editInlinereads on, decided inListView'sinlineEditOffered) composes unchanged: it decides whether the toggle is offered, and this narrows single columns inside an editing grid.@object-ui/permissions, the owner field. The server's guard keys the owner field by name:owner_id, which the spec spells as theOWNER_IDkey ofSystemFieldName. No field type and no object-level owner declaration is read there;ownershiponly decides injection. So the field is identified by the spec's constant, and the console carries no literal.MePermissionsProvider.checkField(object, owner, 'write')now also requires the transfer grant, read with the spec's own predicateobjectPermissionGrants(objPerm, 'allowTransfer'), which isallowTransferormodifyAllRecords. Thewritequestion is documented as "the server's update rule", and the owner's update rule includes the transfer grant. Fixing it in the resolver therefore covers everywriteasker (edit forms, in-place edit, in-cell edit), and no affordance row has to remember it.createis not narrowed, because on insert the server stamps an empty owner to the caller and accepts the caller's own id.MePermissionsResponsedeclares theallowTransfer?bit the endpoint already serves.@object-ui/permissionsgains a direct@objectstack/spec ^17.7.0dependency for@objectstack/spec/securityand@objectstack/spec/system, which adds +3 lockfile lines.@object-ui/coreand@object-ui/permissionsREADMEs. Changeset:.changeset/12103-field-write-grants.md(core and permissionsminor, plugin-detail and plugin-gridpatch).affordanceGrantMap-12082.test.tsx).EXPECTED_FIELD_GRANTgainsrecordEditandlistInlineEdit. Two new blocks cover the edit-form-equivalence pins and the owner/transfer matrix: absent andfalserefuse;allowTransferandmodifyAllRecordsopen; CONTROL: the grant moves no other field; an expliciteditable: falseon the owner still refuses; the insert question is not narrowed; fail-open with no provider. TheInlineEditSaveBar … updateVia :: updatewrite-site entry now also namesDetailSection.tsxandHeaderHighlight.tsxas readers ofrecordEdit. No write call site was added or moved. The three Setup entries from feat(app-shell,fields,console): Setup's positions and permission sets read the registry, through the metadata-admin pages' environment scope (part of objectui#7611) #12089 are untouched, and none of that PR's files is touched.File surface: three additions beyond the claim, with the reason
packages/plugin-detail/src/HeaderHighlight.tsx: the highlights strip is the same in-place edit session as the details body, with one draft and one Save. Leaving it ungated would leave the card's own defect reachable on the same page. All four bounded-fix conditions hold: the same defect class, the same one-call shape asDetailSection, no other open PR touches the file (read on the 4 open PRs), and the same gate family.packages/permissions/package.jsonandpnpm-lock.yaml: the spec imports above.packages/core/README.mdandpackages/permissions/README.md: AGENTS.md commandment 2 (docs-driven).Clause-② inventory (for the seat's contract review)
AFFORDANCE_GRANTS.recordEditandAFFORDANCE_GRANTS.listInlineEditgainfield: 'write'. No row is added or removed.FieldAffordance(derived type) widens by'recordEdit' | 'listInlineEdit'.MePermissionsResponse['objects'][string]gainsallowTransfer?: boolean.checkField's action union is unchanged ('read' | 'write' | 'create'). Itswriteanswer narrows for the owner field only.@object-ui/permissionsgains a runtime dependency on@objectstack/spec.Evidence (head
47a8eee)type-check, exit 0:@object-ui/core,@object-ui/permissions,@object-ui/plugin-detail,@object-ui/plugin-gridand@object-ui/plugin-form. Each package'stsconfig.test.json--listFilesincludes its new test file.MePermissionsProviderand the measured envelope:affordanceGrantMap-12082.test.tsx,MePermissionsProvider.ownerTransfer-12103.test.tsx,DetailSection.fieldWriteGrant-12103.test.tsx(details body and highlights strip) andinlineEditFieldWriteGrant-12103.test.tsx(grid). Result:Test Files 4 passed (4)/Tests 66 passed (66).--): 35 files and 381 tests passed (grid inline-edit, permission, FLS and column suites, plugin-form map and field-gate pins, the wholepackages/permissions/), then 31 files and 240 tests passed (ListView permission, inline and FLS suites, app-shellObjectView.objectBoundActions-7234andRecordDetailView.expandFls-7230, everyDetailSection.*,HeaderHighlight.*andRecordHighlightsRenderer.*,DetailView.permissions,InlineEditSaveBar). The whole ofpackages/permissions/pluspackages/plugin-detail/also ran atbccbfc5:Test Files 262 passed | 1 skipped (263). The only later changes are a comment rewording and the test-file typing fix.packages/plugin-grid/suite. Reason: one run exceeded the foreground cap (killed at 590 s, no result). It is declared to CI. The grid files above are the narrowed set.ablation-replace.mjs. Every leg reportedok mutation landedandok restored: blob == HEAD … git diff HEAD is empty. Vitest aliases these packages tosrc, so no rebuild was needed.DetailSectiongate replaced bytrue: 3 red (refused field, owner, edit mode); the strip pins and the controls stayed green.HeaderHighlightgate replaced bytrue: 3 red (refused highlight, owner, edit-mode display); the CONTROL stayed green.ObjectGridfield question removed: 2 red (refused field, owner); the controls stayed green.check:metadata-write-doors,check:handler-key-reads,check:new-line-citations(VERDICT … 0 new citation(s)),check:control-bytes,check:changeset-claims,check:pending-changeset-literals,check:test-path-roots,check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:esm-specifiers,check:self-import,check:phantom-deps,check:unused-deps,check:spec-symbols,check:installed-pin-claims,check:lockfile-integrity,check:lockfile-dedupe,check:pre-install-import-graph,check:side-effects-array,check:unreferenced-sources,check:doc-fences,check:doc-types,check-changeset-presence.mjsandcheck-changeset-no-major.mjs.check:readme-exports(its own line: "the population COLLAPSED -- this run proves nothing"),check:doc-snippets([unbuilt-package]) andcheck:spec-floors. Incheck:spec-floors, all 14 findings are[no-artifact]for unbuilt packages. The built@object-ui/coreand@object-ui/permissionsraised no finding, and the gate read the17.7.0./securityand./systementries. The README edits add no fenced block.vite buildat base5330afdand at2ff9d43; the last commit changes a test file only): base 3,246,298 bytes, head 3,246,484 bytes gzipped, +186 bytes. The chunk count stays at 290, andvendor-objectstackstays at 1561.8 KB. The spec modules were already in that chunk. Gate:Console eager closure is 3170.4 KB gzipped … headroom: 34.2 KB, exit 0..ts/.tsxfiles. ① The population comes from eslint's own resolution:--print-configresolves a config for every file, and none is ignored. ②--format jsonreports 10 files. ③ Invariance:eslint.config.jsenables no type-aware linting (noprojectServiceand noparserOptions.project), so the diff cannot move a verdict on an untouched file. With inline config (how the packagelintscripts run): 0 errors. With--no-inline-config: 1 error,react-hooks/static-componentsinObjectGrid.tsx, which is identical at base (the same rule, with an inline disable). Warning counts per file are identical to base.Acceptance notes
check:spec-symbolsrefused a citation the spec does declare. A dotted citation ofOWNER_IDonSystemFieldNamenext to a spec mention reads as "a key the spec does not declare". The spec exportsSystemFieldNameas a const and as a value-union type; the gate'smemberSetOfSymbolfillsauthoredfrom the declared type's members only (here the string members), so the const's keys never count. The comment was reworded instead. Carrier: none (承接者:无). This is a read of the gate's code, not a filed defect.owner_idfrom every payload by name (SERVER_OWNED_FIELD_NAMESinplugin-form'ssanitize.ts). A transfer-holder whose form layout draws the owner field therefore gets an enabled input whose change Save drops. This was not changed and not measured here (a read-only inference). The in-place and in-cell paths do write it, and the server accepts it from a transfer-holder (the admin control above). Carrier: none.PermissionProviderhas no transfer concept, so itscheckFieldis unchanged. The console mountsMePermissionsProvider.Generated by Claude Code