Skip to content

fix(plugin-auth): Create User with no email answers the field-level VALIDATION_FAILED refusal - #28354

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-qxiikw
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-qxiikw

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Part of #28289

Clause-②: yes (widening: the refusal gains error.fields, one { field, code, message } entry per input that would satisfy it, code required; its error.code moves from INVALID_REQUEST to VALIDATION_FAILED, the code the same endpoint's permission-set refusal and the data doors already answer with fields)
Metadata-protocol: none

维护者速读

  • 改了什么:设置 → 用户 → 创建用户,邮箱(开启手机号登录时为邮箱与手机号)都不填时,服务端报错从通用的 INVALID_REQUEST 改为字段级的 VALIDATION_FAILED,并带 fields:email(及 phoneNumber)各一条,code: required。英文提示不变。
  • 为什么改:通用错误码让界面只能显示英文句子;字段级信封让界面能标到输入框、按自己的语言措辞(triage「不新增错误码」)。
  • 风险与回滚:只动这一条拒绝;状态码仍 400;其它拒绝(邮箱格式、手机号、权限集、密码长度)原样不变。读 INVALID_REQUEST 判断这条拒绝的客户端会看到新码。回滚即还原本 PR。
  • 先看:packages/plugins/plugin-auth/src/admin-user-endpoints.ts(requiredFieldsRefusal 与缺标识分支)、.changeset/28289-create-user-email-field-refusal.md。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/admin-user-endpoints.test.ts,预期全绿,含两条 VALIDATION_FAILED 用例与四条对照。
  • 需要你做的:无,席位落地。控制台按字段标注并翻译是 ui 车道的后半(见 Acceptance notes),故本 PR 用 Part of。

What changes

plugin-auth admin-user-endpoints.ts, POST /api/v1/auth/admin/create-user: with neither an email nor a phone number, the refusal is the field-level envelope.

  • Before: 400 { code: 'INVALID_REQUEST', message: 'Email is required' }, or "Email or phone number is required" where phone sign-in is on.
  • Now: 400 { code: 'VALIDATION_FAILED', message, fields }. fields is [{ field: 'email', code: 'required', message }], plus { field: 'phoneNumber', code: 'required', message } where phone sign-in is on. The English message is unchanged.
  • Built by a new private requiredFieldsRefusal(message, fields) beside badRequest(). Its entry code required is checked against FieldErrorCode (the closed ADR-0114 catalog, @objectstack/spec/api) with satisfies, so no code is invented. The code is written in the entry literal, not passed as a parameter: as a parameter, check:dispatcher-error-vocabulary read it as a stamped ADR-0112 code (objlithelper shape) and went red; fixed in f2bc72d.
  • Every other refusal of the endpoint still goes through badRequest() and answers INVALID_REQUEST as before.

Which field envelope, measured (the triage named VALIDATION_ERROR with issues[])

The order said to use the field-level shape that already exists for this door, and not to invent a second one. At origin/main b339465:

  • The spec declares no issues list on the error envelope. Its per-field list is fields (EnhancedApiErrorSchema.fields, entries FieldErrorSchema, in packages/spec/src/api/errors.zod.ts; ADR-0114 D4 renamed fieldErrors to fields because fields is what the wire carries). On the wire, issues is a thrown error's raw Zod issues passed through (@objectstack/rest error-response.ts, one batch-routes.ts refusal, the metadata save door that the console's metadata editors read). It is not the record/form field shape.
  • This same endpoint already answers that envelope: grantRefusal() passes a permission-set refusal through as VALIDATION_FAILED with error.fields (EndpointResult.body.error.fields). VALIDATION_FAILED is in the error-code ledger under @objectstack/plugin-auth, so this adds no ledger row.
  • The data doors answer the same pair: @objectstack/rest crud-routes.ts and form-routes.ts (validationFailure, 400 VALIDATION_FAILED with fields[]).
  • The console's field-error reader, extractFieldErrors (ui/packages/react/src/utils/error-message.ts), reads error.fields, error.details.fields or validationErrors, whatever the code.

