Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/28280-create-user-email-required-mark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/platform-objects': patch
'@objectstack/spec': minor
---

fix(platform-objects): Setup Create User marks Email required while phone sign-in is off

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 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.
11 changes: 11 additions & 0 deletions packages/platform-objects/src/feature-gate-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<string, unknown>) => 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'));
});
});
18 changes: 16 additions & 2 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof boot>, params: Record<string, unknown>) =>
h.dispatcher.handleActions(`/${OBJECT}/${ACTION}`, 'POST', { params }, CTX());

const statusOf = (r: any): unknown => r?.response?.status;
const forwardedBody = (h: ReturnType<typeof boot>) => 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);
});
});
17 changes: 13 additions & 4 deletions packages/spec/src/kernel/public-auth-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,7 +72,10 @@ export type PublicAuthFeatureEntry = {
* Spec inputs gated on this flag. Path grammar:
* `<object>.actions.<action>` or `<object>.actions.<action>.params.<name|field>`.
* 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[];
Expand Down Expand Up @@ -230,12 +235,16 @@ 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 ' +
'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).
Expand Down
Loading
Loading