Skip to content

Commit 6866e2e

Browse files
fix(ui): the record form's edit dialog reads the record's field projection (#27137)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBFrHTwT2queiHnTbzxnSn3Y)_ Fixes #26594 Fixes #27147 Ruling-ref: 6457467702 Clause-②: yes (widening) Metadata-protocol: none Skills-docs: updated here — content/docs/permissions/field-level-security.mdx (the record-scoped projection bullet now says the answer is field-level only and a row the caller may read but not write is refused as a whole); skills/objectstack-data/rules/security.md names no reader of the lock, so no skills/** edit (this PR is not governed) ## 维护者速读 - 改了什么:记录表单的编辑对话框(console 的 `ModalForm`、`ObjectForm` 及其全部布局、`DrawerForm`)在编辑已存在的记录时,向服务端询问「这条记录」的字段投影(#27063 的 `GET /data/{object}/{id}/field-access`),只禁用该答案锁定的字段;保存时外发闸读同一个答案。 - 为什么改:裁决 B。带 `when` 的字段锁(批准后锁定)在草稿上服务端允许写,但表单只读无行的 `/auth/me/permissions`,在草稿上也把字段禁用了。 - 风险与回滚:只替换「字段级锁」这一项输入;对象授权兜底、owner 转移、read/create、其它对象都沿用原答案;答案未到或无答案(404/501/拒绝)时保持原锁;客户端从不求值 `when`。回滚即 revert。 - 先看:`ui/packages/permissions/src/RecordFieldAccessScope.tsx`、`ui/packages/plugin-form/src/recordFieldAccess.tsx`、`ui/packages/data-objectstack/src/index.ts` 的 `getRecordFieldAccess`。 - 两分钟验证:`cd ui && npx vitest run packages/plugin-form/src/recordFieldAccess-26594.test.tsx`,预期 10/10(草稿上 summary 可编辑且随保存发送,ref_no 与已批准行上的 summary 保持锁定)。 - 需要你做的:无,席位落地。 ## What changes **The edit dialog asks for this record's projection.** `RecordFieldAccessForForm` (`ui/packages/plugin-form/src/recordFieldAccess.tsx`) wraps the three containers a record form is rendered through directly: `ModalForm` (the console's edit dialog, mounted by `AppContent`), `ObjectForm` (the `/record/:id/edit` page and every layout it dispatches to, master-detail header included) and `DrawerForm`. Only an `edit` form with an object and a record id, on a data source that has `getRecordFieldAccess`, asks. A container nested in another for the same record reuses the outer answer (asked once). **One input replaced, nothing evaluated client-side.** `RecordFieldAccessScope` (`@object-ui/permissions`) re-provides the permission context below the form. For the scoped object, `checkField(object, field, 'write')` answers `editable.includes(field)` only where `/auth/me/permissions` reports an explicit field-level lock (the reader that holds no row, which serves a `when`-conditioned entry as its lock). Every other input keeps the enclosing answer: a field with no lock entry (the object-grant fallback), the owner field (a transfer needs a grant the projection does not carry), `read` and `create`, and every other object. Until the answer arrives, and when there is none, the row-blind lock stands, so a conditioned field shows locked rather than briefly open. `gateFormFields`, `applyFieldPermissions` and the outbound `fieldWriteGate` all ask `usePermissions()`, so what the dialog draws and what its save sends read the same answer. The derived value is cached in a module-level `WeakMap` keyed on the enclosing value and the answer (the `ServerReadScope` shape), never a `useMemo` (AGENTS.md #10). **Adapter.** `DataSource.getRecordFieldAccess?(resource, id)` and the `RecordFieldAccess` type in `@object-ui/types`; `ObjectStackAdapter.getRecordFieldAccess` calls `client.data.fieldAccess`. The record read's 404 and a server without the route (501 `NOT_IMPLEMENTED`) resolve to `null`; any other refusal is thrown. **Measured: `editable` on a row the caller may read but not write.** The projection is field-level only: the object grant and the record's row-level write scope (sharing, RLS, ownership) are not in it, and the response schema already says so (`GetRecordFieldAccessResponseSchema.editable`, #27063). The new dogfood leg pins it on a real stack: a `public_read` row another user owns answers `GET` 200, `field-access` lists `title` and `summary` editable, and `PATCH title` answers 403. **The ui does not gate the dialog on record-level edit permission anywhere.** The form-wide lock (`managedModeLocked` / `gateFormFields` in `ui/packages/plugin-form/src/fieldWriteGate.ts`) reads the managed bucket, `apiOperations` and `can(object, 'update')`, which is the object grant only. On such a row the dialog therefore offers its fields and the server refuses the save as a whole, exactly as before this PR (the projection never widens it: on that row the row-blind lock was the only field input it replaces). This PR says so where a reader looks: the route's schema (already), `RecordFieldAccess.editable` in `@object-ui/types`, the `RecordFieldAccessScope` doc, and the field-level-security doc's projection bullet. Gating the dialog on row write scope would need a record-level `canEdit` from the server; that is filed as #27138. **Other surfaces keep their current reader.** Surfaces that hold one row and still read the row-blind answer: the record detail page's inline edit (`plugin-detail` `DetailSection.tsx:507` and `HeaderHighlight.tsx:211`, through `resolveFieldAffordance('recordEdit', …)`), filed as #27146. Surfaces that hold many rows decide per column, not per row: list/grid inline edit (`plugin-grid` `objectGridPersistedColumns.ts:89`, `resolveFieldAffordance('listInlineEdit', …)`). Surfaces that hold no stored row: the new-record form (create mode asks nothing) and `view` forms (write nothing). Files: - `ui/packages/permissions/src/RecordFieldAccessScope.tsx` (new), exported from `index.ts`. - `ui/packages/plugin-form/src/recordFieldAccess.tsx` (new); `ObjectForm.tsx`, `ModalForm.tsx`, `DrawerForm.tsx`: each exported component wraps its unchanged body. - `ui/packages/types/src/data.ts`, `index.ts`: `RecordFieldAccess`, `DataSource.getRecordFieldAccess?`. - `ui/packages/data-objectstack/src/index.ts`: the adapter method. - `packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts`, `record-field-access-when.dogfood.test.ts`: the `rfa_notice` (`public_read`) object and the row-not-writable measurement. - `content/docs/permissions/field-level-security.mdx`: one sentence on the projection's scope. - Changesets: `@object-ui/types`, `@object-ui/data-objectstack`, `@object-ui/permissions` (patch, `Clause-②: yes (widening)`), `@object-ui/plugin-form` (patch, `Clause-②: no`), all `Metadata-protocol: none`. - Round 2 (#27147, claimed for this branch in comment 6464455969): the projection follows the stored row. `RecordFieldAccessScope` takes a `revision`; when it moves, the scope asks again and holds the row-blind lock until the new answer lands. `RecordFieldAccessForForm` moves it on the data-invalidation bus for the record (the signal the form re-reads on) and on the data source's `onMutation` for the record (the form's own save, with or without the console's bridge). Two new tests: `recordFieldAccess-26594.test.tsx` "a save that moves the row into the state the condition names locks the field again" (both containers, so the file is 12; with the revision wiring ablated these 2 go red) and `RecordFieldAccessScope-26594.test.tsx` "a new revision asks again and holds the lock until the new answer lands" (the file is 8). The `@object-ui/permissions` and `@object-ui/plugin-form` changesets each gain one sentence on it. ## Tests - `ui/packages/plugin-form/src/recordFieldAccess-26594.test.tsx` (12): for the console's edit dialog (`ModalForm` rendered directly) and for `ObjectForm`'s edit page, under `MePermissionsProvider` with the contract persona (`summary` locked from `approved` onward, served row-blind as a lock; `ref_no` locked on every row): - on the draft shape the gate admits, `summary` is live and `ref_no` locked, and the projection is asked once with `('rfa_contract', 'c1')`; - the save sends the edited `summary` and withholds `ref_no`; - on the approved row both stay locked; - a create form asks nothing; a data source without the method keeps the row-blind lock. - `ui/packages/permissions/src/__tests__/RecordFieldAccessScope-26594.test.tsx` (8): the truth table above, case-insensitive object match, owner/read/create/other object unchanged, empty answer, pending / `null` / rejected answer keeps the lock, nested scope asks once, no provider asks nothing. - `ui/packages/data-objectstack/src/getRecordFieldAccess-26594.test.ts` (4): URL and encoding, envelope unwrap, 404 → `null`, 501 → `null`, 403 thrown. - `packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts`: the new measurement leg above (8/8 with #27063's seven). ## Acceptance notes - The dialog test is the ui half of the card's acceptance ("the edit dialog on hotclm's draft shape enables the fields the gate admits"): the draft/approved projections it feeds are the shapes #27063's dogfood file reads off the real route for the same persona. - The dogfood suite is not on the PR's CI path; its run is recorded under Verification. - The row-not-writable gap this PR measures (the dialog never asks whether the row itself is writable, so on a row the PATCH refuses as a whole it still offers the fields and the save answers 403) is tracked as #27138. This PR neither causes nor widens it. ## Verification Head `90f1ea4df`, one commit on `origin/main` `a98daf75f`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 93 derived families, each run and its exit recorded; `--ran` reconciliation: 93 run, 0 NOT-MEASURED, 0 UNRUN. 92 exited 0 on the first run. `pnpm --dir ui exec vitest run scripts/` exited 1: `check-handler-key-read-sites` found the `view:form` / `object-form` handler reads gone (56 → 51), because the new `ObjectForm` wrapper handed its body the document through `{...props}`, and the census follows a document only through a named `schema=` attribute. The three wrappers now pass `schema={schema}` by name; the census reads 56 again with only line numbers moved, and the gate reran exit 0 (5019 passed). Nothing else changed between the two runs. - Under `os-verify-lock`: the ui build of every dependency of `@object-ui/plugin-form` and `@object-ui/data-objectstack` exit 0; type-check of `@object-ui/types`, `@object-ui/permissions`, `@object-ui/data-objectstack`, `@object-ui/plugin-form` exit 0 (plugin-form rerun after the wrapper fix, exit 0); `@object-ui/permissions` test 158 passed, `@object-ui/data-objectstack` test 1209 passed, `@object-ui/plugin-form` test 2423 passed (rerun after the wrapper fix: 204 files, 2423 passed). - The plugin-form write census (`affordanceGrantMap-12082.test.tsx`) keys write sites by enclosing function: the `ModalForm` / `DrawerForm` `handleSubmit` rows moved to `ModalFormBody` / `DrawerFormBody`, same disposition. - Dogfood, under `os-verify-lock`: `pnpm --filter @objectstack/dogfood exec vitest run test/record-field-access-when.dogfood.test.ts` 8/8; dogfood typecheck exit 0. - Ablation: with `RecordFieldAccessForForm` forced to pass through, `recordFieldAccess-26594.test.tsx` goes 6 red / 4 green (in both containers: the draft unlock, the save, and the approved row, which waits for the ask); restored (`cmp` identical), 10/10. ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. `pnpm lint` is CI's run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 block is in Chinese by the repo's dispatch rule.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f1045b commit 6866e2e

20 files changed

Lines changed: 926 additions & 9 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/data-objectstack': patch
3+
---
4+
5+
feat(data-objectstack): `ObjectStackAdapter.getRecordFieldAccess` reads `GET /data/:object/:id/field-access`
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The adapter implements `DataSource.getRecordFieldAccess` through the client's `data.fieldAccess`. A record the read answers 404 for, and a server without the route (501 `NOT_IMPLEMENTED`), resolve to `null`, so the caller keeps its row-blind answer. Any other refusal is thrown, as a read's is.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/permissions': patch
3+
---
4+
5+
feat(permissions): `RecordFieldAccessScope` answers one record's field locks from the server's record-scoped projection
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
Below `RecordFieldAccessScope`, `checkField(object, field, 'write')` for the scoped record answers from the server's projection of that record wherever `/auth/me/permissions` reports a field-level lock. That reader holds no row, so it reports a lock that applies only from a stage onward (a field permission `when`) on every row; the scope replaces that one input with the write gate's verdict for the stored row. The object-grant fallback, the owner-transfer rule, `read` and `create`, and every other object keep the enclosing provider's answer. Until the answer arrives, and when there is none, the row-blind lock stands. When its `revision` prop moves (the row was saved or re-read), it asks again and the lock stands until the new answer lands. The client never evaluates `when`. A nested scope for the same record asks once.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-form': patch
3+
---
4+
5+
fix(plugin-form): the record form's edit dialog disables only the fields the server locks on that record
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
An edit form for a stored record (`ModalForm`, the console's edit dialog; `ObjectForm` and every layout beneath it; `DrawerForm`) now asks the data source for that record's field projection and answers its field locks from it, through `RecordFieldAccessScope`. A field locked only from `approved` onward is live on a draft and locked on an approved row, in what the form draws and in what its save sends. The form asks again after a write to the record or a re-read of it, so a save that approves the row locks the field again. Create forms, `view` forms and data sources without `getRecordFieldAccess` behave as before. Row-level write scope is not part of the answer: on a row the caller may read but not write, the fields stay open and the server refuses the save, as before.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/types': patch
3+
---
4+
5+
feat(types): `DataSource.getRecordFieldAccess` asks the server for one record's field projection
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
`DataSource` gains the optional `getRecordFieldAccess(resource, id)`, resolving to the new `RecordFieldAccess` type (`object`, `recordId`, `readable`, `editable`), or `null` when there is no answer. `editable` is the server's write gate judged on the stored row, so a field permission whose `when` names only some rows locks the field only on those rows. Adapters that do not speak to an ObjectStack server can leave it out.

‎content/docs/permissions/field-level-security.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ fields: {
6464
an update of the stored row. So a lock that applies from a stage onward
6565
disables the field only on the rows its condition names — this is the
6666
answer a record's edit form reads. A row that is absent or that the caller
67-
cannot read answers the record read's `404`.
67+
cannot read answers the record read's `404`. The answer is field-level
68+
only: the object grant and the record's row-level write scope (sharing,
69+
row-level security, ownership) are not part of it, so on a row the caller
70+
may read but not write it still lists fields as editable, and the update
71+
is refused as a whole.
6872
- **Union without deny is unchanged.** A matched conditioned lock is still
6973
out-voted by another set's unconditioned `editable: true`; an entry
7074
without `when` votes on every row, as before (ADR-0066 ⑧, note of

‎packages/qa/dogfood/test/fixtures/record-field-access-fixture.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
// row-blind lock is shown to stay a lock on every row.
1515
// - `rfa_memo` is `private`: a member reads only the rows they own. It is the
1616
// row the caller cannot read, for the existence-leak leg.
17+
// - `rfa_notice` is `public_read`: everyone reads, only the owner writes. A
18+
// row someone else owns is one the member may READ but not WRITE — the
19+
// row-level write scope the record-scoped projection declares it leaves
20+
// out, measured against the PATCH.
1721

1822
import { defineStack } from '@objectstack/spec';
1923
import { ObjectSchema, Field } from '@objectstack/spec/data';
@@ -54,6 +58,17 @@ export const RecordFieldAccessMemo = ObjectSchema.create({
5458
},
5559
});
5660

61+
export const RecordFieldAccessNotice = ObjectSchema.create({
62+
name: 'rfa_notice',
63+
sharingModel: 'public_read',
64+
label: 'RFA Notice',
65+
pluralLabel: 'RFA Notices',
66+
fields: {
67+
title: Field.text({ label: 'Title', required: true }),
68+
summary: Field.text({ label: 'Summary' }),
69+
},
70+
});
71+
5772
export const recordFieldAccessFixtureStack = defineStack({
5873
manifest: {
5974
id: 'com.dogfood.record-field-access-fixture',
@@ -63,7 +78,7 @@ export const recordFieldAccessFixtureStack = defineStack({
6378
name: 'Record Field Access Fixture',
6479
description: 'A field lock that holds only from a stage onward (field permission `when`).',
6580
},
66-
objects: [RecordFieldAccessContract, RecordFieldAccessMemo],
81+
objects: [RecordFieldAccessContract, RecordFieldAccessMemo, RecordFieldAccessNotice],
6782
} as any);
6883

6984
/** The `when` the conditioned entry carries, as authored. */
@@ -81,6 +96,7 @@ export const recordFieldAccessMemberSet: PermissionSet = PermissionSetSchema.par
8196
objects: {
8297
rfa_contract: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
8398
rfa_memo: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
99+
rfa_notice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
84100
},
85101
fields: {
86102
'rfa_contract.summary': { readable: true, editable: false, when: APPROVED_ONLY },

‎packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ describe('a row-conditioned field lock, on the record the form edits', () => {
3131
let draftId: string;
3232
let approvedId: string;
3333
let hiddenMemoId: string;
34+
let othersNoticeId: string;
3435

3536
beforeAll(async () => {
3637
stack = await bootStack(recordFieldAccessFixtureStack, { security: recordFieldAccessFixtureSecurity() });
@@ -43,6 +44,8 @@ describe('a row-conditioned field lock, on the record the form edits', () => {
4344
approvedId = (await ql.insert('rfa_contract', { title: 'A', status: 'approved' }, { context: SYS })).id;
4445
// A private row the member does not own, so cannot read.
4546
hiddenMemoId = (await ql.insert('rfa_memo', { title: 'approved' }, { context: SYS })).id;
47+
// A public-read row someone else owns: readable, not writable, for the member.
48+
othersNoticeId = (await ql.insert('rfa_notice', { title: 'N' }, { context: SYS })).id;
4649
}, 180_000);
4750

4851
afterAll(async () => {
@@ -118,6 +121,23 @@ describe('a row-conditioned field lock, on the record the form edits', () => {
118121
}
119122
}, 60_000);
120123

124+
it('measure — a row the caller may read but not write: the projection is field-level only, the PATCH refuses the row', async () => {
125+
// The row-level write scope (sharing, row-level security, ownership) is
126+
// declared outside `editable`: the projection lists the fields
127+
// field-level security admits, and the write is refused for the row as a
128+
// whole. A reader ANDs this answer with the record's own write scope; the
129+
// console's edit dialog has never gated on that scope, so the server's
130+
// 403 on save is its answer there, before this card and after it.
131+
const read = await stack.apiAs(memberTok, 'GET', `/data/rfa_notice/${othersNoticeId}`);
132+
expect(read.status, await read.clone().text()).toBe(200);
133+
const { status, body } = await fieldAccess('rfa_notice', othersNoticeId);
134+
expect(status).toBe(200);
135+
const access = GetRecordFieldAccessResponseSchema.parse(body.data);
136+
expect(access.editable).toEqual(expect.arrayContaining(['title', 'summary']));
137+
const patch = await stack.apiAs(memberTok, 'PATCH', `/data/rfa_notice/${othersNoticeId}`, { title: 'N' });
138+
expect(patch.status, await patch.clone().text()).toBe(403);
139+
}, 60_000);
140+
121141
it('the row-less reader still answers as before, and the route takes no query parameter', async () => {
122142
const res = await stack.apiAs(memberTok, 'GET', '/auth/me/permissions');
123143
const body: any = await res.json();
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `ObjectStackAdapter.getRecordFieldAccess` (objectstack#26594) — the record
11+
* form's question to the server: `GET /api/v1/data/:object/:id/field-access`.
12+
*
13+
* Pinned:
14+
* - the wire: that route, the object and id encoded, the envelope unwrapped;
15+
* - "no answer" is `null`, never a throw, for the two refusals that mean it —
16+
* the record read's own 404 (absent or hidden from the caller) and a server
17+
* without the route (501 `NOT_IMPLEMENTED`) — so the form keeps its
18+
* row-blind answer;
19+
* - any other refusal reaches the caller, as a read's does.
20+
*/
21+
22+
import { describe, it, expect, vi } from 'vitest';
23+
import { ObjectStackAdapter } from './index';
24+
25+
type FetchSpy = ReturnType<typeof vi.fn<(url: string, init: RequestInit) => Promise<Response>>>;
26+
27+
function makeDS(fetchImpl: FetchSpy, baseUrl = 'http://test.local') {
28+
const ds: any = new ObjectStackAdapter({ baseUrl, fetch: fetchImpl as any });
29+
ds.connected = true;
30+
ds.connectionState = 'connected';
31+
return ds;
32+
}
33+
34+
const json = (status: number, body: unknown) =>
35+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
36+
37+
const ACCESS = { object: 'rfa_contract', recordId: 'c/1', readable: ['title', 'summary'], editable: ['title', 'summary'] };
38+
39+
describe('[#26594] ObjectStackAdapter.getRecordFieldAccess', () => {
40+
it("GETs the record's field-access route and hands back the projection", async () => {
41+
const fetchImpl: FetchSpy = vi.fn(async () => json(200, { success: true, data: ACCESS }));
42+
const ds = makeDS(fetchImpl);
43+
await expect(ds.getRecordFieldAccess('rfa_contract', 'c/1')).resolves.toEqual(ACCESS);
44+
expect(fetchImpl).toHaveBeenCalledTimes(1);
45+
const [url, init] = fetchImpl.mock.calls[0];
46+
expect(url).toBe('http://test.local/api/v1/data/rfa_contract/c%2F1/field-access');
47+
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
48+
});
49+
50+
it('a record the read answers 404 for is no answer, not a failure', async () => {
51+
const fetchImpl: FetchSpy = vi.fn(async () =>
52+
json(404, { success: false, error: { code: 'RECORD_NOT_FOUND', message: 'Record c1 not found in rfa_memo' } }));
53+
await expect(makeDS(fetchImpl).getRecordFieldAccess('rfa_memo', 'c1')).resolves.toBeNull();
54+
});
55+
56+
it('a server without the route (501) is no answer, not a failure', async () => {
57+
const fetchImpl: FetchSpy = vi.fn(async () =>
58+
json(501, { success: false, error: { code: 'NOT_IMPLEMENTED', message: 'no record-scoped projection' } }));
59+
await expect(makeDS(fetchImpl).getRecordFieldAccess('rfa_contract', 'c1')).resolves.toBeNull();
60+
});
61+
62+
it('any other refusal reaches the caller', async () => {
63+
const fetchImpl: FetchSpy = vi.fn(async () =>
64+
json(403, { success: false, error: { code: 'PERMISSION_DENIED', message: 'no read' } }));
65+
await expect(makeDS(fetchImpl).getRecordFieldAccess('rfa_contract', 'c1')).rejects.toMatchObject({ httpStatus: 403 });
66+
});
67+
});

‎ui/packages/data-objectstack/src/index.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
import type { AnalyticsResult, DatasetSelection } from '@objectstack/spec/contracts';
4848
import type {
4949
DataSource,
50+
RecordFieldAccess,
5051
ConnectionState,
5152
// #4564 — the canonical `deleteView` receipt shapes, declared beside the
5253
// `DataSource` interface they belong to. Imported for the local uses below
@@ -1018,6 +1019,17 @@ export function is404Error(error: unknown): boolean {
10181019
return errorCodeIsAnyOf({ code }, ['OBJECT_NOT_FOUND', 'RECORD_NOT_FOUND']);
10191020
}
10201021

1022+
/**
1023+
* A server that does not offer the route asked (`501 NOT_IMPLEMENTED`) — for an
1024+
* optional capability, "no answer", never a failure of the read.
1025+
*/
1026+
function isNotImplementedError(error: unknown): boolean {
1027+
if (!error || typeof error !== 'object') return false;
1028+
const err = error as Record<string, unknown>;
1029+
if (err.httpStatus === 501 || err.status === 501 || err.statusCode === 501) return true;
1030+
return errorCodeIs({ code: typeof err.code === 'string' ? err.code : '' }, 'NOT_IMPLEMENTED');
1031+
}
1032+
10211033
// `API_ACCESS_DENIED_CODES` / `isApiAccessDeniedError` live in
10221034
// `@object-ui/core` (`utils/api-access-denied.ts`) so that `@object-ui/react`
10231035
// can classify the denial without depending on this adapter. Re-exported here
@@ -7231,6 +7243,28 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
72317243
return response.json();
72327244
}
72337245

7246+
/**
7247+
* The field projection of ONE stored record (objectstack#26594):
7248+
* `GET /data/{object}/{id}/field-access` through the client's
7249+
* `data.fieldAccess`. Its `editable` is the server's write gate judged on
7250+
* the stored row, so a field permission whose `when` names only some rows
7251+
* locks the field only on those rows.
7252+
*
7253+
* `null` when there is no answer to give: a record the caller cannot see
7254+
* (the route answers the record read's own 404), or a server with no
7255+
* record-scoped projection (501). Any other failure is thrown, as a read's.
7256+
*/
7257+
async getRecordFieldAccess(resource: string, id: string): Promise<RecordFieldAccess | null> {
7258+
await this.connect();
7259+
try {
7260+
const access = await this.client.data.fieldAccess(resource, String(id));
7261+
return access ?? null;
7262+
} catch (error: unknown) {
7263+
if (is404Error(error) || isNotImplementedError(error)) return null;
7264+
throw error;
7265+
}
7266+
}
7267+
72347268
/**
72357269
* Cancel (recall) the active pending approval request for a given record.
72367270
*

0 commit comments

Comments
 (0)