So the refusal uses VALIDATION_FAILED + error.fields, the shape of the permission-set refusal one branch below it.

Tests

  • admin-user-endpoints.test.ts: the Create User with Email empty says "Either email or phoneNumber is required" and the dialog marks nothing required (#27521 row 10) #27857 pair (phone sign-in off and on) now asserts 400, VALIDATION_FAILED, the unchanged English message, and fields exactly. Four new CONTROL cases pin that the refusals beside it keep { code: 'INVALID_REQUEST', message } with no fields: a malformed email, a phone number with phone sign-in off, a malformed phone number, a permissionSet that is not a name. A valid Create User is pinned by the suite's existing success cases.
  • request-json-body-refusal.test.ts (the real Hono route): the three empty-body controls for /admin/create-user now expect VALIDATION_FAILED and fields: [{ field: 'email', code: 'required', message: 'Email is required' }], through new optional emptyCode / emptyFields keys on the route table. /admin/set-user-password and every other route keep INVALID_REQUEST.
Red first, on the base implementation

git restore --source=b3394651 -- packages/plugins/plugin-auth/src/admin-user-endpoints.ts with the new tests, then vitest run src/admin-user-endpoints.test.ts src/request-json-body-refusal.test.ts: Test Files 2 failed (2), Tests 5 failed, 165 passed (170). The 5 are the two VALIDATION_FAILED cases (expected 'INVALID_REQUEST' to be 'VALIDATION_FAILED') and the three real-route empty-body cases. The four controls stayed green. Restored with git checkout HEAD --; git diff HEAD empty, blob equal to HEAD.

Green, and the ablation
  • pnpm --filter @objectstack/plugin-auth test (head f2bc72d): Test Files 205 passed (205), Tests 3921 passed, 10 skipped (3931). Lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-auth typecheck (head f2bc72d, after building the package itself, which tsconfig.examples.json resolves): exit 0; check:test-typecheck OK, the debt ledger unchanged.
  • Ablation through scripts/ablation-replace.mjs on head f2bc72d: the requiredFieldsRefusal(...) return replaced by return badRequest(message); (anchor 1 to 0, blob 1715c5359286 to f25398fd46e8) reds the same 5 cases, 165 passed. Restored by the tool, blob after restore 1715c5359286 equal to HEAD, git diff HEAD empty. (The same ablation on the first head, 0c1a502, read the same 5.) The subject is the package's own src/, imported by relative path, so no build is in the path.
  • eslint --no-inline-config packages/plugins/plugin-auth: exit 0.

Acceptance notes

  • Why Part of. The console does not yet use the new fields. Create User is the create_user api action on sys_user. A refused run reaches useConsoleActionRuntime's refused(), then apiActionFailureMessage, then actionErrorDetail, which reads only error.message. ActionParamDialog shows that text above the buttons ("ActionResult carries the refusal as text only"). So today the dialog shows the same English sentence as before. The per-field mark and its translation are the ui lane's half:
    Seam: spec:EnhancedApiErrorSchema.fields → renderer:ActionParamDialog (ui/packages/app-shell/src/views/ActionParamDialog.tsx; useConsoleActionRuntime.tsx refused() → flowResponse.ts apiActionFailureMessage → actionErrorDetail reads error.message only).
  • Who words the field message. FieldErrorSchema.message is described as rendered in the caller's locale. This door renders English only, like the rest of plugin-auth's refusals. Two routes for the translated sentence: the dialog words code: 'required' from its own language pack (it already words its client-side required check), or the server renders required through renderValidationMessage (@objectstack/spec/system) in the caller's locale, which would need this door to read the request locale and the translated field label. Left to the ui-lane card; the report recommends the first.
  • Same family, not changed here. "A valid email is required" and the two phone-number refusals are also about one input and still answer INVALID_REQUEST (pinned as controls, as the order asked). They could take invalid_email / invalid_phone entries the same way.
  • The earlier changeset. .changeset/27857-create-user-email-refusal.md (from fix(plugin-auth): Create User with no email names Email, and a wrong password on the password-change screen reads in the user's language #28288, unreleased) says the code INVALID_REQUEST is unchanged. This PR does not edit it; its own changeset carries a line that supersedes it.
  • Clause-② is declared as measured. The claim's line said yes was expected; request-json-body-refusal.test.ts read INVALID_REQUEST for this refusal, so the published answer moved and a contract review is owed.

Verification

  • Derived gate list, head f2bc72d: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack named 62 commands; each run with its exit code captured before any pipe. All 62 exit 0, including check:dual-build-cjs-loads (113 entry points across 70 packages load) and check:dispatcher-error-vocabulary (OK, 56 sites all classified). --ran reconciliation: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN.
  • On the first head, check:dispatcher-error-vocabulary went red ([unclassified-site] … stamps unregistered code 'required' (objlithelper)), because the helper took the field code as a parameter. f2bc72d writes code: 'required' in the entry literal; the gate is green and no ledger row or classification row was added.
  • eslint --no-inline-config packages/plugins/plugin-auth: exit 0.
  • Overlap: the open pull requests read before opening this one touch no file under packages/plugins/plugin-auth/src/ by title; fix(plugin-auth): Create User with no email names Email, and a wrong password on the password-change screen reads in the user's language #28288, the last change to admin-user-endpoints.ts, is merged and is this branch's base.
  • Not run locally: test:repo (CI Test Core), the dogfood battery (the title carries no ! and Clause-② has no (narrowing) arm).

Generated by Claude Code

…level (red first)

Claude-Session: https://claude.ai/code/session_01FEkQ5n7KCBaXqGnnMcHsiD
Co-authored-by: Claude <noreply@anthropic.com>
… VALIDATION_FAILED refusal

Claude-Session: https://claude.ai/code/session_01FEkQ5n7KCBaXqGnnMcHsiD
Co-authored-by: Claude <noreply@anthropic.com>
… place, not as a parameter

check:dispatcher-error-vocabulary read a code parameter in the entry literal
as a stamped ADR-0112 code.

Claude-Session: https://claude.ai/code/session_01FEkQ5n7KCBaXqGnnMcHsiD
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Oct 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 4 documentable anchor(s).

31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 488b3ed092796136551c36a5eb6d143c44a2b878.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 59 of 211 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 488b3ed092796136551c36a5eb6d143c44a2b878 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b3afeb010df9c7d48c28aeb4adc358a2550bce55 — the merge of head f2bc72d5a0d511039a58611ac5e4af3bc68bcc23 into base 488b3ed092796136551c36a5eb6d143c44a2b878, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b3afeb010df9c7d48c28aeb4adc358a2550bce55 && git checkout b3afeb010df9c7d48c28aeb4adc358a2550bce55
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 488b3ed092796136551c36a5eb6d143c44a2b878 f2bc72d5a0d511039a58611ac5e4af3bc68bcc23 && git checkout -B drift-repro 488b3ed092796136551c36a5eb6d143c44a2b878 && git merge --no-ff f2bc72d5a0d511039a58611ac5e4af3bc68bcc23

node scripts/docs-audit/affected-docs.mjs --json 488b3ed092796136551c36a5eb6d143c44a2b878

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 488b3ed092796136551c36a5eb6d143c44a2b878 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

Contract review

Served-tier: TIER_DEFAULT
Head-sha: f2bc72d5a0d511039a58611ac5e4af3bc68bcc23
Local-runs: none

The review is owed by the Clause-②: yes declaration alone (declaration face). No packages/spec/src/** path and no governed path are in the diff, and the PR narrows nothing. It was done in-seat at the default tier.

① Derived judgments

  • Accept set. Unchanged. The same request (Create User with neither email nor phone) is refused 400 before and after, and every other input answers as before. Nothing is newly accepted or refused.
  • Public face, the refusal's wire answer. It changes for this one refusal: error.code goes from INVALID_REQUEST to VALIDATION_FAILED, and error.fields is added with one { field, code: 'required', message } entry per input that would satisfy it (email, plus phoneNumber where phone sign-in is on). Correct:
    • VALIDATION_FAILED is already in the error-code ledger, and it is the code that carries fields[] (Field-level error codes are four vocabularies with no schema — needs its own catalog (ADR-0112 D6 follow-up) #3977). This endpoint already answers it, with fields, for a refused permission set (grantRefusal). No code and no ledger row is added, which honours triage's "no new code".
    • required is a member of the closed FieldErrorCode catalog (ADR-0114 D2), and the satisfies check pins that at compile time.
    • The envelope is the spec's existing per-field list. No second shape is invented.
  • A departure from the order's wording, measured. The order and claim named VALIDATION_ERROR with issues[]. The dev measured that no door answers that shape, and used the shape this endpoint and the data doors do answer. That is the correct reading of triage's intent: use the envelope the console already maps per field, with no new code.
  • Controls. The malformed-email refusal, both phone refusals, the permissionSet refusal and the password rules keep INVALID_REQUEST and their messages, and they are pinned. A valid Create User still succeeds.
  • Readers of the old code. request-json-body-refusal.test.ts read INVALID_REQUEST for this refusal and is updated in place with the new code and fields. The console reads error.message only (actionErrorDetail), and the message is unchanged, so what the console shows today does not move.

② Semver level

③ Boundary flags

  • open_questions[0] (informational): who words the per-field message in the user's language. The answer is A, the console, as the dev recommends. The console already holds the translated "required" sentence and the field label for the dialog it draws, and the wire stays one shape. It does not block this PR, and it rides with the console half.
  • Seam (ui lane). ActionParamDialog and actionErrorDetail read error.message only, so the Email input is not marked yet. The PR therefore says Part of #28289, which is correct. The card carries the console half.
  • Noted, not filed: the malformed-email and phone refusals could take fields entries the same way. That is out of this card's scope; recorded only.
  • Author: the PR's user.login is objectstack-fleet[bot].

Implemented-by: claude/project-thread-qxiikw
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-31T06:23Z · head f2bc72d5 · #28289

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main with no assignees and a patch changeset for @objectstack/plugin-auth.
    • It says Part of #28289, because the console half (marking the Email input from fields) is a ui-lane Seam the card keeps.
    • check-governed-merges --pr 28354 reads NOT governed, 95 changed lines.
    • The claim (6539231571) names this PR's branch, claude/project-thread-qxiikw.
  2. Against the order:
    • No new code, no ledger row. The missing-identifier refusal now answers VALIDATION_FAILED with error.fields, which is the shape this endpoint already answers for a refused permission set. The English message is unchanged.
    • The order's VALIDATION_ERROR + issues[] wording was measured and departed from. No door answers that shape. This is triage's intent, read correctly, as the contract review records.
    • Controls hold. Every other refusal of the endpoint keeps INVALID_REQUEST.
    • Red first, then ablation. 5 cases red on base and 5 red under ablation. All green at head.
    • Foreign changeset untouched. The supersede line for Create User with Email empty says "Either email or phoneNumber is required" and the dialog marks nothing required (#27521 row 10) #27857's unreleased changeset is in this PR's own changeset.
  3. Contract review: owed for Clause-②: yes at TIER_DEFAULT. PASS on this head, 6539927716.
  4. Gates:
    • Locally, the dev ran 62 derived families with 0 NOT-MEASURED, every one exit 0. plugin-auth passed 205 of 205 test files, and typecheck and eslint pass.
    • CI on head f2bc72d5: 35 success, 13 skipped, 0 red. The state is clean.
  5. Overlap: no other open PR touches admin-user-endpoints.ts or request-json-body-refusal.test.ts. git merge-tree reports no conflict against main 92120357.
  6. After landing:
    • The landing record goes on the card, and the claim is released.
    • The remaining half is ui-lane work, so the card goes back to pm:queue for triage to route.
    • The dev's informational question, who words the translated message, is answered A (the console) in the contract review.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 31, 2026 06:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 31, 2026 06:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit f048e42 Oct 31, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-qxiikw branch October 31, 2026 06:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant