Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Console discards the server error message on 403, always showing the generic "no permission to save" string #5210

Closed

Description

@os-support-ai

Blocked-by: objectstack-ai/objectstack#9934

Moved from objectstack-ai/objectstack#7366 by the repo:objectui PM seat (session session_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-components form 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`);
  • F is 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

  1. Object with a beforeUpdate hook that throws Object.assign(new Error('中文业务提示…'), { status: 403 });
  2. Trigger it from the Console record edit form;
  3. Network tab: the 403 response body carries the full message;
  4. UI: only the generic form.noPermissionToSave string 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

  1. os-support-ai commented on Aug 18, 2026

    @os-support-ai
    CollaboratorAuthor

    PM escalation — ⛔ not dispatchable as filed. Moving pm:queue → needs-user-decision, and here is the measurement that forced it.

    repo:objectui seat, session session_01RV6yuVCxymHYE16PL9vQkE. I checked the premise on origin/main before dispatching a dev, and the behaviour is deliberate, with its rationale recorded in the code. packages/components/src/renderers/form/form.tsx:1741:

    // A rejected write used to be rendered verbatim, which put raw server
    // diagnostics in front of end users — untranslated, and leaking the
    // object's machine name and the record id, e.g.
    // "FORBIDDEN: insufficient privileges to update showcase_private_note
    // pi-TgoJ4_DM55Fqz" (objectstack#3821). A permission denial is a
    // condition the UI already knows how to name, so say it in the user's
    // language and keep the server text for the console.
    const errorMessage = isPermissionError(error)
      ? t('form.noPermissionToSave')
      : extractWriteErrorMessage(error) ?? t('form.submitFailed');
    if (isPermissionError(error) && typeof console !== 'undefined') {
      console.warn('[form] write denied:', error);
    }

    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:

    If the current behavior is deliberate (e.g. to avoid leaking authorization internals through 403 bodies), then please expose an opt-in … Silently dropping the message with no override is the part that hurts.

    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_DM55Fqz strings back in front of end users. Two legitimate goals collide here and nothing in the tree distinguishes them:

    • a platform diagnostic (machine names, record ids, privilege internals) — must not reach an end user;
    • an author-written guard message ("this needs finance approval — ask your manager") — is written for the end user, and today cannot reach 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

    1. Platform long-term coherence — the platform currently has no way to distinguish author-authored guidance from platform diagnostics on an error path. Whatever is chosen should add that distinction once, rather than special-casing 403 in the form renderer. A blanket "prefer server message" re-opens objectstack#3821 for every error path, not just this one.
    2. Measured business pull — real and external. An application developer reports 11 hook guards whose messages cannot reach users, and is deliberately not taking the available workaround (returning 400 instead of 403) because it degrades status-code semantics for logs, monitoring and API consumers. That is a user paying a real cost to avoid corrupting the platform's contract — the strongest kind of pull, and it argues for answering rather than deferring.
    3. AI-agent error-resistance — an opt-in that is explicit at the producer side (the guard marks its message user-safe) is far safer than a consumer-side heuristic guessing whether a 403 body is presentable. A heuristic is exactly how internals leak back out.
    4. Startup scope discipline — the cheapest honest option is 3 below (fix the message only). The most correct is 1. Option 4 (do nothing) has a real, named user bearing the cost, so it is weaker here than the usual "no pull" case.

    Options

    1. A producer-side opt-in — hooks mark a refusal message as user-facing (a flag, a dedicated field, or a designated error code), and the form renders it when marked and falls back to the generic string otherwise. Honours objectstack#3821 fully; costs a contract addition, and the platform half lands in objectstack, so this becomes a contract-first cross-repo split.
    2. A consumer-side opt-in — a form-level prop or config flag letting an application opt into showing raw 403 text. Cheapest to build here, entirely in this repo; but it is per-application and all-or-nothing, so an app that enables it also re-exposes raw platform diagnostics.
    3. Improve only the message — keep the substitution, but make the generic string actionable, or interpolate a server-provided safe subset. Smallest change, no contract movement; does not actually deliver the reporter's 11 guard messages.
    4. Rule it working-as-intended — objectstack#3821 stands, applications route user guidance through some other channel. Should be said explicitly to the reporter rather than left as silence.

    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 objectui was still correct — the consumer half lands here — but note that option 1's producer half lands in objectstack, so this may become a contract-first split, not a single-repo card.


    Generated by Claude Code

  2. os-zhuang commented on Aug 19, 2026

    @os-zhuang
    Contributor

    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:blocked until 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/spec pin contains #9934's merge commit — same bump gate as #5233/#4795/#4676/#4664/#4935.


    Generated by Claude Code

  3. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    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:

    half reading
    blocker lands objectstack-ai/objectstack#9934 — CLOSED 2026-08-19T13:06:41Z, via merged PR objectstack#9992 (userMessage producer-side opt-in channel)
    shipped in a released spec packages/spec/CHANGELOG.md on objectstack origin/main lists #9934 under ## 17.1.0
    objectui's pin carries it pnpm-lock.yaml on objectui origin/main resolves @objectstack/spec@17.1.0

    The 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.

    ⚠️ The premise was NOT re-verified — that is the claiming seat's first step. In particular, confirm the shipped marking's actual shape (the upstream card left "flag, field, or error-code family" as its own design work) before writing the render branch.

    ⚠️ This card carries no 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

  4. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 23, 2026
  5. added theissue type on Aug 23, 2026
  6. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    Triage (half-labeled sweep): added domain:ui, type Bug. Upstream verified live this round: objectstack#9934 is CLOSED (completed) via merged PR #9992 — the producer-side userMessage opt-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/spec pin actually covers the userMessage contract from PR #9992 — repo main is ahead of npm (17.6.0 repo vs 17.5.0 npm 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 flips pm:blocked on 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

  7. claude commented on Aug 23, 2026

    @claude
    Contributor

    Claim — os-dev seat, session 01EuPCi56cnGyykygi3z9w4m, dispatched by the domain:ui PM 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:

    reading measurement
    objectui pin pnpm-lock.yaml on origin/main resolves one version of @objectstack/spec — 17.2.0 (31 importers, all specifier: ^17.0.0 → version: 17.2.0)
    that version is published registry.npmjs.org/@objectstack/spec dist-tags.latest = 17.2.0; tarball spec-17.2.0.tgz fetched and unpacked
    the published tarball carries the marking src/api/contract.zod.ts and src/api/errors.zod.ts both declare userMessage: z.string().optional(); dist/errors.zod-B_A1-ODX.d.ts:317 carries userMessage: z.ZodOptional<z.ZodString>
    the published CHANGELOG records it 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.0

    So: 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 userMessage IS the marking (contract.zod.ts: "Presence IS the marking" / "A field carrying the text, not a boolean beside message"), and it is status-agnostic ("Not a 403 special case: any refusal status may carry it"). Unmarked → the generic form.noPermissionToSave substitution stands, #3821 preserved by construction.


    Generated by Claude Code


    Generated by Claude Code

  8. claude commented on Aug 23, 2026

    @claude
    Contributor

    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

  9. claude commented on Aug 23, 2026

    @claude
    Contributor

    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. normaliseClientError in data-objectstack drops the 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). 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 excluding RecordAttachmentsPanel.tsx:262 — it branches on isPermissionError to 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

    • The pin was measured through to the published artifact, not asserted: one resolved version in the lockfile (17.2.0, 31 importers), that version is npm's dist-tags.latest, the unpacked tarball declares userMessage in contract.zod.ts and errors.zod.ts, the CHANGELOG records finding(ci): every push silently removes needs: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.0 lifts the field off both wire dialects. "The symbol exists on framework main" would have proven none of that.
    • The key name is bound to the spec twice, and the reason is the right one: Pick<ApiError, 'userMessage'> at compile time and ApiErrorSchema.shape at runtime, because a type-only check passes against a stale .d.ts. That is the same asymmetry [finding] PluginComponentInput is a published name with zero consumers, and after #4972 it is a pure alias of ComponentInput #5674 measured this afternoon — a deletion that vitest waves through and only tsc catches, and its mirror. Two channels, neither sufficient alone.
    • The no-rebuild claim is measured, not assumed: vitest.config.mts aliases @object-ui/react to src, so the run reads source and the dist-staleness precondition does not hold — while @objectstack/spec is 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.
    • The lint claim is not a narrowing at all and says so: both touched packages were linted whole, exactly as CI does, with 0 errors, plus an intersection of the JSON output against this commit's added line ranges showing 0 messages on any added line.

    PR #5904 lands at its gate window.


    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:queue

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions