From 61d8f1f47b7be3a4a8a0758e302f60ba40256e47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:56:32 +0000 Subject: [PATCH 1/4] test(plugin-auth): Create User's missing-identifier refusal is field-level (red first) Claude-Session: https://claude.ai/code/session_01FEkQ5n7KCBaXqGnnMcHsiD Co-authored-by: Claude --- .../src/admin-user-endpoints.test.ts | 34 +++++++++++++++++-- .../src/request-json-body-refusal.test.ts | 13 +++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index 6e67eeb4a9b..56edda02d23 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -269,15 +269,43 @@ describe('runAdminCreateUser', () => { // #27857: the refusal names what the dialog shows. `phoneNumber` is a body // key, and the dialog has a Phone Number field only where phone sign-in is - // on. The code and status are the endpoint's contract and do not move. + // on. #28289: it is the field-level refusal, `VALIDATION_FAILED` with one + // `fields[]` entry per input that can satisfy it, code `required`, so a + // client can mark the input and word it in its own language. for (const phone of [false, true]) { const expected = phone ? 'Email or phone number is required' : 'Email is required'; - it(`phone sign-in ${phone ? 'on' : 'off'}: neither email nor phone answers 400 INVALID_REQUEST "${expected}"`, async () => { + const fields = phone + ? [ + { field: 'email', code: 'required', message: expected }, + { field: 'phoneNumber', code: 'required', message: expected }, + ] + : [{ field: 'email', code: 'required', message: expected }]; + it(`phone sign-in ${phone ? 'on' : 'off'}: neither email nor phone answers 400 VALIDATION_FAILED "${expected}" at ${fields.map((f) => f.field).join(' + ')}`, async () => { const m = makeDeps({ phoneNumberEnabled: () => phone }); const res = await runAdminCreateUser(m.deps, makeRequest({ name: 'x', generatePassword: true }), ACTOR); expect(res.status).toBe(400); - expect(res.body.error?.code).toBe('INVALID_REQUEST'); + expect(res.body.error?.code).toBe('VALIDATION_FAILED'); expect(res.body.error?.message).toBe(expected); + expect(res.body.error?.fields).toEqual(fields); + expect(m.createUser).not.toHaveBeenCalled(); + }); + } + + // #28289 CONTROLS: the refusals beside it keep their answer exactly. + for (const [shape, body, phone, message] of [ + ['a malformed email', { email: 'not-an-email' }, false, 'A valid email is required'], + ['a phone number with phone sign-in off', { phoneNumber: '+8613800000000' }, false, + 'Phone numbers require the phoneNumber auth plugin (auth.plugins.phoneNumber)'], + ['a malformed phone number', { phoneNumber: 'abc' }, true, + 'phoneNumber must be a valid phone number (E.164 recommended, e.g. +8613800000000)'], + ['a permissionSet that is not a name', { email: 'a@b.co', permissionSet: 7 }, false, + 'permissionSet must be the machine name of a permission set'], + ] as const) { + it(`CONTROL: ${shape} still answers 400 INVALID_REQUEST with no fields`, async () => { + const m = makeDeps({ phoneNumberEnabled: () => phone }); + const res = await runAdminCreateUser(m.deps, makeRequest({ ...body, generatePassword: true }), ACTOR); + expect(res.status).toBe(400); + expect(res.body.error).toEqual({ code: 'INVALID_REQUEST', message }); expect(m.createUser).not.toHaveBeenCalled(); }); } diff --git a/packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts b/packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts index 359a7c65980..212e6b4398e 100644 --- a/packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts +++ b/packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts @@ -380,6 +380,10 @@ interface SecondSpellingRoute { truncated: string; echo: string; empty: string; + /** The empty body's code, where it is not `INVALID_REQUEST` (#28289: Create User's field-level refusal). */ + emptyCode?: string; + /** The empty body's `error.fields`, where the refusal is field-level. */ + emptyFields?: unknown[]; /** `admin-set-user-manager.ts` discriminates its refusals by `error.details.reason`. */ reason?: string; } @@ -407,7 +411,8 @@ const SECOND_SPELLING: Array<{ reader: string; routes: SecondSpellingRoute[] }> { reader: 'G. admin-user-endpoints.ts parseJson', routes: [ - { path: '/admin/create-user', truncated: '{"email": "pin-new@example.test", "name": "Pin New"', echo: 'pin-new@example.test', empty: 'Email is required' }, + { path: '/admin/create-user', truncated: '{"email": "pin-new@example.test", "name": "Pin New"', echo: 'pin-new@example.test', empty: 'Email is required', + emptyCode: 'VALIDATION_FAILED', emptyFields: [{ field: 'email', code: 'required', message: 'Email is required' }] }, { path: '/admin/set-user-password', truncated: '{"userId": "usr_pin_target", "newPassword": "Pin-Passw0rd!"', echo: 'usr_pin_target', empty: 'userId is required' }, ], }, @@ -445,7 +450,11 @@ for (const { reader, routes } of SECOND_SPELLING) { const peek = (await res.clone().json()) as { error?: { details?: { reason?: string } } }; expect(peek.error?.details?.reason).toBe(route.reason); } - await expectAnswer(res, 400, 'INVALID_REQUEST', route.empty); + if (route.emptyFields) { + const peek = (await res.clone().json()) as { error?: { fields?: unknown[] } }; + expect(peek.error?.fields).toEqual(route.emptyFields); + } + await expectAnswer(res, 400, route.emptyCode ?? 'INVALID_REQUEST', route.empty); nothingActed(); }); } From 0c1a502f3a5f3682a98728edc045b41b0d786b23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:57:05 +0000 Subject: [PATCH 2/4] fix(plugin-auth): Create User's missing email answers the field-level VALIDATION_FAILED refusal Claude-Session: https://claude.ai/code/session_01FEkQ5n7KCBaXqGnnMcHsiD Co-authored-by: Claude --- .../plugin-auth/src/admin-user-endpoints.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index c5a27a7c252..2dd661887dd 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -234,6 +234,7 @@ import { cloudManagedRefusal } from './cloud-managed.js'; import { temporaryPasswordLength } from './temporary-password-length.js'; import { isCloudManaged } from '@objectstack/core'; import { resolveThrownHttpError, validationFailureDetails } from '@objectstack/types'; +import type { FieldErrorCode } from '@objectstack/spec/api'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; @@ -295,6 +296,22 @@ function badRequest(message: string): EndpointResult { return { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message } } }; } +/** + * [#28289] A refusal about named inputs: `400 VALIDATION_FAILED` with one + * `fields[]` entry per input, the same envelope the grant refusal passes + * through and the data doors answer, so the console's field-error reader + * needs no case for this door. + */ +function fieldRefusal(message: string, fields: string[], code: FieldErrorCode): EndpointResult { + return { + status: 400, + body: { + success: false, + error: { code: 'VALIDATION_FAILED', message, fields: fields.map((field) => ({ field, code, message })) }, + }, + }; +} + /** * Linear-time email plausibility check (local@domain with a dotted domain). * Deliberately not a regex: the obvious `\S+@\S+\.\S+` is polynomially @@ -696,9 +713,13 @@ export async function runAdminCreateUser( if (!hasEmail && !phoneNumber) { // Named in the dialog's words, not the body's keys: an administrator sees // an Email field, and a Phone Number field only where phone sign-in is on. - return badRequest( - deps.phoneNumberEnabled?.() ? 'Email or phone number is required' : 'Email is required', - ); + // [#28289] Field-level, as the grant refusal below is: `VALIDATION_FAILED` + // with one `fields[]` entry per input that would satisfy it, code + // `required` (the closed ADR-0114 catalog), so a client marks the input + // and words it in its own language instead of showing this sentence. + const phoneSignIn = deps.phoneNumberEnabled?.() === true; + const message = phoneSignIn ? 'Email or phone number is required' : 'Email is required'; + return fieldRefusal(message, phoneSignIn ? ['email', 'phoneNumber'] : ['email'], 'required'); } const email = hasEmail ? (rawEmail as string).trim() : generatePlaceholderEmail(); const name = From 2421947fc715a92a49e313c5ae343207db38b85e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:58:04 +0000 Subject: [PATCH 3/4] chore(changeset): plugin-auth Create User field-level refusal Claude-Session: https://claude.ai/code/session_01FEkQ5n7KCBaXqGnnMcHsiD Co-authored-by: Claude --- .../28289-create-user-email-field-refusal.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/28289-create-user-email-field-refusal.md diff --git a/.changeset/28289-create-user-email-field-refusal.md b/.changeset/28289-create-user-email-field-refusal.md new file mode 100644 index 00000000000..d84b02658bb --- /dev/null +++ b/.changeset/28289-create-user-email-field-refusal.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth): Create User with no email answers the field-level refusal, so a client can mark the Email input and word it in its own language (#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 + + + +**BREAKING** wire answer of one refusal at `POST /api/v1/auth/admin/create-user` (`@objectstack/plugin-auth`). + +- **Before.** With neither an email nor a phone number, the endpoint answered `400` `{ code: 'INVALID_REQUEST', message: 'Email is required' }` ("Email or phone number is required" where phone sign-in is on). The code is the endpoint's generic one, so a client could only show the English sentence. +- **Now.** The same `400` answers `{ code: 'VALIDATION_FAILED', message, fields }`. `fields` holds `{ field: 'email', code: 'required', message }`, plus `{ field: 'phoneNumber', code: 'required', message }` where phone sign-in is on. The English message is unchanged. +- **Unchanged.** Every other refusal of the endpoint keeps `INVALID_REQUEST` and its message: a malformed email, a phone number without phone sign-in, a malformed phone number, a `permissionSet` that is not a name, the password rules. A valid Create User still succeeds. +- **Supersedes** the line "The status (400) and code (`INVALID_REQUEST`) are unchanged" in this release's earlier Create User changeset: the status is still 400, and the code is now `VALIDATION_FAILED`. +- **Who sees it.** A client that read `INVALID_REQUEST` for this one refusal. The console shows the refusal's message as before; marking the Email input from `fields` is the console's own follow-up. From f2bc72d5a0d511039a58611ac5e4af3bc68bcc23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:22:24 +0000 Subject: [PATCH 4/4] fix(plugin-auth): the required-fields refusal names its field code in 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 --- .../plugin-auth/src/admin-user-endpoints.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index 2dd661887dd..1715c535928 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -297,17 +297,21 @@ function badRequest(message: string): EndpointResult { } /** - * [#28289] A refusal about named inputs: `400 VALIDATION_FAILED` with one - * `fields[]` entry per input, the same envelope the grant refusal passes - * through and the data doors answer, so the console's field-error reader - * needs no case for this door. + * [#28289] A refusal for inputs left empty: `400 VALIDATION_FAILED` with one + * `fields[]` entry per input, code `required` (the closed ADR-0114 catalog) — + * the envelope the grant refusal passes through and the data doors answer, so + * the console's field-error reader needs no case for this door. */ -function fieldRefusal(message: string, fields: string[], code: FieldErrorCode): EndpointResult { +function requiredFieldsRefusal(message: string, fields: string[]): EndpointResult { return { status: 400, body: { success: false, - error: { code: 'VALIDATION_FAILED', message, fields: fields.map((field) => ({ field, code, message })) }, + error: { + code: 'VALIDATION_FAILED', + message, + fields: fields.map((field) => ({ field, code: 'required' satisfies FieldErrorCode, message })), + }, }, }; } @@ -713,13 +717,12 @@ export async function runAdminCreateUser( if (!hasEmail && !phoneNumber) { // Named in the dialog's words, not the body's keys: an administrator sees // an Email field, and a Phone Number field only where phone sign-in is on. - // [#28289] Field-level, as the grant refusal below is: `VALIDATION_FAILED` - // with one `fields[]` entry per input that would satisfy it, code - // `required` (the closed ADR-0114 catalog), so a client marks the input - // and words it in its own language instead of showing this sentence. + // [#28289] Field-level, as the grant refusal below is: one `fields[]` + // entry per input that would satisfy it, so a client marks the input and + // words it in its own language instead of showing this sentence. const phoneSignIn = deps.phoneNumberEnabled?.() === true; const message = phoneSignIn ? 'Email or phone number is required' : 'Email is required'; - return fieldRefusal(message, phoneSignIn ? ['email', 'phoneNumber'] : ['email'], 'required'); + return requiredFieldsRefusal(message, phoneSignIn ? ['email', 'phoneNumber'] : ['email']); } const email = hasEmail ? (rawEmail as string).trim() : generatePlaceholderEmail(); const name =