Skip to content

Commit 8bceb6a

Browse files
fix(platform-objects): Setup Create User marks Email required while phone sign-in is off (#28707)
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XMQYxWuFJTUDhuJ14KLeVrk)_ Fixes #28280 Clause-②: yes (widening) Metadata-protocol: semantic ## 维护者速读 - **改了什么**:Setup → 用户 → 创建用户:未开启手机号登录(默认)时,「邮箱」在提交前就带必填星标,留空会在对话框里直接拦下;开启手机号登录时,邮箱仍是可选,旁边是手机号。控制台还没拿到 `/auth/config` 的应答时(加载中或请求失败),邮箱不带标记,和 main 上一样。 - **为什么改**:服务器在未开启手机号登录时拒收空邮箱(400 "Email is required"),但对话框把邮箱画成可选,管理员只有提交后才知道。#28649 已能在提交后标出服务器点名的字段,这张卡补上提交前的标记。 - **风险与回滚**:中低。`sys_user.create_user` 的邮箱参数拆成两个(同一字段,按 `features.phoneNumber` 各自显隐,对话框只画并只提交显示的那个)。为此动作门的参数校验 `validateActionParams` 改了一处语义:同名参数声明多次时,只有每一处都必填才算必填,值形状按第一处(以前是最后一处说了算,会让 REST 动作门把只填手机号的创建拒成 400)。仓库、示例和 hotcrm 里没有别的动作重复声明参数名,所以别处行为不变。外加 `public-auth-features.ts` 登记一条路径。纯 revert 即可回滚。 - **先看**:`packages/spec/src/ui/action-params.zod.ts`(同名合并那一行);`packages/platform-objects/src/identity/sys-user.object.ts`(两个邮箱参数的顺序与 `visible`);`packages/runtime/src/domains/api-action-create-user-email-param.test.ts`(经真实 REST 动作门的三条路径)。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-create-user-email-param.test.ts`,预期 5 例全绿(只填手机号的创建能到达接口;空邮箱由接口自己答 400 "Email is required")。 - **需要你做的**:无,席位落地(含 `packages/spec` 路径的 Contract review)。 ## What changes - **`sys_user.create_user` declares two params on the one `email` field** (`packages/platform-objects/src/identity/sys-user.object.ts`). A param's `required` is a static boolean, and the endpoint requires the email only while phone sign-in is off, so: - first `{ field: 'email', required: false, requiresFeature: 'phoneNumber' }`, which lowers to `features.phoneNumber == true`; - then `{ field: 'email', required: true, visible: 'has(features.phoneNumber) && features.phoneNumber != true' }`. The console collects and submits only the params whose gate says yes (`filterVisibleParams`), so exactly one Email input renders. The rendered order is unchanged: Email, then Phone Number when it shows, then Name. - **The action doors judge a twice-declared name by every declaration** (`packages/spec/src/ui/action-params.zod.ts`, under the one-PR exception the seat extended to this file). `validateActionParams`, which `enforceActionParams` runs on the REST action route and the MCP `run_action` bridge before the handler, keyed params by name with the LAST declaration winning, so the required second Email param made the REST door refuse a phone-only create (`400 VALIDATION_ERROR` "Action param \"email\" is required"). A name declared more than once is now required only when EVERY declaration is, and its value shape is the FIRST declaration's; unknown-key handling is unchanged. The door reads no `visible` or `features`, so the endpoint stays the judge of a blank email. - **The registry** (`packages/spec/src/kernel/public-auth-features.ts`): `PUBLIC_AUTH_FEATURES.phoneNumber.gatedInputs` books `sys_user.actions.create_user.params.email`. The row's notes say the path stands for two params, the second negatively gated, and the module and `gatedInputs` docblocks say a param path resolves to the first param on its field and the gate is checked as a conjunct. No registry model change and no required-when predicate, as ruled. - **The guard's header** (`packages/platform-objects/src/feature-gate-guard.test.ts`) states that this one booked path stands for two params, one of them negatively gated. The guard's logic is untouched. - **Tests**: a platform-objects pin that evaluates the declared gates through the canonical CEL engine under the console's show rule; an app-shell test that opens Create User through the console runtime with the served `features`; a runtime test that drives the real REST action route with the real `SysUser` declaration; two `validateActionParams` cases in `action-params.test.ts`. - **Changeset** `.changeset/28280-create-user-email-required-mark.md`: `@objectstack/spec` minor (the semantic change to a twice-declared param's `required` at the doors) and `@objectstack/platform-objects` patch. Both packages ship `dist`, and the built `dist` of each carries the change. ## Tests <details><summary>Red first on origin/main 49a6a5c, green at head</summary> `pnpm --filter @objectstack/platform-objects exec vitest run src/identity/sys-user-create-user-email-required.test.ts`, with only the test file on top of `origin/main`: ``` × phone sign-in off: one Email input, and it is required × the first Email param carries the canonical phoneNumber gate the feature-gate guard resolves Tests 2 failed | 2 passed (4) os-verify-lock: VERDICT command-exit 1 ``` At head, with the guard file (after `pnpm --filter @objectstack/spec build`): ``` ✓ forward … sys_user.actions.create_user.params.email is gated on phoneNumber ✓ reverse … sys_user.actions.create_user.params.email references registered flag phoneNumber and is booked (x3) ✓ 4/4 of the new pin Tests 116 passed (116) ``` Whole package: `pnpm --filter @objectstack/platform-objects test` → `Test Files 107 passed (107) · Tests 1496 passed (1496)`. </details> <details><summary>The Create User dialog (app-shell)</summary> `useConsoleActionRuntime.createUserEmailRequiredMark-28280.test.tsx` boots the dialog the way #28649's test does (real `ActionRunner` with the console runtime's api and param handlers, real `ActionParamDialog`, real Chinese `I18nProvider`), inside the console's own `ExpressionProvider` fed the served `features`, with `sys_user`'s fields so the field-backed params resolve through `resolveActionParams`: - phone sign-in off: one Email input, `aria-required="true"`, no Phone Number; a blank Confirm shows `邮箱 为必填项`, marks the input invalid, and sends no request; - phone sign-in on: one Email input, not required, Phone Number shown; a blank Confirm goes to the server; - no served flag (`features` is `{}`): one Email input, not required; - control: with phone sign-in off a typed email submits once. `cd ui && pnpm exec vitest run packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserEmailRequiredMark-28280.test.tsx packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.createUserRequiredField-28289.test.tsx` → `Test Files 2 passed (2) · Tests 10 passed (10)` (#28649's six server-refusal marks stay green). </details> <details><summary>The REST action door (runtime), red first and the merge ablated</summary> `pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-create-user-email-param.test.ts`, driving `HttpDispatcher.handleActions('/sys_user/create_user', 'POST', …)` over the `api-action-forward.test.ts` recording target, with the real `SysUser` from `@objectstack/platform-objects/identity`: - the declaration carries two email params, `[false, true]`; - phone-only `{ phoneNumber, name }` passes the door and is forwarded to `/api/v1/auth/admin/create-user`; - a blank email passes the door; the caller gets the ENDPOINT's `400 VALIDATION_FAILED` "Email is required" with its `fields`, relayed whole (the door does not refuse it); - an email is forwarded with it; - control: a non-string email is still refused at the door (value shape from the first declaration). Red first, with `packages/spec/src/ui/action-params.zod.ts` at 2310b30 and spec rebuilt: `× phone-only … reaches the endpoint` with `{"code":"VALIDATION_ERROR","message":"Invalid action params: Action param \"email\" is required","httpStatus":400}`, `× blank email …` (the seat's finding). That run was on a six-case draft: 3 failed / 3 passed, the third an MCP case that failed on the system-object exposure refusal instead and was dropped (see below). Spec unit cases: 2 failed / 33 passed. Merge ablated through `scripts/ablation-replace.mjs` (`declared.set(p.name, p)`, last wins), spec rebuilt, `dist` hits of the merge 0: door test 2 failed / 3 passed, spec cases 2 failed / 33 passed. Restored to the HEAD blob (`git diff HEAD` empty), rebuilt, `dist` hits 1: 5/5 and 35/35. The MCP bridge is not driven for this action: `create_user` declares no `ai.exposed`, so `run_action` refuses it as a system-object action before any param check (measured). Both doors call the same `enforceActionParams`. </details> <details><summary>Ablations (each through scripts/ablation-replace.mjs, restored to the HEAD blob, git diff HEAD empty)</summary> | Leg | Mutation | Reading | |:--|:--|:--| | A1 | source: the required Email param made `required: false` | `× phone sign-in off: one Email input, and it is required` · 1 failed / 115 passed | | A2 | source: `has(features.phoneNumber) &&` dropped (bare `features.phoneNumber != true`) | `× no served flag …: one Email input, optional` · 1 failed / 114 passed | | A3 | source: the two Email params swapped (required first) | `× the first Email param carries the canonical phoneNumber gate …` and the guard's forward row `× sys_user.actions.create_user.params.email is gated on phoneNumber` · 2 failed / 114 passed | | U1 | ui mirror: the required param made optional | `× phone sign-in off: … required mark …` · 1 failed / 3 passed | | U2 | ui mirror: bare `features.phoneNumber != true` | `× no served flag …` · 1 failed / 3 passed | The platform-objects pin imports the object from `src/` (relative import), and ui vitest aliases `@object-ui/*` to `src/`, so no leg needed a rebuild. A3 confirms the order matters: the forward guard resolves the path to the first param on the field. </details> ## Acceptance notes - **One reading beyond the order's literal.** The order spelled the required param's gate `features.phoneNumber != true`. Measured (A2, U2): while the console holds no served flag (its `features` is `{}` until `/auth/config` answers, and stays `{}` if that fetch fails) both gates fault on the absent key, and the dialog shows a param whose gate faults, so two Email inputs render. The `has(features.phoneNumber) &&` term keeps the required param hidden then, and Email stays optional as it was. The precedent spelling is `sys-user.page.ts`'s `has(features.twoFactor) && features.twoFactor == true`. The first, optional param keeps the canonical gate the guard wants. - **The endpoint is unchanged**: it still refuses a blank email with phone sign-in off; no server file is in this diff. - **Census for the semantic change**: an AST scan of every `params` / `actionParams` array literal in this repository (430 arrays, `ui/` and `examples/` included) and in hotcrm `origin/main` (7 arrays), plus the 2 JSON params arrays, finds no name declared twice except `create_user`'s `email`. No existing action relied on last-wins. - **The reverse guard** has three `email` rows (one per `features.phoneNumber` reference across the two params), all booked by the one path. - The reverse guard's red without the booking was measured on the card (2 failed) and is not re-run here. ## Verification <details><summary>Local gates at head</summary> Local gate run at head 9684fb3, derived by dispatch-gates --commands (87 families), each exit captured before any pipe. Re-run: pnpm --dir ui exec vitest run scripts/ first exit 1 (unit-registry-absence-collision could not import app-shell modules while @objectstack/client dist was still being built in this fresh worktree), then exit 0 (169 files) once it was built. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/spec run check:api-surface :: exit 0 pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0 pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0 pnpm --filter @objectstack/spec run check:export-origins :: exit 0 pnpm --filter @objectstack/spec run check:exported-any :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm --filter @objectstack/spec run check:skill-refs :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:engine-double-contract :: exit 0 pnpm check:error-code-casing :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-stale-fill :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:pm-prior-rulings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:spec-docblock-symbol-anchors :: exit 0 pnpm check:spec-parsed-alias :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` dispatch-gates --ran: 87 derived, 86 run, 1 NOT-MEASURED (check:dual-build-cjs-loads, PREREQUISITE NOT MET: needs every package dist), 0 UNRUN. ``` Also: pnpm --filter @objectstack/spec test :: exit 0 (783 files, 23175 tests); pnpm --filter @objectstack/spec typecheck :: exit 0; pnpm --filter @objectstack/runtime test :: exit 0 (505 files, 8191 tests); pnpm --filter @objectstack/runtime typecheck :: exit 0; pnpm --filter @objectstack/platform-objects test :: exit 0 (107 files, 1496 tests); eslint --no-inline-config on the touched spec, runtime and platform-objects paths :: exit 0. The app-shell test and type-check are unchanged since 2310b30 (green there). ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5nn2N4syy7fVBUEXff)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c5b177 commit 8bceb6a

9 files changed

Lines changed: 521 additions & 7 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(platform-objects): Setup Create User marks Email required while phone sign-in is off
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: semantic
10+
11+
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.
12+
13+
- **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.
14+
- **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.
15+
- **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.
16+
- **Unchanged.** The endpoint's own refusal of a blank email, and the dialog's marks for a refusal the server names per field.

‎packages/platform-objects/src/feature-gate-guard.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@
1515
* booked in that flag's `gatedInputs`. Adding a gate without registry
1616
* bookkeeping turns this red.
1717
*
18+
* One booked path can stand for two params (#28280):
19+
* `sys_user.actions.create_user.params.email` in `phoneNumber.gatedInputs`
20+
* books BOTH Email params of Create User, because a param's `required` is
21+
* static and the field is required only while phone sign-in is off. The first,
22+
* optional param carries the canonical opt-in gate, and the forward guard
23+
* resolves the path to that first param. The second, required param is
24+
* NEGATIVELY gated (`has(features.phoneNumber) && features.phoneNumber !=
25+
* true`); the registry does not model a negated gate, so the reverse guard
26+
* only checks it is booked, and its verdicts are pinned in
27+
* `identity/sys-user-create-user-email-required.test.ts`.
28+
*
1829
* Scope notes: objects that moved to capability plugins per ADR-0029 K2
1930
* (plugin-security / plugin-sharing / plugin-audit / service-realtime /
2031
* plugin-webhooks) are outside this package and this guard; none of them
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #28280 — Create User shows Email as required exactly when the endpoint
5+
* requires it.
6+
*
7+
* plugin-auth's Create User refuses a blank email with phone sign-in off (the
8+
* default), and accepts an email OR a phone number with it on. A param's
9+
* `required` is a static boolean, so the declaration carries TWO params on the
10+
* one `email` field, each gated on `features.phoneNumber`:
11+
*
12+
* - FIRST, the optional one, `requiresFeature: 'phoneNumber'` — shown beside
13+
* Phone Number when phone sign-in is on. It is first because the feature-gate
14+
* guard (`feature-gate-guard.test.ts`) resolves the booked path
15+
* `sys_user.actions.create_user.params.email` to the first param with that
16+
* field and wants the canonical opt-in gate as one of its conjuncts.
17+
* - SECOND, the required one, shown when the served config says phone sign-in
18+
* is off. Its gate leads with `has(features.phoneNumber)` so that a console
19+
* which has no served flag (the config not fetched yet, or a fetch that
20+
* failed: the console's `features` is then `{}`) does not show it: a bare
21+
* `features.phoneNumber != true` faults on the absent key, and the console
22+
* shows a param whose gate faults, so both Email inputs would render.
23+
*
24+
* The console collects and submits only the params whose gate says yes
25+
* (`filterVisibleParams` in app-shell `ActionParamDialog.tsx`, whose own tests
26+
* pin that a fault shows the param). This file evaluates the declared gates
27+
* through the canonical engine under that rule, on the three `features`
28+
* bindings the console can hold, and asserts exactly one Email input with the
29+
* right `required` on each.
30+
*/
31+
32+
import { describe, expect, it } from 'vitest';
33+
import { celEngine } from '@objectstack/formula';
34+
import { featureGatePredicate } from '@objectstack/spec/kernel';
35+
import { SysUser } from './sys-user.object.js';
36+
37+
type Param = { name?: string; field?: string; required?: boolean; visible?: { source?: string } };
38+
39+
const createUser = (SysUser.actions ?? []).find((a) => a.name === 'create_user');
40+
const params = ((createUser as { params?: Param[] } | undefined)?.params ?? []) as Param[];
41+
const keyOf = (p: Param) => p.name ?? p.field;
42+
43+
/** The console's rule: no gate, a gate answering true, or a gate that faults, shows the param. */
44+
function shown(features: Record<string, unknown>): Param[] {
45+
return params.filter((p) => {
46+
const source = p.visible?.source;
47+
if (!source) return true;
48+
const r = celEngine.evaluate({ dialect: 'cel', source }, { record: {}, extra: { features } });
49+
return !r.ok || r.value === true;
50+
});
51+
}
52+
53+
const emailsShown = (features: Record<string, unknown>) => shown(features).filter((p) => keyOf(p) === 'email');
54+
55+
describe('#28280 — Create User marks Email required exactly when the endpoint requires it', () => {
56+
it('phone sign-in off: one Email input, and it is required', () => {
57+
const emails = emailsShown({ phoneNumber: false });
58+
expect(emails).toHaveLength(1);
59+
expect(emails[0].required).toBe(true);
60+
expect(shown({ phoneNumber: false }).map(keyOf)).not.toContain('phoneNumber');
61+
});
62+
63+
it('phone sign-in on: one Email input, optional, beside Phone Number', () => {
64+
const emails = emailsShown({ phoneNumber: true });
65+
expect(emails).toHaveLength(1);
66+
expect(emails[0].required).toBe(false);
67+
const keys = shown({ phoneNumber: true }).map(keyOf);
68+
expect(keys.indexOf('email')).toBeLessThan(keys.indexOf('phoneNumber'));
69+
});
70+
71+
it('no served flag (config not fetched, or the fetch failed): one Email input, optional', () => {
72+
const emails = emailsShown({});
73+
expect(emails).toHaveLength(1);
74+
expect(emails[0].required).toBe(false);
75+
});
76+
77+
it('the first Email param carries the canonical phoneNumber gate the feature-gate guard resolves', () => {
78+
const first = params.find((p) => keyOf(p) === 'email');
79+
expect(first?.required).toBe(false);
80+
expect(first?.visible?.source).toBe(featureGatePredicate('phoneNumber'));
81+
});
82+
});

‎packages/platform-objects/src/identity/sys-user.object.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,22 @@ export const SysUser = ObjectSchema.create({
314314
bodyExtra: { generatePassword: true },
315315
params: [
316316
// The endpoint requires email OR phone (phone-only users get a
317-
// placeholder address; requires auth.plugins.phoneNumber).
318-
{ field: 'email', required: false },
317+
// placeholder address; requires auth.plugins.phoneNumber). With phone
318+
// sign-in off (the default) it requires the email. A param's
319+
// `required` is a static boolean, so the one Email field is declared
320+
// twice, and the console collects and submits only the param whose
321+
// gate says yes (#28280):
322+
// - FIRST, optional, shown when phone sign-in is on. It must stay
323+
// first: the feature-gate guard resolves the booked path
324+
// `sys_user.actions.create_user.params.email` to the first param on
325+
// the field and wants the canonical opt-in gate on it.
326+
// - SECOND, required, shown when the served config says phone sign-in
327+
// is off. `has()` keeps it hidden while the console holds no flag
328+
// (config not fetched, or the fetch failed): a bare
329+
// `features.phoneNumber != true` faults on the absent key, and a
330+
// faulting gate shows its param, so both inputs would render.
331+
{ field: 'email', required: false, requiresFeature: 'phoneNumber' },
332+
{ field: 'email', required: true, visible: 'has(features.phoneNumber) && features.phoneNumber != true' },
319333
{
320334
name: 'phoneNumber',
321335
label: 'Phone Number',
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#28280] `sys_user.create_user` declares its `email` param twice: optional
4+
// while phone sign-in is on, required while it is off, each shown under its own
5+
// `visible` gate in the console. The two action doors (REST
6+
// `POST /actions/sys_user/create_user` and the MCP `run_action` bridge) judge
7+
// the request bag with ADR-0104 D2's `validateActionParams` BEFORE forwarding
8+
// to plugin-auth, and that check reads neither `visible` nor `features`. So the
9+
// doors must hold `email` required only when every declaration of it is,
10+
// otherwise a phone-only create with phone sign-in on is refused at the door
11+
// ("Action param \"email\" is required") and never reaches the endpoint that
12+
// accepts it.
13+
//
14+
// Only the REST door is driven here: `create_user` declares no `ai.exposed`, so
15+
// the MCP bridge refuses it as a system-object action before any param check
16+
// (measured; `systemObjectActionExposureError`). Both doors call the same
17+
// `enforceActionParams` → `validateActionParams`, whose merge is pinned in
18+
// `packages/spec/src/ui/action-params.test.ts`.
19+
//
20+
// The door is driven as `api-action-forward.test.ts` drives it, over a
21+
// recording in-process target, with the REAL `SysUser` declaration. The target
22+
// stands in for plugin-auth's Create User: whether a blank email is acceptable
23+
// is that endpoint's decision (it answers 400 "Email is required" with phone
24+
// sign-in off, pinned in plugin-auth), and the doors relay its answer whole.
25+
26+
import { describe, it, expect, vi } from 'vitest';
27+
28+
import { HttpDispatcher } from '../http-dispatcher.js';
29+
import type { HttpProtocolContext } from '../http-dispatcher.js';
30+
import { SysUser } from '@objectstack/platform-objects/identity';
31+
import { ActionActivationProjection } from '@objectstack/objectql';
32+
33+
const OBJECT = 'sys_user';
34+
const ACTION = 'create_user';
35+
36+
function boot(answer: { status?: number; body?: unknown } = {}) {
37+
const injected: Array<{ url: string; init: RequestInit }> = [];
38+
const request = vi.fn(async (url: string, init: RequestInit) => {
39+
injected.push({ url, init });
40+
return new Response(JSON.stringify(answer.body ?? { success: true, data: { user: { id: 'u_new' } } }), {
41+
status: answer.status ?? 200,
42+
headers: [['content-type', 'application/json']],
43+
});
44+
});
45+
const server = { getRawApp: () => ({ request }) };
46+
const objectDef: any = SysUser;
47+
const ql: any = {
48+
executeAction: vi.fn(async () => ({ ran: 'script' })),
49+
getSchema: (name: string) => (name === OBJECT ? objectDef : undefined),
50+
registry: { getObject: (name: string) => (name === OBJECT ? objectDef : undefined), getItem: () => undefined },
51+
find: vi.fn(async () => []),
52+
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
53+
isActionEnabled: () => true,
54+
describeDisabledAction: (name: string) => new ActionActivationProjection().describeDisabled(name),
55+
};
56+
const metadata: any = {
57+
load: vi.fn(async () => null),
58+
loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })),
59+
loadMany: vi.fn(async () => []),
60+
listObjects: vi.fn(async () => [objectDef]),
61+
getObject: vi.fn(async () => objectDef),
62+
};
63+
const resolve = (n: string) =>
64+
n === 'objectql' || n === 'data' ? ql
65+
: n === 'metadata' ? metadata
66+
: n === 'http.server' || n === 'http-server' ? server
67+
: null;
68+
const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } };
69+
return { dispatcher: new HttpDispatcher(kernel), ql, metadata, request, injected };
70+
}
71+
72+
/** A signed-in platform administrator, as the forward tests' caller. */
73+
const CTX = (): HttpProtocolContext => ({
74+
request: {
75+
headers: {
76+
host: 'app.example.test',
77+
authorization: 'Bearer tok_admin',
78+
'content-type': 'application/json',
79+
},
80+
},
81+
environmentId: 'platform',
82+
executionContext: { userId: 'u_admin', tenantId: 'org_1', systemPermissions: [] },
83+
} as unknown as HttpProtocolContext);
84+
85+
const rest = (h: ReturnType<typeof boot>, params: Record<string, unknown>) =>
86+
h.dispatcher.handleActions(`/${OBJECT}/${ACTION}`, 'POST', { params }, CTX());
87+
88+
const statusOf = (r: any): unknown => r?.response?.status;
89+
const forwardedBody = (h: ReturnType<typeof boot>) => JSON.parse(String(h.injected[0].init.body));
90+
91+
describe('#28280 — the action doors judge Create User\'s twice-declared email by the endpoint, not by the last declaration', () => {
92+
it('the declaration really carries two email params, optional first and required second', () => {
93+
const params = ((SysUser.actions ?? []).find((a) => a.name === ACTION)?.params ?? []) as Array<{ field?: string; required?: boolean }>;
94+
expect(params.filter((p) => p.field === 'email').map((p) => p.required)).toEqual([false, true]);
95+
});
96+
97+
it('REST, phone sign-in on: a phone-only create passes the door and reaches the endpoint', async () => {
98+
const h = boot();
99+
100+
const res = await rest(h, { phoneNumber: '+8613800000000', name: 'Ada' });
101+
102+
expect(statusOf(res), JSON.stringify((res as any)?.response?.body)).toBe(200);
103+
expect(h.injected).toHaveLength(1);
104+
expect(h.injected[0].url).toBe('http://app.example.test/api/v1/auth/admin/create-user');
105+
expect(forwardedBody(h)).toMatchObject({ phoneNumber: '+8613800000000', name: 'Ada', generatePassword: true });
106+
});
107+
108+
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 () => {
109+
const h = boot({
110+
status: 400,
111+
body: {
112+
success: false,
113+
error: {
114+
code: 'VALIDATION_FAILED',
115+
message: 'Email is required',
116+
httpStatus: 400,
117+
fields: [{ field: 'email', code: 'required', message: 'Email is required' }],
118+
},
119+
},
120+
});
121+
122+
const res: any = await rest(h, { name: 'Ada' });
123+
124+
expect(h.injected).toHaveLength(1);
125+
expect(statusOf(res)).toBe(400);
126+
expect(res?.response?.body?.error?.code).toBe('VALIDATION_FAILED');
127+
expect(res?.response?.body?.error?.message).toBe('Email is required');
128+
expect(res?.response?.body?.error?.fields).toEqual([{ field: 'email', code: 'required', message: 'Email is required' }]);
129+
});
130+
131+
it('REST, phone sign-in off: a create with an email passes and is forwarded with it', async () => {
132+
const h = boot();
133+
134+
const res = await rest(h, { email: 'ada@example.com', name: 'Ada' });
135+
136+
expect(statusOf(res)).toBe(200);
137+
expect(forwardedBody(h)).toMatchObject({ email: 'ada@example.com', name: 'Ada' });
138+
});
139+
140+
it('control: the door still judges the email\'s value shape, so a non-string email is refused there', async () => {
141+
const h = boot();
142+
143+
const res: any = await rest(h, { email: 42 });
144+
145+
expect(statusOf(res)).toBe(400);
146+
expect(h.injected).toHaveLength(0);
147+
});
148+
});

‎packages/spec/src/kernel/public-auth-features.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import type { z } from 'zod';
3131
* `visible` CEL predicate using {@link featureGatePredicate}.
3232
* - plugin-auth drift guard (key-set equivalence with `getPublicConfig()`).
3333
* - platform-objects completeness guard (`feature-gate-guard.test.ts`) —
34-
* every path in `gatedInputs` must carry the matching predicate, and every
34+
* every path in `gatedInputs` must resolve to an input whose `visible`
35+
* carries the matching predicate as one of its `&&` conjuncts (a param path
36+
* resolves to the FIRST param on that name or field), and every
3537
* `features.*` reference in a `visible` predicate must be booked here.
3638
*
3739
* This module is deliberately **import-free** (type-only zod import aside) so
@@ -70,7 +72,10 @@ export type PublicAuthFeatureEntry = {
7072
* Spec inputs gated on this flag. Path grammar:
7173
* `<object>.actions.<action>` or `<object>.actions.<action>.params.<name|field>`.
7274
* The platform-objects completeness guard resolves each path and asserts the
73-
* target's `visible` predicate matches {@link featureGatePredicate}.
75+
* target's `visible` predicate carries {@link featureGatePredicate} as a
76+
* conjunct. A param path resolves to the FIRST param on that name or field;
77+
* further params on the same field that reference the flag are booked by the
78+
* same path, whatever their gate (see `phoneNumber`).
7479
* Mutually exclusive with `exempt`.
7580
*/
7681
gatedInputs?: readonly string[];
@@ -230,12 +235,16 @@ export const PUBLIC_AUTH_FEATURES = {
230235
phoneNumber: {
231236
surface: 'crud',
232237
semantics: 'opt-in',
233-
gatedInputs: ['sys_user.actions.create_user.params.phoneNumber'],
238+
gatedInputs: ['sys_user.actions.create_user.params.phoneNumber', 'sys_user.actions.create_user.params.email'],
234239
notes:
235240
'The fix this registry generalizes: create-user\'s phone field ' +
236241
'follows the opt-in phoneNumber plugin instead of offering a field ' +
237242
'the backend refuses. Also read by objectui LoginForm for the ' +
238-
'phone+password sign-in mode.',
243+
'phone+password sign-in mode. The create_user email path stands for ' +
244+
'TWO params on the email field: the first, optional one carries this ' +
245+
'gate; the second, required one is NEGATIVELY gated ' +
246+
'(has(features.phoneNumber) && features.phoneNumber != true), because ' +
247+
'the endpoint requires an email only while phone sign-in is off.',
239248
},
240249
// The managed (cloud-hosted, SSO-only) mode — ruling 6341690661 on #25083,
241250
// Q2 D (one accessor) and Q3 A (this flag, camelCase, hide-when-on).

0 commit comments

Comments
 (0)