Skip to content

Commit 0e7be7e

Browse files
fix(react,components,app-shell): a delegated admin's out-of-scope save shows the server's reason (#26363)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9SmSkP71JiVHXqMg5WDuVZY)_ Fixes #26190 Clause-②: yes (widening: `@object-ui/react` exports a new `delegationScopeMessage`) Metadata-protocol: none ## 维护者速读 - 改了什么:委派管理员在自己范围之外保存时(服务端回 `403 DELEGATION_SCOPE_VIOLATION`),记录表单的 toast 与表单横幅、以及 flow 运行失败提示,改为显示服务端给出的原因;其它所有拒绝照旧显示固定的「没有权限保存」。 - 为什么改:#26059 裁决 Q3 第 4 项。委派管理员需要知道是哪条边界(业务单元、权限集白名单、目标用户)拦下了操作,才能自己修正分配;原来所有 403 都被当成普通拒绝。 - 风险与回滚:只读这一个错误码,`PERMISSION_DENIED`、`FORBIDDEN`、裸 403、行级安全拒绝的分支不变(#24914 B′ 不变);回滚即 revert 本 PR。 - 先看:`ui/packages/react/src/utils/error-message.ts`(新函数 `delegationScopeMessage`)、`ui/packages/components/src/renderers/form/form.tsx`(错误文案取值链)、`ui/packages/app-shell/src/utils/flowResponse.ts`(`flowFailureMessage`)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/react/src/utils/error-message.test.ts packages/components/src/__tests__/form-write-error-message.test.tsx packages/app-shell/src/utils/__tests__/flowResponse.test.ts`,预期 3 个文件全部通过。 - 需要你做的:无,席位落地。 ## What changes - **`@object-ui/react`**: new exported `delegationScopeMessage(err)`. It answers the server's reason, without the `[Security] ` machine prefix (via `extractWriteErrorMessage`), when the error's code is `DELEGATION_SCOPE_VIOLATION` (read from `code`, then `details.code`, the same pair `isPermissionError` reads, case-insensitive through `errorCodeIs`). It answers `null` for everything else, and for a scope refusal with no usable text. `isPermissionError` is unchanged and still answers true for any 403; its doc now says to ask the new reader first. - **`@object-ui/components`** (record form): the message chain is `declaredUserMessage` → `delegationScopeMessage` → `isPermissionError ? form.noPermissionToSave : server text`. The toast and the in-form banner share the value. - **`@object-ui/app-shell`** (flow run path, `flowFailureMessage`): the inner refusal is asked for the scope reason before the permission check, so a write node that met the same refusal shows the same reason. - Root changeset `.changeset/26190-delegation-scope-refusal-reason.md` (patch on the three packages). - No sdui record. The per-package records were retired by #26367 while this PR was open. The main merge took the modify/delete with `git rm`, and CI's Build & E2E UI regenerates the manifest. ## Tests - `ui/packages/react/src/utils/error-message.test.ts`: `delegationScopeMessage` answers the reason for the client's 403 shape and for `details.code`; answers `null` for `PERMISSION_DENIED`, `FORBIDDEN`, a bare 403, a row-level-security message and `null`; answers `null` for a scope refusal with no text. One assertion pins why it must be asked first: `isPermissionError` answers true for the same error. - `ui/packages/components/src/__tests__/form-write-error-message.test.tsx`: a scope refusal renders the reason without `[Security]` and without the fixed line; CONTROL: a plain `403 PERMISSION_DENIED` keeps the fixed line and withholds its text. - `ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts`: the 400 `FLOW_FAILED` door and the 200 failed arm both show the reason; CONTROL on the same path: a `PERMISSION_DENIED` inner refusal still reads `form.noPermissionToSave`. - No forced edits to existing tests. ## Acceptance notes - **Flow site premise, measured.** On the flow path the inner refusal carries `code` and `message` but no status, and the scope reason does not match `isPermissionError`'s text pattern, so before this change the flow path already showed the server text, with its `[Security] ` prefix. The ablation shows exactly that: with the new line removed the test fails on the prefix, not on the fixed line. The explicit read makes the rule hold by code rather than by the text happening not to match, and makes the two sites render the same string. - The card's note about the producer-side `userMessage` marking: not used. The ruled shape is the code; `userMessage` still wins when a producer sets it, as before. - `delegationScopeMessage` is a new export of `@object-ui/react`, so the declaration is `Clause-②: yes (widening)`, re-declared at the seat's review (the claim first said `no`); the changeset carries the same line. Metadata-protocol stays `none` and the bump stays `patch`. ## Verification Head: `2f86b3a3` (code); `c786eccb` changes only the changeset's `Clause-②` line, and the changeset gates (`check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`) exit 0 there <details><summary>Tests (lock VERDICT lines)</summary> All runs on head `2f86b3a3` (origin/main `222755eb` merged in), under `scripts/pm/os-verify-lock.sh`: ``` pnpm --dir ui exec vitest run --maxWorkers=2 packages/react/src/utils/ packages/components/src/__tests__/form-write-error-message.test.tsx packages/app-shell/src/utils/__tests__/flowResponse.test.ts Test Files 8 passed (8) Tests 145 passed (145) pnpm --filter @object-ui/react run type-check (tsc --noEmit && tsc -p tsconfig.test.json) pnpm --filter @object-ui/components run type-check pnpm --filter @object-ui/app-shell run type-check os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>Ablations</summary> Taken on head `43106bd8` (pre-merge, before the record commits) with `scripts/ablation-replace.mjs`; tests resolve `@object-ui/*` to `src/` through the vitest alias, so no build sits between the mutation and the run. ``` form.tsx: delete the line "delegationScopeMessage(error) ??" ok mutation landed: anchor 1 -> 0 x renders the server's reason, without the machine prefix, instead of the fixed line Tests 1 failed | 10 passed (11) ok restored: blob == HEAD and `git diff HEAD` is empty flowResponse.ts: delete the line "if (scopeReason) return scopeReason;" ok mutation landed: anchor 1 -> 0 x a delegated-admin scope refusal shows the server reason, not the fixed permission line - "error": "Outside your delegated scope: ..." + "error": "[Security] Outside your delegated scope: ..." Tests 1 failed | 64 passed (65) ok restored: blob == HEAD and `git diff HEAD` is empty ``` </details> <details><summary>Gates (dispatch-gates --commands, 35 commands)</summary> Derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `2f86b3a3`; every command run there; reconciled: ``` Run reconciliation — 45 derived, 45 run, 0 NOT-MEASURED, 0 UNRUN. ``` ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-changeset-no-major.mjs --self-test :: exit 0 node scripts/check-ci-filter-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-scripts-symbol-anchors.mjs :: exit 0 node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/check-undeclared-dep-imports.mjs :: exit 0 node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` CI owns the type-check lanes and the repo-wide scans. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01JZRTRVa5FXXjuAJC6ZPM8P)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92a0073 commit 0e7be7e

8 files changed

Lines changed: 188 additions & 5 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/react': patch
3+
'@object-ui/components': patch
4+
'@object-ui/app-shell': patch
5+
---
6+
7+
fix(react,components,app-shell): a delegated admin's out-of-scope save shows the server's reason instead of the fixed no-permission line
8+
9+
Clause-②: yes (widening: `@object-ui/react` exports a new `delegationScopeMessage`)
10+
Metadata-protocol: none
11+
12+
When a delegated administrator saved something outside their own scope, the server answered `403 DELEGATION_SCOPE_VIOLATION` with the boundary that refused it (a business unit outside the delegated subtree, a permission set not in the scope's allowlist, a target user outside the subtree). The console read every 403 as a plain denial, so the toast and the in-form banner said only "You don't have permission to save this record."
13+
14+
- **`@object-ui/react`.** New `delegationScopeMessage(err)`: the server's reason, without the `[Security] ` machine prefix, when the error's code (`code` or `details.code`) is `DELEGATION_SCOPE_VIOLATION`; `null` for everything else. It is read before `isPermissionError`, which still answers true for any 403.
15+
- **`@object-ui/components`.** The record form's toast and banner show that reason. A producer-marked `userMessage` still comes first.
16+
- **`@object-ui/app-shell`.** A flow run whose write node met the same refusal (`innerRefusal`) shows the same reason.
17+
18+
Every other denial is unchanged: `PERMISSION_DENIED`, `FORBIDDEN`, a bare 403 and a row-level-security refusal still show the fixed localized line and never the server's text.
19+
20+
What to do: nothing.

‎ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,25 @@ describe('interpretFlowResponse — a refusal met inside the run, by the record-
696696
expect((zh as { error: string }).error).not.toContain('crm_account');
697697
});
698698

699+
// objectstack#26059 Q3: a delegated admin's out-of-scope write is the one
700+
// denial whose reason is shown; it describes only the caller's own scope.
701+
it('a delegated-admin scope refusal shows the server reason, not the fixed permission line', () => {
702+
const SCOPE_REASON =
703+
"Outside your delegated scope: delegated 'create' on sys_user_position rejected — business unit 'bu_west' is outside the delegated subtree (scope from 'regional_admin').";
704+
const SCOPE = { code: 'DELEGATION_SCOPE_VIOLATION', message: `[Security] ${SCOPE_REASON}` } as const;
705+
706+
expect(failed400(flowFailed({ innerRefusal: SCOPE }))).toEqual({
707+
kind: 'failed', error: SCOPE_REASON, retryable: false,
708+
});
709+
const on200 = interpretFlowResponse(ok, {
710+
success: true,
711+
data: { success: false, status: 'failed', error: RAW_ERROR, innerRefusal: SCOPE },
712+
}, 'Flow "assign"');
713+
expect(on200).toMatchObject({ kind: 'failed', error: SCOPE_REASON });
714+
// Control: a plain denial on the same path still reads the fixed line.
715+
expect(failed400(flowFailed({ innerRefusal: DENIED }))).toMatchObject({ error: builtInLocales.en.form.noPermissionToSave });
716+
});
717+
699718
it('a producer-marked userMessage wins over the inner refusal, verbatim', () => {
700719
expect(failed400(flowFailed({ innerRefusal: UNIQUE }, USER_TEXT))).toMatchObject({ error: USER_TEXT });
701720
expect(failed400(flowFailed({ innerRefusal: DENIED }, USER_TEXT))).toMatchObject({ error: USER_TEXT });

‎ui/packages/app-shell/src/utils/flowResponse.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@
214214
import { actionCopySource, actionErrorDetail } from '@object-ui/core';
215215
import type { ActionResult } from '@object-ui/core';
216216
import { getLoadedBuiltInLocales } from '@object-ui/i18n';
217-
import { declaredUserMessage, isPermissionError } from '@object-ui/react';
217+
import { declaredUserMessage, delegationScopeMessage, isPermissionError } from '@object-ui/react';
218218
import { errorCodeIs } from '@object-ui/types';
219219
import type { AutomationResult } from '@objectstack/spec/contracts';
220220
import { translateFlow, type TranslationBundle } from '@objectstack/spec/system';
@@ -368,7 +368,9 @@ function permissionDeniedLine(locale: string | undefined): string {
368368
* The inner refusal is rendered exactly as the record form renders the same
369369
* refusal on a save: a permission denial (`isPermissionError`) becomes the
370370
* form's localized permission line instead of server prose (objectstack#3821),
371-
* anything else reads its own sentence. `message` stays the last rung, so a
371+
* anything else reads its own sentence. A delegated-admin scope refusal
372+
* (`DELEGATION_SCOPE_VIOLATION`) reads its own reason, asked before the
373+
* permission check exactly as the form asks it (objectstack#26059, Q3). `message` stays the last rung, so a
372374
* run that met no refusal still says something, never nothing.
373375
*/
374376
function flowFailureMessage(failure: FlowFailureCarriers, message: string, copy: FlowCopyLocale | undefined): string {
@@ -378,6 +380,8 @@ function flowFailureMessage(failure: FlowFailureCarriers, message: string, copy:
378380
const inner = failure.innerRefusal as { code?: unknown; message?: unknown } | null | undefined;
379381
if (inner && typeof inner === 'object' && typeof inner.code === 'string') {
380382
const refusal = { code: inner.code, message: typeof inner.message === 'string' ? inner.message : '' };
383+
const scopeReason = delegationScopeMessage(refusal);
384+
if (scopeReason) return scopeReason;
381385
if (isPermissionError(refusal)) return permissionDeniedLine(copy?.locale);
382386
if (refusal.message.trim()) return refusal.message;
383387
}

‎ui/packages/components/src/__tests__/form-write-error-message.test.tsx‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,53 @@ describe('a producer-marked refusal reaches the user', () => {
160160
expect(await screen.findByText(/title must be unique/i)).toBeInTheDocument();
161161
});
162162
});
163+
164+
/**
165+
* objectstack#26059 Q3 (child #26190) — a delegated administrator who saves
166+
* outside their own scope is told which boundary refused it.
167+
*
168+
* `403 DELEGATION_SCOPE_VIOLATION` is the one denial whose server reason is
169+
* shown: it describes only the caller's own delegated scope. Every other denial
170+
* keeps the fixed line (objectstack#24914 B′, unchanged), which the plain-403
171+
* control below holds.
172+
*/
173+
describe('a delegated-admin scope refusal shows the server reason', () => {
174+
const REASON =
175+
"Outside your delegated scope: delegated 'update' on sys_user_position rejected — business unit 'bu_west' is outside the delegated subtree (scope from 'regional_admin').";
176+
177+
beforeEach(() => {
178+
vi.spyOn(console, 'warn').mockImplementation(() => {});
179+
});
180+
181+
function scopeRefusal() {
182+
// The shape `@objectstack/client` hands the form for a 403: the server's
183+
// message on `message`, the semantic code on `code`, the status on
184+
// `httpStatus`.
185+
return Object.assign(new Error(`[Security] ${REASON}`), {
186+
code: 'DELEGATION_SCOPE_VIOLATION',
187+
httpStatus: 403,
188+
});
189+
}
190+
191+
it("renders the server's reason, without the machine prefix, instead of the fixed line", async () => {
192+
const { container } = renderForm(async () => { throw scopeRefusal(); });
193+
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
194+
195+
expect(await screen.findByText(REASON)).toBeInTheDocument();
196+
expect(screen.queryByText(/permission to save/i)).not.toBeInTheDocument();
197+
expect(container.textContent).not.toMatch(/\[Security\]/);
198+
});
199+
200+
it('CONTROL: a plain 403 PERMISSION_DENIED keeps the fixed line and withholds its text', async () => {
201+
const { container } = renderForm(async () => {
202+
throw Object.assign(
203+
new Error("[Security] Access denied: delegated 'update' on sys_user_position rejected — no delegated scope covers this write."),
204+
{ code: 'PERMISSION_DENIED', httpStatus: 403 },
205+
);
206+
});
207+
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
208+
209+
expect(await screen.findByText(/permission to save/i)).toBeInTheDocument();
210+
expect(container.textContent).not.toMatch(/sys_user_position/);
211+
});
212+
});

‎ui/packages/components/src/renderers/form/form.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import { FullscreenEditor } from '../../custom/fullscreen-editor';
4242
import { CharacterCount } from '../../custom/character-count';
4343
import { cn } from '../../lib/utils';
4444
import React from 'react';
45-
import { SchemaRendererContext, usePredicateScope, isPermissionError, extractWriteErrorMessage, extractFieldErrors, declaredUserMessage } from '@object-ui/react';
45+
import { SchemaRendererContext, usePredicateScope, isPermissionError, extractWriteErrorMessage, extractFieldErrors, declaredUserMessage, delegationScopeMessage } from '@object-ui/react';
4646
import { createSafeTranslation } from '@object-ui/i18n';
4747

4848
/**
@@ -3048,8 +3048,15 @@ ComponentRegistry.register('form',
30483048
// construction rather than by us guessing what a 403 body contains.
30493049
// Status-agnostic on purpose: 403 is where this was reported
30503050
// (objectui#5210), not a fence the contract draws.
3051+
// A delegated administrator's out-of-scope write (`403
3052+
// DELEGATION_SCOPE_VIOLATION`) is the one denial whose reason is shown:
3053+
// it describes only the caller's own scope, and it tells them which
3054+
// boundary to fix (objectstack#26059, Q3). It is read before
3055+
// `isPermissionError`, which answers true for any 403; every other
3056+
// denial keeps the fixed line (objectstack#24914 B′).
30513057
const errorMessage =
30523058
declaredUserMessage(error) ??
3059+
delegationScopeMessage(error) ??
30533060
(isPermissionError(error)
30543061
? t('form.noPermissionToSave')
30553062
: extractWriteErrorMessage(error) ?? t('form.submitFailed'));

‎ui/packages/react/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export type { PredicateGateKind } from './utils/visibilityDiagnostic.js';
8484

8585
// Write-error surfacing utilities (shared by drag-write plugins so a failed
8686
// PATCH — e.g. an RLS 403 — is never silently swallowed).
87-
export { extractWriteErrorMessage, isPermissionError, extractFieldErrors, classifyLoadError, declaredUserMessage } from './utils/error-message.js';
87+
export { extractWriteErrorMessage, isPermissionError, extractFieldErrors, classifyLoadError, declaredUserMessage, delegationScopeMessage } from './utils/error-message.js';
8888
export type { WriteFieldError, LoadErrorKind } from './utils/error-message.js';
8989

9090
// Built-in i18n support

‎ui/packages/react/src/utils/error-message.test.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest';
1010

1111
import { ApiErrorSchema } from '@objectstack/spec/api';
1212

13-
import { extractFieldErrors, classifyLoadError, declaredUserMessage } from './error-message';
13+
import { extractFieldErrors, classifyLoadError, declaredUserMessage, delegationScopeMessage, isPermissionError } from './error-message';
1414

1515
describe('extractFieldErrors', () => {
1616
// The wire shape the server actually sends: `@objectstack/rest` serves a
@@ -250,3 +250,44 @@ describe('declaredUserMessage', () => {
250250
expect(declaredUserMessage(parsedUnmarked)).toBeNull();
251251
});
252252
});
253+
254+
// objectstack#26059 Q3: the one denial whose server reason a surface shows.
255+
describe('delegationScopeMessage', () => {
256+
const REASON =
257+
"Outside your delegated scope: delegated 'update' on sys_user_permission_set rejected — permission set 'finance_admin' is not in the scope's allowlist (scope from 'regional_admin').";
258+
259+
it("answers the server's reason, without the machine prefix, for a 403 DELEGATION_SCOPE_VIOLATION", () => {
260+
const err = Object.assign(new Error(`[Security] ${REASON}`), {
261+
code: 'DELEGATION_SCOPE_VIOLATION',
262+
httpStatus: 403,
263+
});
264+
expect(delegationScopeMessage(err)).toBe(REASON);
265+
// The reason it must be asked first: the permission predicate claims it too.
266+
expect(isPermissionError(err)).toBe(true);
267+
});
268+
269+
it('reads the code from `details` too, the pair isPermissionError reads', () => {
270+
const err = Object.assign(new Error(`[Security] ${REASON}`), {
271+
httpStatus: 403,
272+
details: { code: 'DELEGATION_SCOPE_VIOLATION' },
273+
});
274+
expect(delegationScopeMessage(err)).toBe(REASON);
275+
});
276+
277+
it('CONTROL: every other denial answers null, so its text stays withheld', () => {
278+
const denied = (code: string | undefined) =>
279+
Object.assign(new Error("[Security] Access denied: delegated 'update' on sys_user_position rejected — no scope."), {
280+
code,
281+
httpStatus: 403,
282+
});
283+
expect(delegationScopeMessage(denied('PERMISSION_DENIED'))).toBeNull();
284+
expect(delegationScopeMessage(denied('FORBIDDEN'))).toBeNull();
285+
expect(delegationScopeMessage(denied(undefined))).toBeNull();
286+
expect(delegationScopeMessage(new Error('row-level security policy violation'))).toBeNull();
287+
expect(delegationScopeMessage(null)).toBeNull();
288+
});
289+
290+
it('answers null for a scope refusal with no usable text, so the caller still says something', () => {
291+
expect(delegationScopeMessage({ code: 'DELEGATION_SCOPE_VIOLATION', httpStatus: 403, message: '' })).toBeNull();
292+
});
293+
});

‎ui/packages/react/src/utils/error-message.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323

2424
import { isApiAccessDeniedError } from '@object-ui/core';
25+
import { errorCodeIs } from '@object-ui/types';
2526
import type { ApiError } from '@objectstack/spec/api';
2627

2728
/**
@@ -136,6 +137,43 @@ export function declaredUserMessage(err: unknown): string | null {
136137
return null;
137138
}
138139

140+
/**
141+
* The ADR-0112 code a delegated administrator's write answers when it falls
142+
* outside the caller's OWN `adminScope` (the ledger row registered under
143+
* `@objectstack/plugin-security`, thrown as `DelegationScopeViolationError`).
144+
*/
145+
const DELEGATION_SCOPE_VIOLATION = 'DELEGATION_SCOPE_VIOLATION';
146+
147+
/**
148+
* The server's reason for a delegated-admin scope refusal, or `null` when the
149+
* refusal is anything else.
150+
*
151+
* A `403 DELEGATION_SCOPE_VIOLATION` names which boundary of the caller's own
152+
* delegated scope refused the write (a business unit outside the delegated
153+
* subtree, a permission set outside the allowlist, a target user outside the
154+
* subtree). That text describes only the caller's own scope, so it is safe to
155+
* show, and it tells the delegate what to fix (ruling on objectstack#26059, Q3).
156+
* Every other denial stays opaque: `PERMISSION_DENIED`, `FORBIDDEN`, a bare 403
157+
* or a row-level-security refusal answers `null` here, and callers keep their
158+
* fixed localized line (ruling B′ on objectstack#24914, unchanged).
159+
*
160+
* Callers must ask this BEFORE {@link isPermissionError}: that predicate answers
161+
* true for any 403, so a scope refusal read after it is flattened into the
162+
* plain denial. The code is read from the same pair {@link isPermissionError}
163+
* reads (`code`, then `details.code`); the text is
164+
* {@link extractWriteErrorMessage}'s, without the `[Security] ` machine prefix.
165+
* A scope refusal that carries no usable text answers `null`, so the caller
166+
* still says something.
167+
*/
168+
export function delegationScopeMessage(err: unknown): string | null {
169+
if (!err || typeof err !== 'object') return null;
170+
const details = (err as { details?: unknown }).details;
171+
if (!errorCodeIs(err, DELEGATION_SCOPE_VIOLATION) && !errorCodeIs(details, DELEGATION_SCOPE_VIOLATION)) {
172+
return null;
173+
}
174+
return extractWriteErrorMessage(err);
175+
}
176+
139177
/** One field the server rejected, normalised for `form.setError`. */
140178
export interface WriteFieldError {
141179
/** Field name, matching the form control's `name`. */
@@ -198,6 +236,10 @@ export function extractFieldErrors(err: unknown): WriteFieldError[] | null {
198236
* `PERMISSION_DENIED` / `FORBIDDEN` / a row-level-security denial). Lets a
199237
* caller show a friendly "you don't have permission" toast rather than dumping
200238
* the raw server text.
239+
*
240+
* A delegated-admin scope refusal is a 403 too, so this answers true for it:
241+
* a caller that shows that refusal's reason asks
242+
* {@link delegationScopeMessage} first.
201243
*/
202244
export function isPermissionError(err: unknown): boolean {
203245
const e = err as Record<string, any> | null | undefined;

0 commit comments

Comments
 (0)