From 43106bd868c3337db9417985798a339f9529d2f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:57:30 +0000 Subject: [PATCH 1/4] fix(react,components,app-shell): a delegated admin's out-of-scope save shows the server's reason A 403 DELEGATION_SCOPE_VIOLATION carries the boundary of the caller's own delegated scope that refused the write. The record form and the flow-run path classed every 403 as a plain denial and showed the fixed form.noPermissionToSave line. delegationScopeMessage reads the code before isPermissionError; every other denial keeps the fixed line (ruling B' on the record-save rule, unchanged). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01JZRTRVa5FXXjuAJC6ZPM8P --- .../26190-delegation-scope-refusal-reason.md | 20 ++++++++ .../src/utils/__tests__/flowResponse.test.ts | 19 +++++++ .../app-shell/src/utils/flowResponse.ts | 8 ++- .../form-write-error-message.test.tsx | 50 +++++++++++++++++++ .../components/src/renderers/form/form.tsx | 9 +++- ui/packages/react/src/index.ts | 2 +- .../react/src/utils/error-message.test.ts | 43 +++++++++++++++- ui/packages/react/src/utils/error-message.ts | 42 ++++++++++++++++ 8 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 .changeset/26190-delegation-scope-refusal-reason.md diff --git a/.changeset/26190-delegation-scope-refusal-reason.md b/.changeset/26190-delegation-scope-refusal-reason.md new file mode 100644 index 00000000000..ebae4b08c59 --- /dev/null +++ b/.changeset/26190-delegation-scope-refusal-reason.md @@ -0,0 +1,20 @@ +--- +'@object-ui/react': patch +'@object-ui/components': patch +'@object-ui/app-shell': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +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." + +- **`@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. +- **`@object-ui/components`.** The record form's toast and banner show that reason. A producer-marked `userMessage` still comes first. +- **`@object-ui/app-shell`.** A flow run whose write node met the same refusal (`innerRefusal`) shows the same reason. + +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. + +What to do: nothing. diff --git a/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts b/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts index 258e2b9ec1d..f0a4425a972 100644 --- a/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts +++ b/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts @@ -696,6 +696,25 @@ describe('interpretFlowResponse — a refusal met inside the run, by the record- expect((zh as { error: string }).error).not.toContain('crm_account'); }); + // objectstack#26059 Q3: a delegated admin's out-of-scope write is the one + // denial whose reason is shown; it describes only the caller's own scope. + it('a delegated-admin scope refusal shows the server reason, not the fixed permission line', () => { + const SCOPE_REASON = + "Outside your delegated scope: delegated 'create' on sys_user_position rejected — business unit 'bu_west' is outside the delegated subtree (scope from 'regional_admin')."; + const SCOPE = { code: 'DELEGATION_SCOPE_VIOLATION', message: `[Security] ${SCOPE_REASON}` } as const; + + expect(failed400(flowFailed({ innerRefusal: SCOPE }))).toEqual({ + kind: 'failed', error: SCOPE_REASON, retryable: false, + }); + const on200 = interpretFlowResponse(ok, { + success: true, + data: { success: false, status: 'failed', error: RAW_ERROR, innerRefusal: SCOPE }, + }, 'Flow "assign"'); + expect(on200).toMatchObject({ kind: 'failed', error: SCOPE_REASON }); + // Control: a plain denial on the same path still reads the fixed line. + expect(failed400(flowFailed({ innerRefusal: DENIED }))).toMatchObject({ error: builtInLocales.en.form.noPermissionToSave }); + }); + it('a producer-marked userMessage wins over the inner refusal, verbatim', () => { expect(failed400(flowFailed({ innerRefusal: UNIQUE }, USER_TEXT))).toMatchObject({ error: USER_TEXT }); expect(failed400(flowFailed({ innerRefusal: DENIED }, USER_TEXT))).toMatchObject({ error: USER_TEXT }); diff --git a/ui/packages/app-shell/src/utils/flowResponse.ts b/ui/packages/app-shell/src/utils/flowResponse.ts index b4ae44eb734..3a1f8f975b1 100644 --- a/ui/packages/app-shell/src/utils/flowResponse.ts +++ b/ui/packages/app-shell/src/utils/flowResponse.ts @@ -214,7 +214,7 @@ import { actionCopySource, actionErrorDetail } from '@object-ui/core'; import type { ActionResult } from '@object-ui/core'; import { getLoadedBuiltInLocales } from '@object-ui/i18n'; -import { declaredUserMessage, isPermissionError } from '@object-ui/react'; +import { declaredUserMessage, delegationScopeMessage, isPermissionError } from '@object-ui/react'; import { errorCodeIs } from '@object-ui/types'; import type { AutomationResult } from '@objectstack/spec/contracts'; import { translateFlow, type TranslationBundle } from '@objectstack/spec/system'; @@ -368,7 +368,9 @@ function permissionDeniedLine(locale: string | undefined): string { * The inner refusal is rendered exactly as the record form renders the same * refusal on a save: a permission denial (`isPermissionError`) becomes the * form's localized permission line instead of server prose (objectstack#3821), - * anything else reads its own sentence. `message` stays the last rung, so a + * anything else reads its own sentence. A delegated-admin scope refusal + * (`DELEGATION_SCOPE_VIOLATION`) reads its own reason, asked before the + * permission check exactly as the form asks it (objectstack#26059, Q3). `message` stays the last rung, so a * run that met no refusal still says something, never nothing. */ function flowFailureMessage(failure: FlowFailureCarriers, message: string, copy: FlowCopyLocale | undefined): string { @@ -378,6 +380,8 @@ function flowFailureMessage(failure: FlowFailureCarriers, message: string, copy: const inner = failure.innerRefusal as { code?: unknown; message?: unknown } | null | undefined; if (inner && typeof inner === 'object' && typeof inner.code === 'string') { const refusal = { code: inner.code, message: typeof inner.message === 'string' ? inner.message : '' }; + const scopeReason = delegationScopeMessage(refusal); + if (scopeReason) return scopeReason; if (isPermissionError(refusal)) return permissionDeniedLine(copy?.locale); if (refusal.message.trim()) return refusal.message; } diff --git a/ui/packages/components/src/__tests__/form-write-error-message.test.tsx b/ui/packages/components/src/__tests__/form-write-error-message.test.tsx index edc171afca3..64cadf10db7 100644 --- a/ui/packages/components/src/__tests__/form-write-error-message.test.tsx +++ b/ui/packages/components/src/__tests__/form-write-error-message.test.tsx @@ -160,3 +160,53 @@ describe('a producer-marked refusal reaches the user', () => { expect(await screen.findByText(/title must be unique/i)).toBeInTheDocument(); }); }); + +/** + * objectstack#26059 Q3 (child #26190) — a delegated administrator who saves + * outside their own scope is told which boundary refused it. + * + * `403 DELEGATION_SCOPE_VIOLATION` is the one denial whose server reason is + * shown: it describes only the caller's own delegated scope. Every other denial + * keeps the fixed line (objectstack#24914 B′, unchanged), which the plain-403 + * control below holds. + */ +describe('a delegated-admin scope refusal shows the server reason', () => { + const REASON = + "Outside your delegated scope: delegated 'update' on sys_user_position rejected — business unit 'bu_west' is outside the delegated subtree (scope from 'regional_admin')."; + + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + function scopeRefusal() { + // The shape `@objectstack/client` hands the form for a 403: the server's + // message on `message`, the semantic code on `code`, the status on + // `httpStatus`. + return Object.assign(new Error(`[Security] ${REASON}`), { + code: 'DELEGATION_SCOPE_VIOLATION', + httpStatus: 403, + }); + } + + it("renders the server's reason, without the machine prefix, instead of the fixed line", async () => { + const { container } = renderForm(async () => { throw scopeRefusal(); }); + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + + expect(await screen.findByText(REASON)).toBeInTheDocument(); + expect(screen.queryByText(/permission to save/i)).not.toBeInTheDocument(); + expect(container.textContent).not.toMatch(/\[Security\]/); + }); + + it('CONTROL: a plain 403 PERMISSION_DENIED keeps the fixed line and withholds its text', async () => { + const { container } = renderForm(async () => { + throw Object.assign( + new Error("[Security] Access denied: delegated 'update' on sys_user_position rejected — no delegated scope covers this write."), + { code: 'PERMISSION_DENIED', httpStatus: 403 }, + ); + }); + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + + expect(await screen.findByText(/permission to save/i)).toBeInTheDocument(); + expect(container.textContent).not.toMatch(/sys_user_position/); + }); +}); diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index fa032bb62d8..9be13044296 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -42,7 +42,7 @@ import { FullscreenEditor } from '../../custom/fullscreen-editor'; import { CharacterCount } from '../../custom/character-count'; import { cn } from '../../lib/utils'; import React from 'react'; -import { SchemaRendererContext, usePredicateScope, isPermissionError, extractWriteErrorMessage, extractFieldErrors, declaredUserMessage } from '@object-ui/react'; +import { SchemaRendererContext, usePredicateScope, isPermissionError, extractWriteErrorMessage, extractFieldErrors, declaredUserMessage, delegationScopeMessage } from '@object-ui/react'; import { createSafeTranslation } from '@object-ui/i18n'; /** @@ -3048,8 +3048,15 @@ ComponentRegistry.register('form', // construction rather than by us guessing what a 403 body contains. // Status-agnostic on purpose: 403 is where this was reported // (objectui#5210), not a fence the contract draws. + // A delegated administrator's out-of-scope write (`403 + // DELEGATION_SCOPE_VIOLATION`) is the one denial whose reason is shown: + // it describes only the caller's own scope, and it tells them which + // boundary to fix (objectstack#26059, Q3). It is read before + // `isPermissionError`, which answers true for any 403; every other + // denial keeps the fixed line (objectstack#24914 B′). const errorMessage = declaredUserMessage(error) ?? + delegationScopeMessage(error) ?? (isPermissionError(error) ? t('form.noPermissionToSave') : extractWriteErrorMessage(error) ?? t('form.submitFailed')); diff --git a/ui/packages/react/src/index.ts b/ui/packages/react/src/index.ts index 847232520e7..ca1126cf6f1 100644 --- a/ui/packages/react/src/index.ts +++ b/ui/packages/react/src/index.ts @@ -84,7 +84,7 @@ export type { PredicateGateKind } from './utils/visibilityDiagnostic.js'; // Write-error surfacing utilities (shared by drag-write plugins so a failed // PATCH — e.g. an RLS 403 — is never silently swallowed). -export { extractWriteErrorMessage, isPermissionError, extractFieldErrors, classifyLoadError, declaredUserMessage } from './utils/error-message.js'; +export { extractWriteErrorMessage, isPermissionError, extractFieldErrors, classifyLoadError, declaredUserMessage, delegationScopeMessage } from './utils/error-message.js'; export type { WriteFieldError, LoadErrorKind } from './utils/error-message.js'; // Built-in i18n support diff --git a/ui/packages/react/src/utils/error-message.test.ts b/ui/packages/react/src/utils/error-message.test.ts index cb42006bdc1..61a9a9716ff 100644 --- a/ui/packages/react/src/utils/error-message.test.ts +++ b/ui/packages/react/src/utils/error-message.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest'; import { ApiErrorSchema } from '@objectstack/spec/api'; -import { extractFieldErrors, classifyLoadError, declaredUserMessage } from './error-message'; +import { extractFieldErrors, classifyLoadError, declaredUserMessage, delegationScopeMessage, isPermissionError } from './error-message'; describe('extractFieldErrors', () => { // The wire shape the server actually sends: `@objectstack/rest` serves a @@ -250,3 +250,44 @@ describe('declaredUserMessage', () => { expect(declaredUserMessage(parsedUnmarked)).toBeNull(); }); }); + +// objectstack#26059 Q3: the one denial whose server reason a surface shows. +describe('delegationScopeMessage', () => { + const REASON = + "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')."; + + it("answers the server's reason, without the machine prefix, for a 403 DELEGATION_SCOPE_VIOLATION", () => { + const err = Object.assign(new Error(`[Security] ${REASON}`), { + code: 'DELEGATION_SCOPE_VIOLATION', + httpStatus: 403, + }); + expect(delegationScopeMessage(err)).toBe(REASON); + // The reason it must be asked first: the permission predicate claims it too. + expect(isPermissionError(err)).toBe(true); + }); + + it('reads the code from `details` too, the pair isPermissionError reads', () => { + const err = Object.assign(new Error(`[Security] ${REASON}`), { + httpStatus: 403, + details: { code: 'DELEGATION_SCOPE_VIOLATION' }, + }); + expect(delegationScopeMessage(err)).toBe(REASON); + }); + + it('CONTROL: every other denial answers null, so its text stays withheld', () => { + const denied = (code: string | undefined) => + Object.assign(new Error("[Security] Access denied: delegated 'update' on sys_user_position rejected — no scope."), { + code, + httpStatus: 403, + }); + expect(delegationScopeMessage(denied('PERMISSION_DENIED'))).toBeNull(); + expect(delegationScopeMessage(denied('FORBIDDEN'))).toBeNull(); + expect(delegationScopeMessage(denied(undefined))).toBeNull(); + expect(delegationScopeMessage(new Error('row-level security policy violation'))).toBeNull(); + expect(delegationScopeMessage(null)).toBeNull(); + }); + + it('answers null for a scope refusal with no usable text, so the caller still says something', () => { + expect(delegationScopeMessage({ code: 'DELEGATION_SCOPE_VIOLATION', httpStatus: 403, message: '' })).toBeNull(); + }); +}); diff --git a/ui/packages/react/src/utils/error-message.ts b/ui/packages/react/src/utils/error-message.ts index 1d199cad267..ea1d2fe29ba 100644 --- a/ui/packages/react/src/utils/error-message.ts +++ b/ui/packages/react/src/utils/error-message.ts @@ -22,6 +22,7 @@ */ import { isApiAccessDeniedError } from '@object-ui/data-objectstack'; +import { errorCodeIs } from '@object-ui/types'; import type { ApiError } from '@objectstack/spec/api'; /** @@ -136,6 +137,43 @@ export function declaredUserMessage(err: unknown): string | null { return null; } +/** + * The ADR-0112 code a delegated administrator's write answers when it falls + * outside the caller's OWN `adminScope` (the ledger row registered under + * `@objectstack/plugin-security`, thrown as `DelegationScopeViolationError`). + */ +const DELEGATION_SCOPE_VIOLATION = 'DELEGATION_SCOPE_VIOLATION'; + +/** + * The server's reason for a delegated-admin scope refusal, or `null` when the + * refusal is anything else. + * + * A `403 DELEGATION_SCOPE_VIOLATION` names which boundary of the caller's own + * delegated scope refused the write (a business unit outside the delegated + * subtree, a permission set outside the allowlist, a target user outside the + * subtree). That text describes only the caller's own scope, so it is safe to + * show, and it tells the delegate what to fix (ruling on objectstack#26059, Q3). + * Every other denial stays opaque: `PERMISSION_DENIED`, `FORBIDDEN`, a bare 403 + * or a row-level-security refusal answers `null` here, and callers keep their + * fixed localized line (ruling B′ on objectstack#24914, unchanged). + * + * Callers must ask this BEFORE {@link isPermissionError}: that predicate answers + * true for any 403, so a scope refusal read after it is flattened into the + * plain denial. The code is read from the same pair {@link isPermissionError} + * reads (`code`, then `details.code`); the text is + * {@link extractWriteErrorMessage}'s, without the `[Security] ` machine prefix. + * A scope refusal that carries no usable text answers `null`, so the caller + * still says something. + */ +export function delegationScopeMessage(err: unknown): string | null { + if (!err || typeof err !== 'object') return null; + const details = (err as { details?: unknown }).details; + if (!errorCodeIs(err, DELEGATION_SCOPE_VIOLATION) && !errorCodeIs(details, DELEGATION_SCOPE_VIOLATION)) { + return null; + } + return extractWriteErrorMessage(err); +} + /** One field the server rejected, normalised for `form.setError`. */ export interface WriteFieldError { /** Field name, matching the form control's `name`. */ @@ -198,6 +236,10 @@ export function extractFieldErrors(err: unknown): WriteFieldError[] | null { * `PERMISSION_DENIED` / `FORBIDDEN` / a row-level-security denial). Lets a * caller show a friendly "you don't have permission" toast rather than dumping * the raw server text. + * + * A delegated-admin scope refusal is a 403 too, so this answers true for it: + * a caller that shows that refusal's reason asks + * {@link delegationScopeMessage} first. */ export function isPermissionError(err: unknown): boolean { const e = err as Record | null | undefined; From 19b3be9253ff07cc383b1c44897dc63d344e57b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 23:05:02 +0000 Subject: [PATCH 2/4] chore(sdui): re-record the react and components source digests Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01JZRTRVa5FXXjuAJC6ZPM8P --- scripts/sdui-manifest.sources/components.json | 2 +- scripts/sdui-manifest.sources/react.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/sdui-manifest.sources/components.json b/scripts/sdui-manifest.sources/components.json index abf8b2d57ee..7b20c2ef901 100644 --- a/scripts/sdui-manifest.sources/components.json +++ b/scripts/sdui-manifest.sources/components.json @@ -1 +1 @@ -{"package":"ui/packages/components","sources":226,"sha256":"75ed8c66878430e296c285bb0885304449f091b00da2209889cb99f634ef1690","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/components","sources":226,"sha256":"35f17b033dde32954105b25a8af80f389335b135dcc987d12d341fdd45678d73","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/react.json b/scripts/sdui-manifest.sources/react.json index bfa31928e40..28083648703 100644 --- a/scripts/sdui-manifest.sources/react.json +++ b/scripts/sdui-manifest.sources/react.json @@ -1 +1 @@ -{"package":"ui/packages/react","sources":70,"sha256":"d89393cc7203be3bc88ea59d1a5e0ad6cbda588b8b8ad44baefed0a44da5d9d2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/react","sources":70,"sha256":"dcbc844ad67f19ab5488b86350ff1db45531da2690d66bf23bc53f2f01ffdc0e","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From 2f86b3a3c33d1891cb3ebe3a3e8b64b3574c0944 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 23:36:55 +0000 Subject: [PATCH 3/4] chore(sdui): re-record the react source digest on the merged tree Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01JZRTRVa5FXXjuAJC6ZPM8P --- scripts/sdui-manifest.sources/react.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/react.json b/scripts/sdui-manifest.sources/react.json index 5946a845eba..6cb95c87c7c 100644 --- a/scripts/sdui-manifest.sources/react.json +++ b/scripts/sdui-manifest.sources/react.json @@ -1 +1 @@ -{"package":"ui/packages/react","sources":70,"sha256":"0a4e839e9459da5ce7a6eb9b112d527bf8afecd3f7e8c6f63b936e5eb9593c68","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/react","sources":70,"sha256":"60b4de7edc92acc4811418487733ec67d7d670134917fef55f4280a3871e6b22","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From c786eccb8902d2f0cdd9b5190af6b671a078a0d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:03:47 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(changeset):=20declare=20the=20new=20d?= =?UTF-8?q?elegationScopeMessage=20export=20as=20Clause-=E2=91=A1=20wideni?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01JZRTRVa5FXXjuAJC6ZPM8P --- .changeset/26190-delegation-scope-refusal-reason.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/26190-delegation-scope-refusal-reason.md b/.changeset/26190-delegation-scope-refusal-reason.md index ebae4b08c59..2bb19da62c4 100644 --- a/.changeset/26190-delegation-scope-refusal-reason.md +++ b/.changeset/26190-delegation-scope-refusal-reason.md @@ -6,7 +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 -Clause-②: no +Clause-②: yes (widening: `@object-ui/react` exports a new `delegationScopeMessage`) Metadata-protocol: none 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."