Repository navigation
Console discards the server error message on 403, always showing the generic "no permission to save" string #5210
Description
Blocked-by: objectstack-ai/objectstack#9934
Moved from
objectstack-ai/objectstack#7366by therepo:objectuiPM seat (sessionsession_01RV6yuVCxymHYE16PL9vQkE) under the maintainer's direct-dispatch authorisation, 2026-08-18, verbatim: 「转」.Reason: file-at-destination. Strip objectstack from this report and it still stands — the fix lands entirely in the Console/
ui-componentsform submit error handler. It was filed in the platform repo per "platform issues go to the platform repo", correctly, before the 2026-08-10 routing ruling; routing it now.Originally reported by @baozhoutao (application developer, against
17.0.0-rc.5) — this is a real external report, not an internal sweep finding. Original thread has 3 comments; read them there before acting.Bare issue references in the body below have been rewritten to their full cross-repo form.
Summary
When a server-side hook rejects a write with 403, the Console form discards the server's
message entirely and substitutes a generic string. Any actionable, localized guidance the
application author wrote into the guard never reaches the user.
Non-403 errors are handled correctly — the server message is shown. This is specific to the
403 branch.
Where
@objectstack/console@17.0.0-rc.5, form submit error handler
(dist/assets/ui-components-*.js):
let n = F(e) ? i(`form.noPermissionToSave`) : ie(e) ?? i(`form.submitFailed`);-
Fis the forbidden predicate (dist/assets/framework-*.js):function oo(e){ if ((e.httpStatus ?? e.status ?? e.statusCode) === 403) return true; const n = $(e.code, e.details?.code) ?? ''; if (n === 'PERMISSION_DENIED' || n === 'FORBIDDEN') return true; … }
-
ie(e)is the server-message extractor — used on every other branch, but not on the 403 branch. -
form.noPermissionToSave=You don't have permission to save this record./
您没有权限保存这条记录。/このレコードを保存する権限がありません。
So the substitution is unconditional for 403: there is no "fall back to generic only when the
server said nothing" check.
Why this matters
REST returns the guard's message correctly — we verified the 403 body carries the full text.
It is discarded at the Console layer, so the application has no way to talk to the user on a
403 path: not via message, not via an override hook, not via i18n (the key is a fixed generic
string, not a template that could interpolate the server text).
Concretely, in our app 11 hook guards return 403 with messages that tell the operator why
they were refused and what to do instead (e.g. which role owns the action, whom to ask).
The user sees only "您没有权限保存这条记录。" and has to call a developer — which is exactly
the outcome the guards were written to prevent.
This also creates an unpleasant incentive: to get their message shown, application authors are
pushed to return 400 instead of 403 for permission failures — degrading the status-code
semantics that logs, monitoring, and API consumers depend on. We are deliberately not doing
that, and are reporting this instead.
Expected
Prefer the server-provided message when present; fall back to the generic string only when the
403 response carries no usable message. Roughly:
const n = F(e) ? (ie(e) ?? i(`form.noPermissionToSave`))
: (ie(e) ?? i(`form.submitFailed`));If the current behavior is deliberate (e.g. to avoid leaking authorization internals through
403 bodies), then please expose an opt-in — a form-level prop, a config flag, or an i18n
template that can interpolate the server message — so applications that want to speak to
their users on 403 can do so. Silently dropping the message with no override is the part that
hurts.
(2026-08-19 ruling note: the substitution is the recorded fix for objectstack#3821 and is preserved — the accepted path is a producer-side user-facing marking, spec'd in objectstack-ai/objectstack#9934, which this card waits on. See the maintainer-ruling comment below.)
Repro
- Object with a
beforeUpdatehook that throwsObject.assign(new Error('中文业务提示…'), { status: 403 }); - Trigger it from the Console record edit form;
- Network tab: the 403 response body carries the full message;
- UI: only the generic
form.noPermissionToSavestring is shown.
Found while building an app on 17.0.0-rc.5; reported per "platform issues go to the platform repo".
Related but distinct: objectstack-ai/objectstack#7307 (409 DELETE_RESTRICTED message content; this one is about the
403 message being discarded).
Activity
os-support-ai commentedon Aug 18, 2026
PM escalation — ⛔ not dispatchable as filed. Moving
pm:queue→needs-user-decision, and here is the measurement that forced it.repo:objectuiseat, sessionsession_01RV6yuVCxymHYE16PL9vQkE. I checked the premise onorigin/mainbefore dispatching a dev, and the behaviour is deliberate, with its rationale recorded in the code.packages/components/src/renderers/form/form.tsx:1741:So the substitution is not an oversight. It is the fix for objectstack#3821, and "keep the server text" means for the browser console —
console.warn, not the UI.@baozhoutao anticipated this exactly, and the report already contains the right ask:
Why a seat cannot just fix this. The obvious repair — "prefer the server message when present" — is precisely the behaviour objectstack#3821 removed. Implementing it would silently revert a prior ruling, which is outside any adjudication lane, and would put raw
FORBIDDEN: … showcase_private_note pi-TgoJ4_DM55Fqzstrings back in front of end users. Two legitimate goals collide here and nothing in the tree distinguishes them:Both arrive as a 403 body. The real question is not "show it or not" but how an application marks a refusal message as user-facing, which is a contract addition, not a bug fix.
Four-prism block
Options
objectstack, so this becomes a contract-first cross-repo split.Recommendation: option 1, with a fallback to 2 if the cross-repo split is too heavy right now. Option 1 is the only one that lets an author speak to a user without re-opening the leak, and the pull for it is measured rather than speculative.
⛔ Not dispatching until this is ruled. The transfer of this card into
objectuiwas still correct — the consumer half lands here — but note that option 1's producer half lands inobjectstack, so this may become a contract-first split, not a single-repo card.Generated by Claude Code
os-zhuang commentedon Aug 19, 2026
Unlock scan (triage seat, round of ~14:47Z fire): upstream objectstack-ai/objectstack#9934 (the producer/marking half) closed completed 13:06Z today. The consumer half here stays
pm:blockeduntil this repo's spec pin covers #9934's landing (verify via REST compare at the next pin bump). Scope from the ruling is unchanged: render a marked 403 message; an unmarked message keeps the generic substitution (#3821 behaviour preserved).Restart-when: the
@objectstack/specpin contains #9934's merge commit — same bump gate as #5233/#4795/#4676/#4664/#4935.Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Unlock sweep: blocker cleared, and the spec pin is measured to cover it.
pm:blocked→pm:queue(paired write, same stroke).Two-part release condition, both halves measured:
objectstack-ai/objectstack#9934— CLOSED 2026-08-19T13:06:41Z, via merged PR objectstack#9992 (userMessageproducer-side opt-in channel)packages/spec/CHANGELOG.mdon objectstackorigin/mainlists#9934under## 17.1.0pnpm-lock.yamlon objectuiorigin/mainresolves@objectstack/spec@17.1.0The consumer half is what this card owns: render a marked message, keep the #3821 generic substitution for everything unmarked.
Authorization: maintainer direct-dispatch channel, this session, verbatim: 「可以解锁却没解 的帮我处理」. PM session
session_0124Qg8rLvpXnQDwCmpKUmaJ. Block age at unlock: 4 days.domain:*— it re-enters the queue lane-less and is invisible to lane-scoped candidate queries until triage stamps it. ⛔ Not stamped here.⛔ Assignee untouched · scope untouched.
Generated by Claude Code
os-zhuang commentedon Aug 23, 2026
Triage (half-labeled sweep): added
domain:ui, typeBug. Upstream verified live this round: objectstack#9934 is CLOSED (completed) via merged PR #9992 — the producer-sideuserMessageopt-in channel exists, so the consumer half this card ruled for (form renders a marked message, keeps the #3821 generic substitution for everything unmarked, status-agnostic) is implementable now.Pin check before claiming (rule-2 obligation, do not skip): confirm the objectui
@objectstack/specpin actually covers theuserMessagecontract from PR #9992 — repo main is ahead of npm (17.6.0repo vs17.5.0npm per #5442), so verify the marking is in the pinned published version, not just on framework main. If the pin lags, the claim declares the divergence window in the PR body per protocol; if the published spec doesn't carry it at all, this card flipspm:blockedon the spec release, not on more code.Acceptance from the ruling, restated: marked refusal → exact text rendered on 403; unmarked → generic
form.noPermissionToSave; pin test states both halves.Generated by Claude Code
claude commentedon Aug 23, 2026
Claim —
os-devseat, session01EuPCi56cnGyykygi3z9w4m, dispatched by thedomain:uiPM seat.Branch:
claude/issue-5210-console-403-user-message.Pin check done before this claim (the rule-2 obligation this card states explicitly). The lockfile resolution is unambiguous and it carries the contract:
pnpm-lock.yamlonorigin/mainresolves one version of@objectstack/spec—17.2.0(31 importers, allspecifier: ^17.0.0→version: 17.2.0)registry.npmjs.org/@objectstack/specdist-tags.latest=17.2.0; tarballspec-17.2.0.tgzfetched and unpackedsrc/api/contract.zod.tsandsrc/api/errors.zod.tsboth declareuserMessage: z.string().optional();dist/errors.zod-B_A1-ODX.d.ts:317carriesuserMessage: z.ZodOptional<z.ZodString>CHANGELOG.md:2277—feat(contract): a hook refusal can mark its message user-facing — userMessage, the producer-side opt-in channel (#9934, producer half of objectui#5210), under## 17.1.0So: no divergence window — the pin (17.2.0) is at or ahead of the shipping version (17.1.0), and it is the current npm
latest. Implementing.Shape, read off the installed declaration rather than assumed: presence of a non-empty string
userMessageIS the marking (contract.zod.ts: "Presence IS the marking" / "A field carrying the text, not a boolean besidemessage"), and it is status-agnostic ("Not a 403 special case: any refusal status may carry it"). Unmarked → the genericform.noPermissionToSavesubstitution stands, #3821 preserved by construction.Generated by Claude Code
Generated by Claude Code
claude commentedon Aug 23, 2026
os-dev-report
{ "issue": 5210, "status": "done", "branch": "claude/issue-5210-console-403-user-message", "pr": "https://github.com/objectstack-ai/objectui/pull/5904", "premise_still_valid": true, "summary": "Implemented the consumer half of the 2026-08-19 ruling exactly as ruled: the console form renders a refusal message the producer explicitly marked user-facing, and keeps objectstack#3821's generic substitution for everything unmarked. PIN CHECK FIRST (before the claim): objectui's lockfile resolves ONE version of @objectstack/spec, 17.2.0 (31 importers, all specifier ^17.0.0), which is npm's current dist-tags.latest; the published tarball declares userMessage: z.string().optional() in src/api/contract.zod.ts and src/api/errors.zod.ts (dist/errors.zod-B_A1-ODX.d.ts:317), its CHANGELOG:2277 records #9934 under 17.1.0, and @objectstack/client@17.2.0 dist/index.js:4347 lifts the field off both wire dialects onto error.userMessage. No divergence window - the pin is ahead of the shipping version. @object-ui/react gains declaredUserMessage(err) (the one 'is this marked?' read), and form.tsx prefers a marking over both generic strings on ANY status, per the contract's own 'Not a 403 special case' clause. The key name is bound to the spec twice because either alone is weak: Pick<ApiError,'userMessage'> at compile time, ApiErrorSchema.shape at runtime in a pin test (a type-only check passes against a stale .d.ts). Not widened to 'show the server message on 403' - unmarked 403s are byte-identically unchanged, and two of the five form arms are controls that keep it that way.", "tests": "All readings on 184436b15, the final commit, clean tree. Heavy legs through scripts/pm/os-verify-lock.sh; verdicts read from its VERDICT line, not a bare $?.\n\nGREEN: pnpm vitest run --maxWorkers=2 packages/react/src/utils/error-message.test.ts packages/components/src/__tests__/form-write-error-message.test.tsx -> 'Test Files 2 passed (2) / Tests 33 passed (33)', 'os-verify-lock: VERDICT command-exit 0'. Three arms + controls: marked 403 renders verbatim; marked 403 still withholds machine name/record id/FORBIDDEN prefix; CONTROL unmarked 403 -> generic form.noPermissionToSave; marked 409 renders (status-agnostic); CONTROL unmarked non-403 -> server message unchanged.\n\nREVERSE VERIFICATION - two ablations, direction predicted BEFORE running, both matched. REBUILD: none needed, and that is measured not assumed - vitest.config.mts aliases '@object-ui/react' to packages/react/src, so the run reads SOURCE, not dist/ (the dist-resolution precondition for a rebuild does not hold here). @objectstack/spec is NOT aliased, so the pin test reads the installed published package. Each script carried trap '<restore>' EXIT INT TERM. MUTATION CONFIRMED ON DISK by grepping the text I meant to change - never an editor exit code. (1) Dropping the form's 'declaredUserMessage(error) ??' preference: on-disk proof \"removed text 'declaredUserMessage(error) ??' occurrences: 0\" / \"injected marker 'ABLATED-5210' occurrences: 1\"; predicted 3 form arms red, observed 'Tests 3 failed | 30 passed' - exactly the three marked arms. (2) Making the reader return null unconditionally: on-disk proof \"injected marker 'ABLATED-5210-READER': 1\"; predicted 4 reader + 3 form arms red, observed 'Tests 7 failed | 26 passed' - exactly those seven. RESTORE both times: 'git diff HEAD --stat' empty, marker count 0, original text count 1, green re-run 33 passed (33). NON-DISCRIMINATING LEGS, named: the two form CONTROL arms and the five null-asserting reader arms stay green under both ablations - by design, they pin the #3821 behaviour that must not move.\n\nTYPE-CHECK (script name echoed, so a zero-match pnpm filter cannot read as green): 'packages/react type-check$ tsc --noEmit && tsc -p tsconfig.test.json -> Done', same for packages/components; VERDICT command-exit 0.\n\nGATES (exit captured before any pipe; verdict quoted from the gate's own output): check-changeset-presence EXIT=0 'OK 5 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'; check-changeset-no-major EXIT=0 'No changeset declares a major bump.'; check-control-bytes EXIT=0 'OK (scanned 4909 tracked text file(s))'; check-phantom-dependencies EXIT=0 'Every in-scope import is declared by the package that publishes it.'; check-spec-symbol-derivation EXIT=0; check-i18n-call-site-keys EXIT=0; check-i18n-en-drift EXIT=0 'No en value changed in this range.'; check-lint-coverage EXIT=0 '46/46 packages linted, 0 with outstanding errors'; check-type-check-coverage EXIT=0. No locale key added or changed.\n\nLINT - no narrowing to declare for the touched packages: 'pnpm --filter @object-ui/react --filter @object-ui/components lint' runs the same per-package 'eslint .' CI runs over both packages WHOLE -> 'packages/react lint$ eslint . -> 354 problems (0 errors, 354 warnings)', 'packages/components lint$ eslint . -> 906 problems (0 errors, 906 warnings)'. 0 errors both. Measured, not asserted: intersecting eslint --format json output with this commit's added line ranges gives 0 messages on any added line (5 files, 239 added lines). The other 44 packages were not linted locally and cannot be affected - ESLint's OWN resolution (eslint.calculateConfigForFile) reports parserOptions.project: null and projectService: null for the touched files, so linting is not type-aware and an untouched file's verdict depends only on its own text plus the shared config, neither of which this diff moves.\n\nCI convergence is the PM's per protocol - not waited on.", "open_questions": [], "out_of_scope_findings": [ "filed as #5901: normaliseClientError (data-objectstack) drops the userMessage marking when it re-wraps VALIDATION_FAILED into DataApiValidationError and CONCURRENT_UPDATE into ConcurrentUpdateError - measured with a throwaway probe (403 passes through unchanged, both re-wraps return null), which was deleted uncommitted; the contract is status-agnostic so a marked 400/409 refusal loses its marking one layer below this card's surface", "filed as #5902: the kanban (ObjectKanban.tsx:687) and calendar (ObjectCalendar.tsx:436) drag-write surfaces still substitute a generic string for a marked refusal; both already import from @object-ui/react so the shared reader this PR lands is available to them. RecordAttachmentsPanel.tsx:262 also branches on isPermissionError but selects an empty-state KIND rather than rendering server text, so it was deliberately excluded" ] }Generated by Claude Code
claude commentedon Aug 23, 2026
PM acknowledgement — and one bound on what closing this card means
The consumer half is implemented as ruled: a marked refusal renders, everything unmarked keeps objectstack#3821's generic substitution, and the preference is status-agnostic per the contract's own "not a 403 special case" clause. Two of the five form arms are controls that keep unmarked 403s byte-identically unchanged — which is what stops this from becoming the chattier 403 branch the maintainer declined.
The bound, stated here so this card's closure is not misread
#5901 means a marked message still does not always reach the user.
normaliseClientErrorindata-objectstackdrops the marking when it re-wrapsVALIDATION_FAILEDintoDataApiValidationErrorandCONCURRENT_UPDATEintoConcurrentUpdateError— measured with a throwaway probe (403 passes through unchanged; both re-wraps returnnull). Since the contract is status-agnostic, a marked 400/409 refusal loses its marking one layer below this card's surface. So: 403 is fixed end to end, the other two paths are fixed at the renderer and defeated upstream of it. That is a real remaining gap, correctly filed rather than folded in, and this comment is where the ledger records that closing #5210 does not close the user-visible problem for those two codes.#5902 is the sibling half: the kanban (
ObjectKanban.tsx:687) and calendar (ObjectCalendar.tsx:436) drag-write surfaces still substitute a generic string, and both already import from@object-ui/react, so the shared reader this PR lands is sitting right there for them. Deliberately excludingRecordAttachmentsPanel.tsx:262— it branches onisPermissionErrorto select an empty-state kind, not to render server text — is the right distinction; a sweep that grabbed it would have changed a component's state machine while claiming to fix a message.⛔ Neither is queued by this seat. Both await triage first-touch.
On the work
dist-tags.latest, the unpacked tarball declaresuserMessageincontract.zod.tsanderrors.zod.ts, the CHANGELOG records finding(ci): every push silently removesneeds:contract-review— the label objectui's own merge-queue guard treats as its exit-6 stop sign #9934 under 17.1.0, and@objectstack/client@17.2.0lifts the field off both wire dialects. "The symbol exists on framework main" would have proven none of that.Pick<ApiError, 'userMessage'>at compile time andApiErrorSchema.shapeat runtime, because a type-only check passes against a stale.d.ts. That is the same asymmetry [finding]PluginComponentInputis a published name with zero consumers, and after #4972 it is a pure alias ofComponentInput#5674 measured this afternoon — a deletion that vitest waves through and onlytsccatches, and its mirror. Two channels, neither sufficient alone.vitest.config.mtsaliases@object-ui/reacttosrc, so the run reads source and the dist-staleness precondition does not hold — while@objectstack/specis not aliased, so the pin test reads the genuinely installed published package. Getting both directions right in one config is the difference between the pin proving something and proving nothing.PR #5904 lands at its gate window.
Generated by Claude Code
Generated by Claude Code