From 1ea5748849fc75a2bfa46d988e12119c9e33aace Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:34:02 +0000 Subject: [PATCH 1/5] test(platform-objects): Create User email required mark per phone sign-in (red first) Co-Authored-By: Claude --- ...ys-user-create-user-email-required.test.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 packages/platform-objects/src/identity/sys-user-create-user-email-required.test.ts diff --git a/packages/platform-objects/src/identity/sys-user-create-user-email-required.test.ts b/packages/platform-objects/src/identity/sys-user-create-user-email-required.test.ts new file mode 100644 index 00000000000..888c93eea9a --- /dev/null +++ b/packages/platform-objects/src/identity/sys-user-create-user-email-required.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #28280 — Create User shows Email as required exactly when the endpoint + * requires it. + * + * plugin-auth's Create User refuses a blank email with phone sign-in off (the + * default), and accepts an email OR a phone number with it on. A param's + * `required` is a static boolean, so the declaration carries TWO params on the + * one `email` field, each gated on `features.phoneNumber`: + * + * - FIRST, the optional one, `requiresFeature: 'phoneNumber'` — shown beside + * Phone Number when phone sign-in is on. It is first because the feature-gate + * guard (`feature-gate-guard.test.ts`) resolves the booked path + * `sys_user.actions.create_user.params.email` to the first param with that + * field and wants the canonical opt-in gate as one of its conjuncts. + * - SECOND, the required one, shown when the served config says phone sign-in + * is off. Its gate leads with `has(features.phoneNumber)` so that a console + * which has no served flag (the config not fetched yet, or a fetch that + * failed: the console's `features` is then `{}`) does not show it: a bare + * `features.phoneNumber != true` faults on the absent key, and the console + * shows a param whose gate faults, so both Email inputs would render. + * + * The console collects and submits only the params whose gate says yes + * (`filterVisibleParams` in app-shell `ActionParamDialog.tsx`, whose own tests + * pin that a fault shows the param). This file evaluates the declared gates + * through the canonical engine under that rule, on the three `features` + * bindings the console can hold, and asserts exactly one Email input with the + * right `required` on each. + */ + +import { describe, expect, it } from 'vitest'; +import { celEngine } from '@objectstack/formula'; +import { featureGatePredicate } from '@objectstack/spec/kernel'; +import { SysUser } from './sys-user.object.js'; + +type Param = { name?: string; field?: string; required?: boolean; visible?: { source?: string } }; + +const createUser = (SysUser.actions ?? []).find((a) => a.name === 'create_user'); +const params = ((createUser as { params?: Param[] } | undefined)?.params ?? []) as Param[]; +const keyOf = (p: Param) => p.name ?? p.field; + +/** The console's rule: no gate, a gate answering true, or a gate that faults, shows the param. */ +function shown(features: Record): Param[] { + return params.filter((p) => { + const source = p.visible?.source; + if (!source) return true; + const r = celEngine.evaluate({ dialect: 'cel', source }, { record: {}, extra: { features } }); + return !r.ok || r.value === true; + }); +} + +const emailsShown = (features: Record) => shown(features).filter((p) => keyOf(p) === 'email'); + +describe('#28280 — Create User marks Email required exactly when the endpoint requires it', () => { + it('phone sign-in off: one Email input, and it is required', () => { + const emails = emailsShown({ phoneNumber: false }); + expect(emails).toHaveLength(1); + expect(emails[0].required).toBe(true); + expect(shown({ phoneNumber: false }).map(keyOf)).not.toContain('phoneNumber'); + }); + + it('phone sign-in on: one Email input, optional, beside Phone Number', () => { + const emails = emailsShown({ phoneNumber: true }); + expect(emails).toHaveLength(1); + expect(emails[0].required).toBe(false); + const keys = shown({ phoneNumber: true }).map(keyOf); + expect(keys.indexOf('email')).toBeLessThan(keys.indexOf('phoneNumber')); + }); + + it('no served flag (config not fetched, or the fetch failed): one Email input, optional', () => { + const emails = emailsShown({}); + expect(emails).toHaveLength(1); + expect(emails[0].required).toBe(false); + }); + + it('the first Email param carries the canonical phoneNumber gate the feature-gate guard resolves', () => { + const first = params.find((p) => keyOf(p) === 'email'); + expect(first?.required).toBe(false); + expect(first?.visible?.source).toBe(featureGatePredicate('phoneNumber')); + }); +}); From e57f8ced7fb2d336196c5163e334bec4b16ac729 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:36:36 +0000 Subject: [PATCH 2/5] fix(platform-objects): Create User marks Email required while phone sign-in is off Two params on the one email field: the optional, phoneNumber-gated one first, then a required one shown while the served config says phone sign-in is off. Book the path in phoneNumber.gatedInputs. Co-Authored-By: Claude --- .../src/feature-gate-guard.test.ts | 11 +++++++++++ .../src/identity/sys-user.object.ts | 18 ++++++++++++++++-- .../spec/src/kernel/public-auth-features.ts | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/platform-objects/src/feature-gate-guard.test.ts b/packages/platform-objects/src/feature-gate-guard.test.ts index 096c5e49c70..61a6cf3143d 100644 --- a/packages/platform-objects/src/feature-gate-guard.test.ts +++ b/packages/platform-objects/src/feature-gate-guard.test.ts @@ -15,6 +15,17 @@ * booked in that flag's `gatedInputs`. Adding a gate without registry * bookkeeping turns this red. * + * One booked path can stand for two params (#28280): + * `sys_user.actions.create_user.params.email` in `phoneNumber.gatedInputs` + * books BOTH Email params of Create User, because a param's `required` is + * static and the field is required only while phone sign-in is off. The first, + * optional param carries the canonical opt-in gate, and the forward guard + * resolves the path to that first param. The second, required param is + * NEGATIVELY gated (`has(features.phoneNumber) && features.phoneNumber != + * true`); the registry does not model a negated gate, so the reverse guard + * only checks it is booked, and its verdicts are pinned in + * `identity/sys-user-create-user-email-required.test.ts`. + * * Scope notes: objects that moved to capability plugins per ADR-0029 K2 * (plugin-security / plugin-sharing / plugin-audit / service-realtime / * plugin-webhooks) are outside this package and this guard; none of them diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 6dabf47005b..cb763e5de39 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -314,8 +314,22 @@ export const SysUser = ObjectSchema.create({ bodyExtra: { generatePassword: true }, params: [ // The endpoint requires email OR phone (phone-only users get a - // placeholder address; requires auth.plugins.phoneNumber). - { field: 'email', required: false }, + // placeholder address; requires auth.plugins.phoneNumber). With phone + // sign-in off (the default) it requires the email. A param's + // `required` is a static boolean, so the one Email field is declared + // twice, and the console collects and submits only the param whose + // gate says yes (#28280): + // - FIRST, optional, shown when phone sign-in is on. It must stay + // first: the feature-gate guard resolves the booked path + // `sys_user.actions.create_user.params.email` to the first param on + // the field and wants the canonical opt-in gate on it. + // - SECOND, required, shown when the served config says phone sign-in + // is off. `has()` keeps it hidden while the console holds no flag + // (config not fetched, or the fetch failed): a bare + // `features.phoneNumber != true` faults on the absent key, and a + // faulting gate shows its param, so both inputs would render. + { field: 'email', required: false, requiresFeature: 'phoneNumber' }, + { field: 'email', required: true, visible: 'has(features.phoneNumber) && features.phoneNumber != true' }, { name: 'phoneNumber', label: 'Phone Number', diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 9ff0a4dd75e..06a569145ce 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -230,7 +230,7 @@ export const PUBLIC_AUTH_FEATURES = { phoneNumber: { surface: 'crud', semantics: 'opt-in', - gatedInputs: ['sys_user.actions.create_user.params.phoneNumber'], + gatedInputs: ['sys_user.actions.create_user.params.phoneNumber', 'sys_user.actions.create_user.params.email'], notes: 'The fix this registry generalizes: create-user\'s phone field ' + 'follows the opt-in phoneNumber plugin instead of offering a field ' + From 2310b3028baa8116a7c14c01be43b2a10d8319ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:43:33 +0000 Subject: [PATCH 3/5] test(app-shell): Create User dialog draws one Email input, required while phone sign-in is off Co-Authored-By: Claude --- .../28280-create-user-email-required-mark.md | 15 ++ ...createUserEmailRequiredMark-28280.test.tsx | 195 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 .changeset/28280-create-user-email-required-mark.md create mode 100644 ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserEmailRequiredMark-28280.test.tsx diff --git a/.changeset/28280-create-user-email-required-mark.md b/.changeset/28280-create-user-email-required-mark.md new file mode 100644 index 00000000000..b7e1c5bdd28 --- /dev/null +++ b/.changeset/28280-create-user-email-required-mark.md @@ -0,0 +1,15 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/spec': patch +--- + +fix(platform-objects): Setup Create User marks Email required while phone sign-in is off + +Clause-②: no +Metadata-protocol: none + +Create User refuses a blank email while phone sign-in is off (the default), and takes an email or a phone number while it is on. The dialog now shows that before submit: with phone sign-in off, Email carries the required mark and a blank one is refused in the dialog; with it on, Email stays optional beside Phone Number. + +- **The declaration.** A param's `required` is a static boolean, so `sys_user.create_user` declares two params on the one `email` field. First an optional one with `requiresFeature: 'phoneNumber'`, then a required one with `visible: 'has(features.phoneNumber) && features.phoneNumber != true'`. The console collects and submits only the param whose gate says yes, so exactly one Email input shows. While the console holds no served flag (config not fetched, or the fetch failed), the `has()` term keeps the required param hidden and Email stays optional, as it was. +- **The registry.** `PUBLIC_AUTH_FEATURES.phoneNumber.gatedInputs` books `sys_user.actions.create_user.params.email`. That one path stands for both Email params: the feature-gate guard checks the canonical gate on the first, optional param, and the required param's negated gate is pinned by its own test. +- **Unchanged.** The endpoint's own refusal of a blank email, and the dialog's marks for a refusal the server names per field. diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserEmailRequiredMark-28280.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserEmailRequiredMark-28280.test.tsx new file mode 100644 index 00000000000..918cab308bd --- /dev/null +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserEmailRequiredMark-28280.test.tsx @@ -0,0 +1,195 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#28280 — Create User marks Email required BEFORE submit, exactly + * when the endpoint requires it. + * + * plugin-auth's Create User refuses a blank email while phone sign-in is off + * (the default) and takes an email OR a phone number while it is on. A param's + * `required` is static, so `sys_user.create_user` declares two params on the + * one `email` field, each gated on `features.phoneNumber`; the dialog draws and + * submits only the param whose gate says yes. `@objectstack/platform-objects` + * is not a dependency of this tree: its own pin + * (`identity/sys-user-create-user-email-required.test.ts`) holds the + * declaration to the params below, which are the declaration as the server + * serves it (`requiresFeature` lowered into `visible`). + * + * The seam is the one objectstack#28289's test boots (a real `ActionRunner` + * with the console runtime's api and param handlers, the real + * `ActionParamDialog`, a real Chinese `I18nProvider`), plus the console's own + * `ExpressionProvider` fed the served `features`, and `sys_user`'s `email` + * field so the field-backed params resolve through `resolveActionParams`. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ActionRunner } from '@object-ui/core'; +import { I18nProvider } from '@object-ui/i18n'; +// Module scope, per AGENTS.md 测试纪律: the dialog's widgets sit behind +// `React.lazy`, and this barrel statically re-exports them. +import '@object-ui/fields'; + +vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); + +const authFetchSpy = vi.fn(); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => authFetchSpy, +})); +// Only the metadata label lookups are stood in for; translation itself is the +// real provider's. +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useObjectLabel: () => ({ + fieldLabel: (_o: any, _n: any, l: any) => l, + fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l, + actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback, + actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, + actionDescription: (_o: any, _a: any, fallback: any) => fallback, + actionSubmitText: (_o: any, _a: any, fallback: any) => fallback, + }), +})); +vi.mock('../useActionModal', () => ({ + useActionModal: () => ({ + modalHandler: vi.fn(async () => ({ success: true })), + modalElement: null, + closeModal: () => {}, + resolveModalTarget: vi.fn(async () => null), + }), +})); +vi.mock('sonner', () => { + const fn: any = vi.fn(); + fn.error = vi.fn(); + fn.success = vi.fn(); + return { toast: fn }; +}); + +import { useConsoleActionRuntime } from '../useConsoleActionRuntime'; +import { ExpressionProvider } from '../../providers/ExpressionProvider'; + +afterEach(() => cleanup()); +beforeEach(() => authFetchSpy.mockReset()); + +const cel = (source: string) => ({ dialect: 'cel', source }); + +/** `sys_user.create_user`'s leading params, as the server serves them. */ +const PARAMS = [ + { field: 'email', required: false, visible: cel('features.phoneNumber == true') }, + { field: 'email', required: true, visible: cel('has(features.phoneNumber) && features.phoneNumber != true') }, + { name: 'phoneNumber', label: '手机号', type: 'text', required: false, visible: cel('features.phoneNumber == true') }, + { field: 'name', required: false }, +]; +const CREATE_USER = { + name: 'create_user', + label: 'Create User', + type: 'api', + target: '/api/v1/auth/admin/create-user', +}; +/** The `sys_user` fields the field-backed params resolve against. */ +const SYS_USER = { + name: 'sys_user', + fields: { + email: { name: 'email', label: '邮箱', type: 'email' }, + name: { name: 'name', label: '姓名', type: 'text' }, + }, + actions: [{ ...CREATE_USER, params: PARAMS }], +}; + +function Harness({ onReady }: { onReady: (rt: ReturnType) => void }) { + const runtime = useConsoleActionRuntime({ + dataSource: {}, + objects: [SYS_USER] as any[], + objectName: 'sys_user', + }); + const ready = React.useRef(false); + if (!ready.current) { + ready.current = true; + onReady(runtime); + } + return <>{runtime.dialogs}; +} + +/** Mount under Chinese with the served `features`, and open Create User. */ +async function open(features: Record) { + let runtime!: ReturnType; + render( + + + { runtime = rt; }} /> + + , + ); + const runner = new ActionRunner({}); + runner.setParamCollectionHandler(runtime.paramCollectionHandler); + runner.setToastHandler(vi.fn()); + runner.registerHandler('api', runtime.apiHandler); + await act(async () => { + void runner.execute({ ...CREATE_USER, params: PARAMS } as any); + }); + await screen.findByLabelText(/邮箱/); +} + +/** The Email inputs the dialog drew: one per field row. */ +const emailInputs = () => screen.getAllByLabelText(/邮箱/); +const confirm = () => fireEvent.click(screen.getByRole('button', { name: '确认' })); + +describe('objectstack#28280 — Create User marks Email required before submit, per phone sign-in', () => { + it('phone sign-in off: one Email input carrying the required mark, and a blank one is refused before any request', async () => { + await open({ phoneNumber: false }); + + expect(emailInputs()).toHaveLength(1); + expect(emailInputs()[0]).toHaveAttribute('aria-required', 'true'); + expect(screen.queryByLabelText(/手机号/)).toBeNull(); + + confirm(); + + expect(await screen.findByText('邮箱 为必填项')).toBeInTheDocument(); + expect(emailInputs()[0]).toHaveAttribute('aria-invalid', 'true'); + expect(authFetchSpy).not.toHaveBeenCalled(); + }); + + it('phone sign-in on: one Email input, optional, beside Phone Number; a blank form goes to the server', async () => { + authFetchSpy.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ success: false, error: { code: 'VALIDATION_FAILED', message: 'Email or phone number is required', httpStatus: 400 } }), + }); + await open({ phoneNumber: true }); + + expect(emailInputs()).toHaveLength(1); + expect(emailInputs()[0]).not.toHaveAttribute('aria-required'); + expect(screen.getByLabelText(/手机号/)).toBeInTheDocument(); + + confirm(); + + await waitFor(() => expect(authFetchSpy).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('邮箱 为必填项')).toBeNull(); + }); + + it('no served flag (config not fetched, or the fetch failed): one Email input, optional', async () => { + await open({}); + + expect(emailInputs()).toHaveLength(1); + expect(emailInputs()[0]).not.toHaveAttribute('aria-required'); + }); + + it('control: with phone sign-in off a typed email submits once, under its one name', async () => { + authFetchSpy.mockResolvedValue({ ok: true, status: 200, json: async () => ({ success: true, data: { user: { id: 'u_new' } } }) }); + await open({ phoneNumber: false }); + + fireEvent.change(emailInputs()[0], { target: { value: 'ada@example.com' } }); + confirm(); + + await waitFor(() => expect(authFetchSpy).toHaveBeenCalledTimes(1)); + const body = JSON.parse(String((authFetchSpy.mock.calls[0][1] as RequestInit).body)); + expect(JSON.stringify(body)).toContain('"email":"ada@example.com"'); + }); +}); From 96a7c56eca5d2ff442261b9fc74192486703ef5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 14:55:02 +0000 Subject: [PATCH 4/5] fix(spec): action-param check holds a twice-declared name required only when every declaration is sys_user.create_user declares email twice (optional while phone sign-in is on, required while it is off). validateActionParams keyed params by name with the last declaration winning, so the action doors refused a phone-only create. A name is now required only when every declaration of it is; the value shape is the first declaration's. Co-Authored-By: Claude --- ...api-action-create-user-email-param.test.ts | 148 ++++++++++++++++++ .../spec/src/kernel/public-auth-features.ts | 15 +- packages/spec/src/ui/action-params.test.ts | 23 +++ packages/spec/src/ui/action-params.zod.ts | 18 ++- 4 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 packages/runtime/src/domains/api-action-create-user-email-param.test.ts diff --git a/packages/runtime/src/domains/api-action-create-user-email-param.test.ts b/packages/runtime/src/domains/api-action-create-user-email-param.test.ts new file mode 100644 index 00000000000..23a9f7a97cf --- /dev/null +++ b/packages/runtime/src/domains/api-action-create-user-email-param.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#28280] `sys_user.create_user` declares its `email` param twice: optional +// while phone sign-in is on, required while it is off, each shown under its own +// `visible` gate in the console. The two action doors (REST +// `POST /actions/sys_user/create_user` and the MCP `run_action` bridge) judge +// the request bag with ADR-0104 D2's `validateActionParams` BEFORE forwarding +// to plugin-auth, and that check reads neither `visible` nor `features`. So the +// doors must hold `email` required only when every declaration of it is, +// otherwise a phone-only create with phone sign-in on is refused at the door +// ("Action param \"email\" is required") and never reaches the endpoint that +// accepts it. +// +// Only the REST door is driven here: `create_user` declares no `ai.exposed`, so +// the MCP bridge refuses it as a system-object action before any param check +// (measured; `systemObjectActionExposureError`). Both doors call the same +// `enforceActionParams` → `validateActionParams`, whose merge is pinned in +// `packages/spec/src/ui/action-params.test.ts`. +// +// The door is driven as `api-action-forward.test.ts` drives it, over a +// recording in-process target, with the REAL `SysUser` declaration. The target +// stands in for plugin-auth's Create User: whether a blank email is acceptable +// is that endpoint's decision (it answers 400 "Email is required" with phone +// sign-in off, pinned in plugin-auth), and the doors relay its answer whole. + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; +import { SysUser } from '@objectstack/platform-objects/identity'; +import { ActionActivationProjection } from '@objectstack/objectql'; + +const OBJECT = 'sys_user'; +const ACTION = 'create_user'; + +function boot(answer: { status?: number; body?: unknown } = {}) { + const injected: Array<{ url: string; init: RequestInit }> = []; + const request = vi.fn(async (url: string, init: RequestInit) => { + injected.push({ url, init }); + return new Response(JSON.stringify(answer.body ?? { success: true, data: { user: { id: 'u_new' } } }), { + status: answer.status ?? 200, + headers: [['content-type', 'application/json']], + }); + }); + const server = { getRawApp: () => ({ request }) }; + const objectDef: any = SysUser; + const ql: any = { + executeAction: vi.fn(async () => ({ ran: 'script' })), + getSchema: (name: string) => (name === OBJECT ? objectDef : undefined), + registry: { getObject: (name: string) => (name === OBJECT ? objectDef : undefined), getItem: () => undefined }, + find: vi.fn(async () => []), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + isActionEnabled: () => true, + describeDisabledAction: (name: string) => new ActionActivationProjection().describeDisabled(name), + }; + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => [objectDef]), + getObject: vi.fn(async () => objectDef), + }; + const resolve = (n: string) => + n === 'objectql' || n === 'data' ? ql + : n === 'metadata' ? metadata + : n === 'http.server' || n === 'http-server' ? server + : null; + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + return { dispatcher: new HttpDispatcher(kernel), ql, metadata, request, injected }; +} + +/** A signed-in platform administrator, as the forward tests' caller. */ +const CTX = (): HttpProtocolContext => ({ + request: { + headers: { + host: 'app.example.test', + authorization: 'Bearer tok_admin', + 'content-type': 'application/json', + }, + }, + environmentId: 'platform', + executionContext: { userId: 'u_admin', tenantId: 'org_1', systemPermissions: [] }, +} as unknown as HttpProtocolContext); + +const rest = (h: ReturnType, params: Record) => + h.dispatcher.handleActions(`/${OBJECT}/${ACTION}`, 'POST', { params }, CTX()); + +const statusOf = (r: any): unknown => r?.response?.status; +const forwardedBody = (h: ReturnType) => JSON.parse(String(h.injected[0].init.body)); + +describe('#28280 — the action doors judge Create User\'s twice-declared email by the endpoint, not by the last declaration', () => { + it('the declaration really carries two email params, optional first and required second', () => { + const params = ((SysUser.actions ?? []).find((a) => a.name === ACTION)?.params ?? []) as Array<{ field?: string; required?: boolean }>; + expect(params.filter((p) => p.field === 'email').map((p) => p.required)).toEqual([false, true]); + }); + + it('REST, phone sign-in on: a phone-only create passes the door and reaches the endpoint', async () => { + const h = boot(); + + const res = await rest(h, { phoneNumber: '+8613800000000', name: 'Ada' }); + + expect(statusOf(res), JSON.stringify((res as any)?.response?.body)).toBe(200); + expect(h.injected).toHaveLength(1); + expect(h.injected[0].url).toBe('http://app.example.test/api/v1/auth/admin/create-user'); + expect(forwardedBody(h)).toMatchObject({ phoneNumber: '+8613800000000', name: 'Ada', generatePassword: true }); + }); + + it('REST, phone sign-in off: a blank email passes the door, and the ENDPOINT\'s 400 "Email is required" is what the caller gets', async () => { + const h = boot({ + status: 400, + body: { + success: false, + error: { + code: 'VALIDATION_FAILED', + message: 'Email is required', + httpStatus: 400, + fields: [{ field: 'email', code: 'required', message: 'Email is required' }], + }, + }, + }); + + const res: any = await rest(h, { name: 'Ada' }); + + expect(h.injected).toHaveLength(1); + expect(statusOf(res)).toBe(400); + expect(res?.response?.body?.error?.code).toBe('VALIDATION_FAILED'); + expect(res?.response?.body?.error?.message).toBe('Email is required'); + expect(res?.response?.body?.error?.fields).toEqual([{ field: 'email', code: 'required', message: 'Email is required' }]); + }); + + it('REST, phone sign-in off: a create with an email passes and is forwarded with it', async () => { + const h = boot(); + + const res = await rest(h, { email: 'ada@example.com', name: 'Ada' }); + + expect(statusOf(res)).toBe(200); + expect(forwardedBody(h)).toMatchObject({ email: 'ada@example.com', name: 'Ada' }); + }); + + it('control: the door still judges the email\'s value shape, so a non-string email is refused there', async () => { + const h = boot(); + + const res: any = await rest(h, { email: 42 }); + + expect(statusOf(res)).toBe(400); + expect(h.injected).toHaveLength(0); + }); +}); diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index 06a569145ce..8866819ba54 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -31,7 +31,9 @@ import type { z } from 'zod'; * `visible` CEL predicate using {@link featureGatePredicate}. * - plugin-auth drift guard (key-set equivalence with `getPublicConfig()`). * - platform-objects completeness guard (`feature-gate-guard.test.ts`) — - * every path in `gatedInputs` must carry the matching predicate, and every + * every path in `gatedInputs` must resolve to an input whose `visible` + * carries the matching predicate as one of its `&&` conjuncts (a param path + * resolves to the FIRST param on that name or field), and every * `features.*` reference in a `visible` predicate must be booked here. * * This module is deliberately **import-free** (type-only zod import aside) so @@ -70,7 +72,10 @@ export type PublicAuthFeatureEntry = { * Spec inputs gated on this flag. Path grammar: * `.actions.` or `.actions..params.`. * The platform-objects completeness guard resolves each path and asserts the - * target's `visible` predicate matches {@link featureGatePredicate}. + * target's `visible` predicate carries {@link featureGatePredicate} as a + * conjunct. A param path resolves to the FIRST param on that name or field; + * further params on the same field that reference the flag are booked by the + * same path, whatever their gate (see `phoneNumber`). * Mutually exclusive with `exempt`. */ gatedInputs?: readonly string[]; @@ -235,7 +240,11 @@ export const PUBLIC_AUTH_FEATURES = { 'The fix this registry generalizes: create-user\'s phone field ' + 'follows the opt-in phoneNumber plugin instead of offering a field ' + 'the backend refuses. Also read by objectui LoginForm for the ' + - 'phone+password sign-in mode.', + 'phone+password sign-in mode. The create_user email path stands for ' + + 'TWO params on the email field: the first, optional one carries this ' + + 'gate; the second, required one is NEGATIVELY gated ' + + '(has(features.phoneNumber) && features.phoneNumber != true), because ' + + 'the endpoint requires an email only while phone sign-in is off.', }, // The managed (cloud-hosted, SSO-only) mode — ruling 6341690661 on #25083, // Q2 D (one accessor) and Q3 A (this flag, camelCase, hide-when-on). diff --git a/packages/spec/src/ui/action-params.test.ts b/packages/spec/src/ui/action-params.test.ts index d3cdacf7c16..677d1c897f5 100644 --- a/packages/spec/src/ui/action-params.test.ts +++ b/packages/spec/src/ui/action-params.test.ts @@ -33,6 +33,29 @@ describe('validateActionParams (ADR-0104 D2)', () => { expect(issues[0].param).toBe('title'); }); + it('a name declared more than once is required only when EVERY declaration is (#28280)', () => { + const optionalThenRequired: ResolvedActionParam[] = [ + { name: 'email', type: 'email', required: false }, + { name: 'email', type: 'email', required: true }, + ]; + expect(validateActionParams(optionalThenRequired, {})).toEqual([]); + expect(validateActionParams([...optionalThenRequired].reverse(), {})).toEqual([]); + const bothRequired: ResolvedActionParam[] = [ + { name: 'email', type: 'email', required: true }, + { name: 'email', type: 'email', required: true }, + ]; + expect(codes(validateActionParams(bothRequired, {}))).toEqual(['required']); + }); + + it('a name declared more than once is judged by the FIRST declaration\'s value shape (#28280)', () => { + const resolved: ResolvedActionParam[] = [ + { name: 'stage', type: 'select', options: [{ value: 'a' }] }, + { name: 'stage', type: 'text' }, + ]; + expect(codes(validateActionParams(resolved, { stage: 'zzz' }))).toEqual(['invalid_shape']); + expect(validateActionParams(resolved, { stage: 'a' })).toEqual([]); + }); + it('does not flag a missing OPTIONAL param', () => { const resolved: ResolvedActionParam[] = [{ name: 'note', type: 'text' }]; expect(validateActionParams(resolved, {})).toEqual([]); diff --git a/packages/spec/src/ui/action-params.zod.ts b/packages/spec/src/ui/action-params.zod.ts index 3f73de3ae0f..2d33e30095d 100644 --- a/packages/spec/src/ui/action-params.zod.ts +++ b/packages/spec/src/ui/action-params.zod.ts @@ -181,6 +181,18 @@ function builtinNearMissHint(key: string, allow: ReadonlySet): string { * exactly as before and additionally NAMED with it — see * {@link builtinNearMissHint}. Message copy only; the accepted set is the * declared params plus `allow`, unchanged. + * + * ## Several declarations of one name: required only when every one is + * + * An action may declare the same request key more than once, each declaration + * shown under its own `visible` gate, because a param's `required` is a static + * boolean: `sys_user.create_user` declares `email` optional while phone sign-in + * is on and required while it is off (#28280). This check reads neither + * `visible` nor `features`, so it cannot tell which declaration the caller saw. + * It therefore treats the name as required only when EVERY declaration of it is + * required, and judges the value shape by the FIRST declaration. The endpoint + * behind the action still refuses a value it needs: Create User answers a blank + * email with phone sign-in off with its own 400. */ export function validateActionParams( resolved: ResolvedActionParam[], @@ -189,7 +201,11 @@ export function validateActionParams( ): ActionParamIssue[] { const issues: ActionParamIssue[] = []; const declared = new Map(); - for (const p of resolved) if (p?.name) declared.set(p.name, p); + for (const p of resolved) { + if (!p?.name) continue; + const first = declared.get(p.name); + declared.set(p.name, first ? { ...first, required: Boolean(first.required && p.required) } : p); + } const allow = new Set(opts?.builtinKeys ?? ACTION_PARAM_BUILTIN_KEYS); for (const p of declared.values()) { From 9684fb3ba196ca4e8fd9a31ed3bffe40cac30ba8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 14:59:26 +0000 Subject: [PATCH 5/5] chore(changeset): declare the action-param merge (Clause-2 yes widening, semantic, spec minor) Co-Authored-By: Claude --- .changeset/28280-create-user-email-required-mark.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/28280-create-user-email-required-mark.md b/.changeset/28280-create-user-email-required-mark.md index b7e1c5bdd28..5ec057cd840 100644 --- a/.changeset/28280-create-user-email-required-mark.md +++ b/.changeset/28280-create-user-email-required-mark.md @@ -1,15 +1,16 @@ --- '@objectstack/platform-objects': patch -'@objectstack/spec': patch +'@objectstack/spec': minor --- fix(platform-objects): Setup Create User marks Email required while phone sign-in is off -Clause-②: no -Metadata-protocol: none +Clause-②: yes (widening) +Metadata-protocol: semantic Create User refuses a blank email while phone sign-in is off (the default), and takes an email or a phone number while it is on. The dialog now shows that before submit: with phone sign-in off, Email carries the required mark and a blank one is refused in the dialog; with it on, Email stays optional beside Phone Number. -- **The declaration.** A param's `required` is a static boolean, so `sys_user.create_user` declares two params on the one `email` field. First an optional one with `requiresFeature: 'phoneNumber'`, then a required one with `visible: 'has(features.phoneNumber) && features.phoneNumber != true'`. The console collects and submits only the param whose gate says yes, so exactly one Email input shows. While the console holds no served flag (config not fetched, or the fetch failed), the `has()` term keeps the required param hidden and Email stays optional, as it was. -- **The registry.** `PUBLIC_AUTH_FEATURES.phoneNumber.gatedInputs` books `sys_user.actions.create_user.params.email`. That one path stands for both Email params: the feature-gate guard checks the canonical gate on the first, optional param, and the required param's negated gate is pinned by its own test. +- **The declaration.** A param's `required` is a static boolean, so `sys_user.create_user` declares two params on the one `email` field. First an optional one with `requiresFeature: 'phoneNumber'`, then a required one with `visible: 'has(features.phoneNumber) && features.phoneNumber != true'`. The console collects and submits only the param whose gate says yes, so exactly one Email input shows. While the console holds no served flag (config not fetched, or the fetch failed), the `has()` term keeps the required param hidden and Email shows unmarked, as before. +- **The action doors (the semantic change).** `validateActionParams` (`@objectstack/spec/ui`), which the REST action route and the MCP `run_action` bridge run before the handler, used to key params by name with the LAST declaration winning. A name declared more than once is now required only when EVERY declaration of it is required, and its value shape is the FIRST declaration's. Without this the REST door refused a phone-only Create User with `400 VALIDATION_ERROR` "Action param \"email\" is required". The door reads no `visible` or `features`, so the endpoint stays the judge: Create User still answers a blank email with phone sign-in off with its own `400` "Email is required". No other action in this repository, its examples or hotcrm declares a param name twice, so nothing else changes meaning. +- **The registry.** `PUBLIC_AUTH_FEATURES.phoneNumber.gatedInputs` books `sys_user.actions.create_user.params.email`. That one path stands for both Email params: the feature-gate guard checks the canonical gate on the first, optional param, and the second, required param is negatively gated, as the row's notes say. - **Unchanged.** The endpoint's own refusal of a blank email, and the dialog's marks for a refusal the server names per field.