From 409087b6a3dff7944b8e7a1275e63ee613e4d12d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 10:03:16 +0000 Subject: [PATCH 1/5] feat(auth): admin direct user management + phone sign-in (#2766 V1/V1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V1 — platform admins can create login-capable accounts and (re)set passwords without the email-dependent invite flow: - POST /api/v1/auth/admin/create-user: ADR-0068 admin gate, then a trusted header-less auth.api.createUser call (better-auth pipeline: scrypt hash + credential sys_account). Optional generated temporary password, returned ONCE in the response (never logged / persisted / audited). - POST /api/v1/auth/admin/set-user-password: shadows the stock better-auth route, which only honors the legacy role scalar; mirrors its hash + credential-account core through $context under the ADR-0068 gate, adds generate-password and must-change stamping. - sys_user.must_change_password + computeAuthGate branch (reuses PASSWORD_EXPIRED so the existing 403 seam and Console redirect apply); cleared by stampPasswordChangedAt on any completed password change. Gate activation via a lazy-TTL "any flagged user" cache mirroring _orgMfaCache, primed synchronously on the issuing node. - create_user / upgraded set_user_password actions on sys_user (pure schema; resultDialog reveals the temporary password one time). - features.admin now mirrors enabled.admin (SCIM forces admin on, ADR-0071) instead of advertising false in SCIM deployments. - Explicit best-effort sys_audit_log rows (better-auth writes bypass the ObjectQL hooks plugin-audit subscribes to). V1.5 — better-auth phoneNumber plugin (opt-in auth.plugins.phoneNumber): - Phone+password sign-in only (POST /sign-in/phone-number); sendOTP throws NOT_SUPPORTED until SMS infrastructure exists (tracked separately). - sys_user.phone_number (unique) / phone_number_verified, snake_case via buildPhoneNumberPluginSchema(). - Phone-only accounts get a placeholder email (u-@placeholder.invalid, RFC 2606 — never deliverable, never derived from the phone number so exports/logs can't leak it). Every mail callback (reset / invitation / magic link) refuses placeholder recipients with PLACEHOLDER_EMAIL. - Invitation/magic-link warn logs no longer print bearer URLs outside dev. Closes nothing yet — V2 (bulk import) follows on this branch. Ref #2766 #2758. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o --- .../src/identity/sys-user.object.ts | 130 ++++- .../src/admin-user-endpoints.test.ts | 354 +++++++++++++ .../plugin-auth/src/admin-user-endpoints.ts | 475 ++++++++++++++++++ .../plugin-auth/src/auth-manager.test.ts | 159 ++++++ .../plugins/plugin-auth/src/auth-manager.ts | 161 +++++- .../plugins/plugin-auth/src/auth-plugin.ts | 80 +++ .../plugin-auth/src/auth-schema-config.ts | 31 ++ packages/plugins/plugin-auth/src/index.ts | 2 + .../plugin-auth/src/placeholder-email.test.ts | 49 ++ .../plugin-auth/src/placeholder-email.ts | 47 ++ packages/spec/src/system/auth-config.zod.ts | 17 + 11 files changed, 1495 insertions(+), 10 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts create mode 100644 packages/plugins/plugin-auth/src/admin-user-endpoints.ts create mode 100644 packages/plugins/plugin-auth/src/placeholder-email.test.ts create mode 100644 packages/plugins/plugin-auth/src/placeholder-email.ts diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 37d5f88762..564d9c195e 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -114,6 +114,63 @@ export const SysUser = ObjectSchema.create({ successMessage: 'Account unlocked', refreshAfter: true, }, + { + // #2766 V1 — a platform admin can add a login-capable teammate without + // the email-dependent invite flow. Hits the plugin-auth wrapper route + // (NOT better-auth's stock /admin/create-user): the wrapper runs the + // ADR-0068 admin gate, drives the better-auth pipeline (scrypt hash + + // credential sys_account), stamps must_change_password, and — when + // "Generate temporary password" is picked — returns the password ONCE + // in the response for the result dialog. It is never persisted or logged. + name: 'create_user', + label: 'Create User', + icon: 'user-plus', + variant: 'secondary', + locations: ['list_toolbar'], + type: 'api', + target: '/api/v1/auth/admin/create-user', + visible: 'features.admin == true', + successMessage: 'User created', + refreshAfter: true, + params: [ + // The endpoint requires email OR phone (phone-only users get a + // placeholder address; requires auth.plugins.phoneNumber). + { field: 'email', required: false }, + { + name: 'phoneNumber', + label: 'Phone Number', + type: 'text', + required: false, + helpText: 'Sign-in phone number (E.164, e.g. +8613800000000). Required when no email is given.', + }, + { field: 'name', required: false }, + { + name: 'generatePassword', + label: 'Generate Temporary Password', + type: 'boolean', + required: false, + defaultValue: true, + }, + { name: 'password', label: 'Password (leave empty to generate)', type: 'text', required: false }, + { + name: 'mustChangePassword', + label: 'Require Password Change On First Login', + type: 'boolean', + required: false, + defaultValue: true, + }, + ], + resultDialog: { + title: 'User Created', + description: + 'Copy the temporary password now — it is shown only once and never stored.', + acknowledge: 'I have saved this password', + fields: [ + { path: 'user.email', label: 'Email', format: 'text' }, + { path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' }, + ], + }, + }, { name: 'set_user_password', label: 'Set Password', @@ -121,13 +178,41 @@ export const SysUser = ObjectSchema.create({ variant: 'secondary', locations: ['list_item'], type: 'api', + // #2766 V1 — same path as better-auth's stock route, but served by the + // plugin-auth wrapper registered ahead of the catch-all: it accepts the + // ADR-0068 platform-admin signals (the stock handler only honors the + // legacy role scalar), can mint a temporary password, and stamps + // must_change_password. target: '/api/v1/auth/admin/set-user-password', recordIdParam: 'userId', successMessage: 'Password updated', refreshAfter: false, params: [ - { name: 'newPassword', label: 'New Password', type: 'text', required: true }, + { + name: 'generatePassword', + label: 'Generate Temporary Password', + type: 'boolean', + required: false, + defaultValue: false, + }, + { name: 'newPassword', label: 'New Password (leave empty to generate)', type: 'text', required: false }, + { + name: 'mustChangePassword', + label: 'Require Password Change On Next Login', + type: 'boolean', + required: false, + defaultValue: true, + }, ], + resultDialog: { + title: 'Password Updated', + description: + 'If a temporary password was generated, copy it now — it is shown only once and never stored.', + acknowledge: 'Done', + fields: [ + { path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' }, + ], + }, }, { name: 'set_user_role', @@ -460,6 +545,46 @@ export const SysUser = ObjectSchema.create({ description: 'Timestamp of the last password change. Backs password_expiry_days; system-managed.', }), + // #2766 V1.5 — phone-number sign-in identifier (better-auth phoneNumber + // plugin, mapped via buildPhoneNumberPluginSchema). Unique when present; + // null for email-only accounts. Written through better-auth, not CRUD. + phone_number: Field.text({ + label: 'Phone Number', + required: false, + readonly: true, + searchable: true, + maxLength: 32, + group: 'Account', + description: + 'Sign-in phone number (E.164 recommended). Unique per user; managed by ' + + 'better-auth when the phoneNumber plugin is enabled.', + }), + + phone_number_verified: Field.boolean({ + label: 'Phone Verified', + defaultValue: false, + readonly: true, + group: 'Account', + description: + 'Whether the phone number has been verified (OTP verification requires ' + + 'SMS infrastructure; false until that ships). System-managed.', + }), + + // #2766 V1 — admin-issued "must change password on next sign-in" flag. + // Set by the /admin/create-user and /admin/set-user-password routes when + // a temporary password is issued; enforced through the ADR-0069 authGate + // (surfaces as PASSWORD_EXPIRED); cleared by stampPasswordChangedAt once + // the user completes a password change. + must_change_password: Field.boolean({ + label: 'Must Change Password', + defaultValue: false, + readonly: true, + group: 'Admin', + description: + 'When true, the user is blocked (403 PASSWORD_EXPIRED) until they change ' + + 'their password. Stamped by the admin user-management routes; system-managed.', + }), + // ADR-0069 D3 — when enforced MFA first applied to this user; starts the // grace clock. Stamped lazily at session validation; system-managed. mfa_required_at: Field.datetime({ @@ -574,6 +699,9 @@ export const SysUser = ObjectSchema.create({ indexes: [ { fields: ['email'], unique: true }, { fields: ['created_at'], unique: false }, + // #2766 V1.5 — phone sign-in identifier; unique when present (null for + // email-only accounts), also the upsert match key for identity import. + { fields: ['phone_number'], unique: true }, ], enable: { diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts new file mode 100644 index 0000000000..a16e5f4036 --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -0,0 +1,354 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + runAdminCreateUser, + runAdminSetUserPassword, + generateTemporaryPassword, + type AdminUserEndpointDeps, + type AdminActor, +} from './admin-user-endpoints.js'; + +const ACTOR: AdminActor = { id: 'admin-1', email: 'admin@example.com' }; + +function makeRequest(body: unknown): Request { + return new Request('http://localhost/api/v1/auth/admin/create-user', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function makeDeps(overrides: Partial> = {}) { + const createUser = vi.fn(async ({ body }: any) => ({ + user: { id: 'user-9', email: body.email, name: body.name }, + })); + const engineUpdate = vi.fn(async () => ({})); + const engineCreate = vi.fn(async () => ({})); + const authCtx = { + password: { + hash: vi.fn(async (pw: string) => `hashed(${pw})`), + config: { minPasswordLength: 8, maxPasswordLength: 128 }, + }, + internalAdapter: { + findUserById: vi.fn(async () => ({ id: 'user-9' })), + findAccounts: vi.fn(async () => [{ providerId: 'credential' }]), + updatePassword: vi.fn(async () => ({})), + createAccount: vi.fn(async () => ({})), + }, + }; + const warn = vi.fn(); + const noteMustChangePasswordIssued = vi.fn(); + const deps: AdminUserEndpointDeps = { + getAuthApi: async () => ({ createUser }) as any, + getAuthContext: async () => authCtx as any, + getDataEngine: () => ({ update: engineUpdate, create: engineCreate }), + assertPasswordComplexity: vi.fn(async () => undefined), + noteMustChangePasswordIssued, + logger: { warn }, + ...overrides, + }; + return { deps, createUser, engineUpdate, engineCreate, authCtx, warn, noteMustChangePasswordIssued }; +} + +/** + * Security red line (#2766): no mock the endpoint touched may ever have seen + * the plaintext password outside the better-auth hashing surface. + */ +function expectNoPasswordLeak(mocks: ReturnType, password: string) { + const persistedCalls = [ + ...mocks.engineCreate.mock.calls, // audit rows + ...mocks.warn.mock.calls, // logs + ]; + for (const call of persistedCalls) { + expect(JSON.stringify(call)).not.toContain(password); + } + // must_change_password stamps must not carry the password either + for (const call of mocks.engineUpdate.mock.calls) { + expect(JSON.stringify(call)).not.toContain(password); + } +} + +describe('generateTemporaryPassword', () => { + it('meets the 4-class complexity policy and min length', () => { + for (let i = 0; i < 50; i++) { + const pw = generateTemporaryPassword(); + expect(pw.length).toBeGreaterThanOrEqual(16); + expect(/[a-z]/.test(pw)).toBe(true); + expect(/[A-Z]/.test(pw)).toBe(true); + expect(/[0-9]/.test(pw)).toBe(true); + expect(/[^A-Za-z0-9]/.test(pw)).toBe(true); + } + }); + + it('produces distinct values', () => { + expect(generateTemporaryPassword()).not.toBe(generateTemporaryPassword()); + }); +}); + +describe('runAdminCreateUser', () => { + it('rejects a missing/invalid email without calling createUser', async () => { + const m = makeDeps(); + const res = await runAdminCreateUser(m.deps, makeRequest({ name: 'x', generatePassword: true }), ACTOR); + expect(res.status).toBe(400); + expect(m.createUser).not.toHaveBeenCalled(); + + const res2 = await runAdminCreateUser(m.deps, makeRequest({ email: 'not-an-email', generatePassword: true }), ACTOR); + expect(res2.status).toBe(400); + }); + + it('rejects when neither password nor generatePassword is provided', async () => { + const m = makeDeps(); + const res = await runAdminCreateUser(m.deps, makeRequest({ email: 'a@b.co' }), ACTOR); + expect(res.status).toBe(400); + expect(m.createUser).not.toHaveBeenCalled(); + }); + + it('rejects when both password and generatePassword are provided', async () => { + const m = makeDeps(); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'a@b.co', password: 'Explicit1!', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(400); + }); + + it('creates a user via better-auth, stamps must_change_password, audits, and returns the temp password once', async () => { + const m = makeDeps(); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'New.User@Example.com', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.user.id).toBe('user-9'); + // email is normalized to lowercase before hitting better-auth + expect(m.createUser.mock.calls[0][0].body.email).toBe('new.user@example.com'); + // name defaults to the email local part + expect(m.createUser.mock.calls[0][0].body.name).toBe('New.User'); + const temp = data.temporaryPassword as string; + expect(typeof temp).toBe('string'); + expect(temp.length).toBeGreaterThanOrEqual(16); + + // must_change_password stamped true + gate cache primed + expect(m.engineUpdate).toHaveBeenCalledWith( + 'sys_user', + { id: 'user-9', must_change_password: true }, + expect.anything(), + ); + expect(m.noteMustChangePasswordIssued).toHaveBeenCalled(); + + // audit row written without password material + expect(m.engineCreate).toHaveBeenCalledTimes(1); + const [auditObject, auditRow] = m.engineCreate.mock.calls[0]; + expect(auditObject).toBe('sys_audit_log'); + expect(auditRow.object_name).toBe('sys_user'); + expect(auditRow.record_id).toBe('user-9'); + expect(JSON.parse(auditRow.metadata).passwordGenerated).toBe(true); + + expectNoPasswordLeak(m, temp); + }); + + it('checks complexity for an explicit password and does not return it', async () => { + const m = makeDeps(); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'a@b.co', password: 'Str0ng!Pass', mustChangePassword: false }), + ACTOR, + ); + expect(res.status).toBe(200); + expect(m.deps.assertPasswordComplexity).toHaveBeenCalledWith('Str0ng!Pass'); + expect((res.body.data as any).temporaryPassword).toBeUndefined(); + // mustChangePassword: false → no stamp + expect(m.engineUpdate).not.toHaveBeenCalled(); + expectNoPasswordLeak(m, 'Str0ng!Pass'); + }); + + it('maps a complexity violation to 400 with the policy code', async () => { + const m = makeDeps({ + assertPasswordComplexity: vi.fn(async () => { + throw { code: 'PASSWORD_POLICY_VIOLATION', message: 'too weak' }; + }), + }); + const res = await runAdminCreateUser(m.deps, makeRequest({ email: 'a@b.co', password: 'weak' }), ACTOR); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('PASSWORD_POLICY_VIOLATION'); + expect(m.createUser).not.toHaveBeenCalled(); + }); + + it('maps USER_ALREADY_EXISTS to 409', async () => { + const m = makeDeps(); + m.createUser.mockRejectedValueOnce({ + statusCode: 400, + body: { code: 'USER_ALREADY_EXISTS', message: 'User already exists' }, + }); + const res = await runAdminCreateUser(m.deps, makeRequest({ email: 'a@b.co', generatePassword: true }), ACTOR); + expect(res.status).toBe(409); + expect(res.body.error?.code).toBe('USER_ALREADY_EXISTS'); + }); + + it('reports mustChangePassword: false when the stamp fails (fail-open honesty)', async () => { + const m = makeDeps(); + m.engineUpdate.mockRejectedValueOnce(new Error('db down')); + const res = await runAdminCreateUser(m.deps, makeRequest({ email: 'a@b.co', generatePassword: true }), ACTOR); + expect(res.status).toBe(200); + expect((res.body.data as any).mustChangePassword).toBe(false); + expect(m.warn).toHaveBeenCalled(); + expectNoPasswordLeak(m, (res.body.data as any).temporaryPassword); + }); + + // ── #2766 V1.5: phone-only users ──────────────────────────────────────── + + it('rejects phone-only creation when the phoneNumber plugin is off', async () => { + const m = makeDeps({ phoneNumberEnabled: () => false }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ phoneNumber: '+8613800000000', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(400); + expect(m.createUser).not.toHaveBeenCalled(); + }); + + it('creates a phone-only user with a placeholder email that never contains the phone number', async () => { + const m = makeDeps({ phoneNumberEnabled: () => true }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ phoneNumber: '+86 138-0000-0000', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const sent = m.createUser.mock.calls[0][0].body; + expect(sent.email).toMatch(/@placeholder\.invalid$/); + expect(sent.email).not.toContain('138'); + expect(sent.data.phoneNumber).toBe('+8613800000000'); // normalized + expect(sent.name).toBe('+8613800000000'); // defaults to the phone + expect((res.body.data as any).placeholderEmail).toBe(true); + expectNoPasswordLeak(m, (res.body.data as any).temporaryPassword); + }); + + it('rejects a malformed phone number', async () => { + const m = makeDeps({ phoneNumberEnabled: () => true }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ phoneNumber: 'not-a-phone', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(400); + }); + + it('rejects when neither email nor phone is given', async () => { + const m = makeDeps({ phoneNumberEnabled: () => true }); + const res = await runAdminCreateUser(m.deps, makeRequest({ generatePassword: true }), ACTOR); + expect(res.status).toBe(400); + }); + + it('email + phone together: real email wins, phone stored', async () => { + const m = makeDeps({ phoneNumberEnabled: () => true }); + const res = await runAdminCreateUser( + m.deps, + makeRequest({ email: 'a@b.co', phoneNumber: '+8613800000000', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + const sent = m.createUser.mock.calls[0][0].body; + expect(sent.email).toBe('a@b.co'); + expect(sent.data.phoneNumber).toBe('+8613800000000'); + expect((res.body.data as any).placeholderEmail).toBe(false); + }); +}); + +describe('runAdminSetUserPassword', () => { + it('requires userId', async () => { + const m = makeDeps(); + const res = await runAdminSetUserPassword(m.deps, makeRequest({ generatePassword: true }), ACTOR); + expect(res.status).toBe(400); + }); + + it('404s for an unknown user', async () => { + const m = makeDeps(); + m.authCtx.internalAdapter.findUserById.mockResolvedValueOnce(null); + const res = await runAdminSetUserPassword( + m.deps, + makeRequest({ userId: 'ghost', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(404); + }); + + it('updates the credential account when one exists', async () => { + const m = makeDeps(); + const res = await runAdminSetUserPassword( + m.deps, + makeRequest({ userId: 'user-9', newPassword: 'Str0ng!Pass' }), + ACTOR, + ); + expect(res.status).toBe(200); + expect(m.authCtx.password.hash).toHaveBeenCalledWith('Str0ng!Pass'); + expect(m.authCtx.internalAdapter.updatePassword).toHaveBeenCalledWith('user-9', 'hashed(Str0ng!Pass)'); + expect(m.authCtx.internalAdapter.createAccount).not.toHaveBeenCalled(); + // default mustChangePassword: true + expect(m.engineUpdate).toHaveBeenCalledWith( + 'sys_user', + { id: 'user-9', must_change_password: true }, + expect.anything(), + ); + expectNoPasswordLeak(m, 'Str0ng!Pass'); + }); + + it('creates a credential account for SSO-onboarded users without one', async () => { + const m = makeDeps(); + m.authCtx.internalAdapter.findAccounts.mockResolvedValueOnce([{ providerId: 'oidc' }]); + const res = await runAdminSetUserPassword( + m.deps, + makeRequest({ userId: 'user-9', generatePassword: true }), + ACTOR, + ); + expect(res.status).toBe(200); + expect(m.authCtx.internalAdapter.createAccount).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-9', providerId: 'credential' }), + ); + const temp = (res.body.data as any).temporaryPassword as string; + expect(typeof temp).toBe('string'); + expectNoPasswordLeak(m, temp); + }); + + it('enforces better-auth min password length', async () => { + const m = makeDeps(); + const res = await runAdminSetUserPassword( + m.deps, + makeRequest({ userId: 'user-9', newPassword: 'Ab1!' }), + ACTOR, + ); + expect(res.status).toBe(400); + expect(m.authCtx.internalAdapter.updatePassword).not.toHaveBeenCalled(); + }); + + it('mustChangePassword: false clears any pending flag instead of setting it', async () => { + const m = makeDeps(); + const res = await runAdminSetUserPassword( + m.deps, + makeRequest({ userId: 'user-9', newPassword: 'Str0ng!Pass', mustChangePassword: false }), + ACTOR, + ); + expect(res.status).toBe(200); + expect(m.engineUpdate).toHaveBeenCalledWith( + 'sys_user', + { id: 'user-9', must_change_password: false }, + expect.anything(), + ); + expect((res.body.data as any).mustChangePassword).toBe(false); + }); + + it('audits without password material', async () => { + const m = makeDeps(); + await runAdminSetUserPassword(m.deps, makeRequest({ userId: 'user-9', generatePassword: true }), ACTOR); + const [auditObject, auditRow] = m.engineCreate.mock.calls[0]; + expect(auditObject).toBe('sys_audit_log'); + const meta = JSON.parse(auditRow.metadata); + expect(meta.event).toBe('user.admin_password_set'); + expect(meta.passwordGenerated).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts new file mode 100644 index 0000000000..271b8b988a --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -0,0 +1,475 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Admin "direct user management" endpoints (#2766 V1). + * + * `sys_user` is `managedBy:'better-auth'` — generic CRUD is suppressed because + * a plain ObjectQL insert would bypass better-auth's password hashing and never + * create the `sys_account` credential row, producing a user that can never sign + * in. Until now the only way to add a teammate was the `invite_user` action, + * which hard-depends on a wired EmailService. These endpoints give platform + * admins a first-class, email-independent path: + * + * POST /api/v1/auth/admin/create-user — create a login-capable account + * POST /api/v1/auth/admin/set-user-password — (re)set a password + force change + * + * Both are wrapped as pure `runXxx(deps, request)` handlers (the + * set-initial-password.ts pattern) so the HTTP mount in auth-plugin.ts stays a + * thin shell and the logic is unit-testable with a mocked deps surface. + * + * Authorization: the HTTP route owns the ADR-0068 platform-admin gate + * (isPlatformAdmin / positions / legacy role scalar). `createUser` is then + * invoked WITHOUT request headers — better-auth treats a header-less server + * call as trusted and skips its own `role === 'admin'` check, which under + * ADR-0068 no longer matches every platform admin. set-user-password + * re-implements the native handler's core (hash + credential-account upsert) + * through `$context` for the same reason: the stock endpoint's adminMiddleware + * would 403 a platform admin whose legacy `role` scalar was never synthesized. + * + * Security red line: a generated temporary password exists ONLY in the HTTP + * response body (surfaced once via the action's resultDialog). It is never + * logged, never written to the audit payload, never persisted. + */ + +/** Minimal better-auth server-api surface used by create-user. */ +export interface AdminCreateCapableApi { + createUser(opts: { + body: { + email: string; + name: string; + password?: string; + role?: string | string[]; + data?: Record; + }; + }): Promise<{ user?: { id?: string; email?: string; name?: string } } | null>; +} + +/** + * Minimal better-auth `$context` surface used by set-user-password. Mirrors + * what the stock `/admin/set-user-password` handler touches, minus its + * role check (our route gates instead). + */ +export interface AuthContextLike { + password: { + hash(pw: string): Promise; + config: { minPasswordLength: number; maxPasswordLength: number }; + }; + internalAdapter: { + findUserById(id: string): Promise; + findAccounts(userId: string): Promise>; + updatePassword(userId: string, hash: string): Promise; + createAccount(account: { + userId: string; + providerId: string; + accountId: string; + password: string; + }): Promise; + }; +} + +export interface AdminUserEndpointDeps { + getAuthApi(): Promise; + getAuthContext(): Promise; + /** ObjectQL engine (may be undefined when the data plugin isn't wired). */ + getDataEngine(): AdminUserDataEngine | undefined; + /** ADR-0069 D1 class-mix validator; throws `{ code, message }` on violation. */ + assertPasswordComplexity(pw: string): Promise; + /** + * Prime the process-local "someone must change their password" gate cache + * so the authGate activates immediately on this node (other nodes catch up + * on the cache TTL). Best-effort. + */ + noteMustChangePasswordIssued(): void; + /** Is the better-auth phoneNumber plugin wired (#2766 V1.5)? */ + phoneNumberEnabled?(): boolean; + logger?: { warn(msg: string): void }; +} + +export interface AdminUserDataEngine { + update(object: string, doc: Record, opts?: unknown): Promise; + create(object: string, doc: Record, opts?: unknown): Promise; +} + +/** The gated caller, passed by the route after its ADR-0068 check. */ +export interface AdminActor { + id: string; + email?: string; +} + +export interface EndpointResult { + status: number; + body: { + success: boolean; + data?: Record; + error?: { code: string; message: string }; + }; +} + +import { generatePlaceholderEmail } from './placeholder-email.js'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +// ── Temporary password generation ──────────────────────────────────────── +// +// Must satisfy the ADR-0069 class-mix policy (lower + upper + digit + symbol) +// and the better-auth min length regardless of deployment config, so one +// guaranteed character per class + random fill. Ambiguous glyphs (O/0, l/1) +// are excluded — these passwords are read off a dialog and typed once. +const LOWER = 'abcdefghijkmnopqrstuvwxyz'; +const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; +const DIGIT = '23456789'; +const SYMBOL = '!@#$%^&*-_=+'; +const ALL = LOWER + UPPER + DIGIT + SYMBOL; + +export function generateTemporaryPassword(length = 16): string { + const size = Math.max(length, 12); + const bytes = new Uint32Array(size); + globalThis.crypto.getRandomValues(bytes); + const pick = (set: string, rnd: number) => set[rnd % set.length]; + const chars: string[] = [ + pick(LOWER, bytes[0]), + pick(UPPER, bytes[1]), + pick(DIGIT, bytes[2]), + pick(SYMBOL, bytes[3]), + ]; + for (let i = 4; i < size; i++) chars.push(pick(ALL, bytes[i])); + // Fisher–Yates with fresh randomness so the guaranteed classes aren't + // always in the first four positions. + const shuffle = new Uint32Array(size); + globalThis.crypto.getRandomValues(shuffle); + for (let i = size - 1; i > 0; i--) { + const j = shuffle[i] % (i + 1); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + return chars.join(''); +} + +// ── Shared helpers ─────────────────────────────────────────────────────── + +async function parseJson(request: Request): Promise> { + try { + const parsed = await request.json(); + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function badRequest(message: string): EndpointResult { + return { status: 400, body: { success: false, error: { code: 'invalid_request', message } } }; +} + +/** + * Resolve the password for a create/set request: an explicit `password` / + * `newPassword`, or a generated temporary one when `generatePassword` is true. + * Returns an EndpointResult on validation failure. + */ +async function resolvePassword( + deps: Pick, + body: Record, + explicitKey: 'password' | 'newPassword', +): Promise<{ password: string; generated: boolean } | EndpointResult> { + const explicit = body[explicitKey]; + const generate = body.generatePassword === true; + if (generate && explicit !== undefined) { + return badRequest(`Provide either ${explicitKey} or generatePassword, not both`); + } + if (generate) return { password: generateTemporaryPassword(), generated: true }; + if (typeof explicit !== 'string' || explicit.length === 0) { + return badRequest(`${explicitKey} is required (or set generatePassword: true)`); + } + try { + await deps.assertPasswordComplexity(explicit); + } catch (error) { + const e = error as { code?: string; message?: string } | null; + return { + status: 400, + body: { + success: false, + error: { + code: e?.code ?? 'PASSWORD_POLICY_VIOLATION', + message: e?.message ?? 'Password does not meet the complexity policy', + }, + }, + }; + } + return { password: explicit, generated: false }; +} + +/** + * Stamp `sys_user.must_change_password`. Best-effort — the flag drives a + * fail-open gate, so a failed stamp must not fail the whole operation; we + * surface the *actual* stamped state in the response instead. + */ +async function stampMustChangePassword( + deps: AdminUserEndpointDeps, + userId: string, + value: boolean, +): Promise { + const engine = deps.getDataEngine(); + if (!engine) return false; + try { + await engine.update('sys_user', { id: userId, must_change_password: value }, { + context: SYSTEM_CTX, + }); + if (value) deps.noteMustChangePasswordIssued(); + return true; + } catch (error) { + deps.logger?.warn( + `[AuthPlugin] failed to stamp must_change_password for user ${userId}: ${ + (error as Error)?.message ?? error + }`, + ); + return false; + } +} + +/** + * Best-effort explicit audit row. better-auth writes bypass the ObjectQL + * lifecycle hooks that plugin-audit subscribes to, so admin identity + * operations would otherwise leave no compliance trail. Never throws; never + * includes password material (red line). + */ +async function writeAdminAudit( + deps: AdminUserEndpointDeps, + entry: { + action: 'create' | 'update'; + actor: AdminActor; + recordId: string; + metadata: Record; + }, +): Promise { + const engine = deps.getDataEngine(); + if (!engine) return; + try { + await engine.create( + 'sys_audit_log', + { + action: entry.action, + user_id: entry.actor.id, + actor: entry.actor.id, + object_name: 'sys_user', + record_id: entry.recordId, + metadata: JSON.stringify(entry.metadata), + }, + { context: SYSTEM_CTX }, + ); + } catch { + // plugin-audit may not be installed (no sys_audit_log table) — audit is + // best-effort by design here; the operation itself must not fail. + } +} + +function mapAuthApiError(error: unknown, fallback: string): EndpointResult { + const e = error as { + statusCode?: number; + status?: number | string; + body?: { code?: string; message?: string }; + message?: string; + } | null; + const code = e?.body?.code ?? 'internal'; + const message = e?.body?.message ?? e?.message ?? fallback; + const rawStatus = + typeof e?.statusCode === 'number' ? e.statusCode : typeof e?.status === 'number' ? e.status : 500; + const status = code === 'USER_ALREADY_EXISTS' ? 409 : rawStatus; + return { status, body: { success: false, error: { code, message } } }; +} + +// ── POST /admin/create-user ────────────────────────────────────────────── + +export async function runAdminCreateUser( + deps: AdminUserEndpointDeps, + request: Request, + actor: AdminActor, +): Promise { + const body = await parseJson(request); + + // #2766 V1.5 — identity: a real email, a phone number, or both. Phone-only + // users get a generated placeholder address (better-auth requires a unique + // email) that every mail callback refuses to send to. + const rawEmail = body.email; + const hasEmail = typeof rawEmail === 'string' && rawEmail.trim().length > 0; + if (hasEmail && !/^\S+@\S+\.\S+$/.test((rawEmail as string).trim())) { + return badRequest('A valid email is required'); + } + const rawPhone = body.phoneNumber ?? body.phone_number; + const hasPhone = typeof rawPhone === 'string' && rawPhone.trim().length > 0; + let phoneNumber: string | undefined; + if (hasPhone) { + if (!deps.phoneNumberEnabled?.()) { + return badRequest('Phone numbers require the phoneNumber auth plugin (auth.plugins.phoneNumber)'); + } + phoneNumber = normalizePhoneNumber(String(rawPhone)); + if (!phoneNumber) { + return badRequest('phoneNumber must be a valid phone number (E.164 recommended, e.g. +8613800000000)'); + } + } + if (!hasEmail && !phoneNumber) { + return badRequest('Either email or phoneNumber is required'); + } + const email = hasEmail ? (rawEmail as string).trim() : generatePlaceholderEmail(); + const name = + typeof body.name === 'string' && body.name.trim().length > 0 + ? body.name.trim() + : hasEmail + ? email.split('@')[0] + : (phoneNumber as string); + const role = typeof body.role === 'string' && body.role.length > 0 ? body.role : undefined; + const mustChangePassword = body.mustChangePassword !== false; // default true + + const resolved = await resolvePassword(deps, body, 'password'); + if ('status' in resolved) return resolved; + + let created: { user?: { id?: string; email?: string; name?: string } } | null; + try { + const authApi = await deps.getAuthApi(); + // Header-less server call: trusted, skips better-auth's role check — the + // HTTP route already ran the ADR-0068 platform-admin gate. + created = await authApi.createUser({ + body: { + email: email.toLowerCase(), + name, + password: resolved.password, + ...(role ? { role } : {}), + // phoneNumber is a user-model field contributed by the phoneNumber + // plugin's schema; `data` is better-auth's carrier for such fields. + ...(phoneNumber ? { data: { phoneNumber } } : {}), + }, + }); + } catch (error) { + return mapAuthApiError(error, 'create-user failed'); + } + + const userId = created?.user?.id; + if (typeof userId !== 'string' || userId.length === 0) { + return { + status: 500, + body: { success: false, error: { code: 'internal', message: 'better-auth returned no user id' } }, + }; + } + + const stamped = mustChangePassword + ? await stampMustChangePassword(deps, userId, true) + : false; + + await writeAdminAudit(deps, { + action: 'create', + actor, + recordId: userId, + metadata: { + event: 'user.admin_created', + email: email.toLowerCase(), + ...(phoneNumber ? { phoneNumber } : {}), + ...(role ? { role } : {}), + placeholderEmail: !hasEmail, + passwordGenerated: resolved.generated, + mustChangePassword: stamped, + }, + }); + + return { + status: 200, + body: { + success: true, + data: { + user: { + id: userId, + email: created?.user?.email ?? email.toLowerCase(), + name, + ...(phoneNumber ? { phoneNumber } : {}), + }, + placeholderEmail: !hasEmail, + mustChangePassword: stamped, + ...(resolved.generated ? { temporaryPassword: resolved.password } : {}), + }, + }, + }; +} + +/** + * Light phone normalization: strip separators, require 6–15 digits with an + * optional leading `+` (E.164 recommended). Returns undefined when invalid. + */ +export function normalizePhoneNumber(raw: string): string | undefined { + const stripped = raw.replace(/[\s\-().]/g, ''); + return /^\+?[0-9]{6,15}$/.test(stripped) ? stripped : undefined; +} + +// ── POST /admin/set-user-password ──────────────────────────────────────── + +export async function runAdminSetUserPassword( + deps: AdminUserEndpointDeps, + request: Request, + actor: AdminActor, +): Promise { + const body = await parseJson(request); + + const userId = body.userId ?? body.user_id; + if (typeof userId !== 'string' || userId.length === 0) { + return badRequest('userId is required'); + } + const mustChangePassword = body.mustChangePassword !== false; // default true + + const resolved = await resolvePassword(deps, body, 'newPassword'); + if ('status' in resolved) return resolved; + + try { + const authCtx = await deps.getAuthContext(); + + const { minPasswordLength, maxPasswordLength } = authCtx.password.config; + if (resolved.password.length < minPasswordLength) { + return badRequest(`Password must be at least ${minPasswordLength} characters`); + } + if (resolved.password.length > maxPasswordLength) { + return badRequest(`Password must be at most ${maxPasswordLength} characters`); + } + + if (!(await authCtx.internalAdapter.findUserById(userId))) { + return { status: 404, body: { success: false, error: { code: 'not_found', message: 'User not found' } } }; + } + + // Mirrors the stock /admin/set-user-password core: hash, then update the + // credential account or create one for SSO/invite-onboarded users. + const hashed = await authCtx.password.hash(resolved.password); + const accounts = await authCtx.internalAdapter.findAccounts(userId); + if (accounts.find((a) => a?.providerId === 'credential')) { + await authCtx.internalAdapter.updatePassword(userId, hashed); + } else { + await authCtx.internalAdapter.createAccount({ + userId, + providerId: 'credential', + accountId: userId, + password: hashed, + }); + } + } catch (error) { + return mapAuthApiError(error, 'set-user-password failed'); + } + + const stamped = mustChangePassword + ? await stampMustChangePassword(deps, userId, true) + : await stampMustChangePassword(deps, userId, false); + + await writeAdminAudit(deps, { + action: 'update', + actor, + recordId: userId, + metadata: { + event: 'user.admin_password_set', + passwordGenerated: resolved.generated, + mustChangePassword: mustChangePassword && stamped, + }, + }); + + return { + status: 200, + body: { + success: true, + data: { + userId, + mustChangePassword: mustChangePassword && stamped, + ...(resolved.generated ? { temporaryPassword: resolved.password } : {}), + }, + }, + }; +} diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 18d76a2136..58ae8d947a 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1040,6 +1040,102 @@ describe('AuthManager', () => { }); }); + // #2766 V1.5 — phone+password sign-in via better-auth's phone-number plugin. + describe('phone-number plugin (#2766 V1.5)', () => { + const boot = async (plugins?: any) => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + ...(plugins ? { plugins } : {}), + }); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + return { manager, capturedConfig }; + }; + + it('is NOT registered by default', async () => { + const { capturedConfig } = await boot(); + expect(capturedConfig.plugins.find((p: any) => p.id === 'phone-number')).toBeUndefined(); + }); + + it('registers with snake_case schema mapping when enabled', async () => { + const { capturedConfig } = await boot({ phoneNumber: true }); + const plugin = capturedConfig.plugins.find((p: any) => p.id === 'phone-number'); + expect(plugin).toBeDefined(); + }); + + it('features.phoneNumber mirrors the plugin switch', async () => { + const { manager } = await boot({ phoneNumber: true }); + expect((manager.getPublicConfig() as any).features.phoneNumber).toBe(true); + expect(manager.isPhoneNumberEnabled()).toBe(true); + const { manager: off } = await boot(); + expect((off.getPublicConfig() as any).features.phoneNumber).toBe(false); + expect(off.isPhoneNumberEnabled()).toBe(false); + }); + }); + + // #2766 V1.5 — placeholder addresses must never become real recipients. + describe('placeholder-email interception (#2766 V1.5)', () => { + const PLACEHOLDER = 'u-abcdefghijklmnopqrst@placeholder.invalid'; + const boot = async (extra: any = {}) => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + emailAndPassword: { enabled: true }, + ...extra, + }); + const emailService = { sendTemplate: vi.fn(async () => ({ status: 'sent' })) }; + manager.setEmailService(emailService as any); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + return { manager, capturedConfig, emailService }; + }; + + it('sendResetPassword refuses a placeholder recipient without touching the transport', async () => { + const { capturedConfig, emailService } = await boot(); + const sendResetPassword = capturedConfig.emailAndPassword.sendResetPassword; + await expect( + sendResetPassword({ user: { id: 'u1', email: PLACEHOLDER }, url: 'http://x/reset', token: 't' }), + ).rejects.toThrow(/PLACEHOLDER_EMAIL/); + expect(emailService.sendTemplate).not.toHaveBeenCalled(); + }); + + it('sendInvitationEmail refuses a placeholder recipient', async () => { + const { capturedConfig, emailService } = await boot(); + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await expect( + orgPlugin._opts.sendInvitationEmail({ + email: PLACEHOLDER, + invitation: { id: 'inv1', organizationId: 'o1', role: 'member' }, + organization: { name: 'Org' }, + inviter: { user: { email: 'admin@example.com' } }, + }), + ).rejects.toThrow(/PLACEHOLDER_EMAIL/); + expect(emailService.sendTemplate).not.toHaveBeenCalled(); + }); + + // NOTE: the magic-link guard is the same isPlaceholderEmail() branch, but + // unlike organization the magic-link plugin doesn't expose its options + // (`_opts`), so it can't be invoked directly here. Covered by the two + // tests above plus the placeholder-email unit tests. + it('magic-link plugin registers when enabled (guard shares the tested code path)', async () => { + const { capturedConfig } = await boot({ plugins: { magicLink: true } }); + expect(capturedConfig.plugins.find((p: any) => p.id === 'magic-link')).toBeDefined(); + }); + }); + describe('getPublicConfig', () => { it('should return safe public configuration', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -1109,6 +1205,7 @@ describe('AuthManager', () => { ssoEnforced: false, deviceAuthorization: false, admin: false, + phoneNumber: false, multiOrgEnabled: false, privacyUrl: 'https://objectstack.ai/privacy', termsUrl: 'https://objectstack.ai/terms', @@ -2021,6 +2118,68 @@ describe('AuthManager', () => { expect(arg.id).toBe('u1'); expect(arg.password_changed_at instanceof Date).toBe(true); }); + + it('stampPasswordChangedAt clears must_change_password in the same write (#2766 V1)', async () => { + const engine = makeEngine({ id: 'u1' }); + const m = mgr(engine); + await (m as any).stampPasswordChangedAt('u1'); + const arg = engine.update.mock.calls[0][1]; + expect(arg.must_change_password).toBe(false); + }); + }); + + // #2766 V1: admin-issued force password change — reuses the auth-gate seam. + describe('must-change-password gate (#2766 V1)', () => { + const SECRET = 'test-secret-at-least-32-chars-long'; + const makeEngine = (user: any) => ({ + findOne: vi.fn(async (_o: string, q: any) => { + const w = q?.where ?? {}; + if (!user) return null; + return Object.entries(w).every(([k, v]) => (user as any)[k] === v) ? user : null; + }), + update: vi.fn(async () => ({})), + count: vi.fn(async () => 0), + }); + const mgr = (engine: any, extra: any = {}) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra }); + warn.mockRestore(); + return m; + }; + + it('noteMustChangePasswordIssued activates the gate immediately (all config off)', () => { + const m = mgr(makeEngine(null), {}); + expect(m.isAuthGateActive()).toBe(false); + m.noteMustChangePasswordIssued(); + expect(m.isAuthGateActive()).toBe(true); + }); + + it('returns PASSWORD_EXPIRED for a flagged user with every config feature off', async () => { + const m = mgr(makeEngine({ id: 'u1', must_change_password: true }), {}); + m.noteMustChangePasswordIssued(); // as the admin route does + const gate = await (m as any).computeAuthGate('u1', undefined, false); + expect(gate?.code).toBe('PASSWORD_EXPIRED'); + }); + + it('does NOT gate an unflagged user even when the cache is hot', async () => { + const m = mgr(makeEngine({ id: 'u1', must_change_password: false }), {}); + m.noteMustChangePasswordIssued(); + await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); + }); + + it('stays a no-op (no DB read) when nothing is flagged and config is off', async () => { + const engine = makeEngine({ id: 'u1', must_change_password: true }); + const m = mgr(engine, {}); + await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); + expect(engine.findOne).not.toHaveBeenCalled(); + }); + + it('fails open when the user lookup throws', async () => { + const engine = { findOne: vi.fn(async () => { throw new Error('db down'); }), update: vi.fn(), count: vi.fn(async () => 1) }; + const m = mgr(engine, {}); + m.noteMustChangePasswordIssued(); + await expect((m as any).computeAuthGate('u1', undefined, false)).resolves.toBeUndefined(); + }); }); // ADR-0069 D3: enforced MFA — reuses the auth-gate seam (same computeAuthGate). diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 4ada8c3681..7881ed765c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -16,6 +16,7 @@ import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveOrgLimit, isMcpS import { mapMembershipRole, BUILTIN_IDENTITY_PLATFORM_ADMIN } from '@objectstack/spec'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; +import { isPlaceholderEmail } from './placeholder-email.js'; import { AUTH_USER_CONFIG, AUTH_SESSION_CONFIG, @@ -27,6 +28,7 @@ import { buildDeviceAuthorizationPluginSchema, buildJwtPluginSchema, buildAdminPluginSchema, + buildPhoneNumberPluginSchema, } from './auth-schema-config.js'; /** @@ -485,6 +487,14 @@ export class AuthManager { // Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap. private _orgMfaCache: { value: boolean; at: number } = { value: false, at: 0 }; private _orgMfaRefreshing = false; + // #2766 V1 — cached "does any user have must_change_password set" flag, so + // the admin-issued temp-password gate activates without making + // isAuthGateActive() (and thus every request's extra session read) hot in + // deployments that never use the feature. Same lazy-TTL pattern as + // _orgMfaCache; primed synchronously by noteMustChangePasswordIssued() on + // the node that issues a flag (other nodes catch up within the TTL). + private _mustChangeCache: { value: boolean; at: number } = { value: false, at: 0 }; + private _mustChangeRefreshing = false; /** * Result of the dev-only admin seed (set by `AuthPlugin.maybeSeedDevAdmin` @@ -627,6 +637,16 @@ export class AuthManager { ...(this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null ? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {}), sendResetPassword: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }) => { + // #2766 V1.5 — placeholder addresses (phone-only users) are never + // real recipients. Refuse loudly instead of "sending" into the void; + // the reset path for these users is phone sign-in / an admin + // set-user-password, not email. + if (isPlaceholderEmail(user.email)) { + throw new Error( + `Password-reset email refused: ${user.email} is a placeholder address (PLACEHOLDER_EMAIL). ` + + 'This account has no real mailbox — use an admin password reset instead.', + ); + } const email = this.getEmailService(); if (!email) { // No transport wired but password reset is enabled — a @@ -1242,6 +1262,9 @@ export class AuthManager { oidcProvider: resolveOidcProviderEnabled(pluginConfig), deviceAuthorization: pluginConfig.deviceAuthorization ?? false, admin: pluginConfig.admin ?? scimEffective, + // #2766 V1.5 — phone+password sign-in. Opt-in; OTP flows stay off until + // SMS infrastructure exists (tracked separately). + phoneNumber: (pluginConfig as any).phoneNumber ?? false, sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false, ssoDomainVerification: ssoDomainVerifyFromEnv ?? (pluginConfig as any).ssoDomainVerification ?? false, scim: scimEffective, @@ -1438,13 +1461,22 @@ export class AuthManager { sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => { const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, ''); const acceptUrl = `${baseUrl}/accept-invitation/${invitation.id}`; + // #2766 V1.5 — placeholder addresses are never real recipients. + if (isPlaceholderEmail(recipientEmail)) { + throw new Error( + `Invitation email refused: ${recipientEmail} is a placeholder address (PLACEHOLDER_EMAIL).`, + ); + } const emailService = this.getEmailService(); if (!emailService) { + // #2766 — the accept URL is a bearer credential; only print it in + // dev, never in production logs. + const dev = (globalThis as any)?.process?.env?.NODE_ENV !== 'production'; console.warn( `[AuthManager] Invitation email not configured. ` + `To: ${recipientEmail} (org: ${org?.name ?? invitation.organizationId}, ` + - `role: ${invitation.role}, inviter: ${inviter?.user?.email ?? 'unknown'}) ` + - `URL: ${acceptUrl}`, + `role: ${invitation.role}, inviter: ${inviter?.user?.email ?? 'unknown'})` + + (dev ? ` URL: ${acceptUrl}` : ' (accept URL suppressed outside dev)'), ); return; } @@ -1507,15 +1539,45 @@ export class AuthManager { })); } + if (enabled.phoneNumber) { + const { phoneNumber } = await import('better-auth/plugins/phone-number'); + // #2766 V1.5 — phone+password sign-in only (`POST /sign-in/phone-number`). + // The OTP surface needs an SMS provider the platform doesn't have yet: + // `sendOTP` throws explicitly so /phone-number/send-otp fails loudly + // (NOT_SUPPORTED) instead of silently logging, and signUpOnVerification + // is deliberately NOT configured — phone-only accounts are created by + // the admin create-user/import routes with a placeholder email + // (see placeholder-email.ts), never by OTP self-signup. + plugins.push(phoneNumber({ + schema: buildPhoneNumberPluginSchema(), + sendOTP: () => { + throw new Error( + 'NOT_SUPPORTED: phone-number OTP requires an SMS delivery service, which is not configured. ' + + 'Phone sign-in is password-based (POST /sign-in/phone-number).', + ); + }, + })); + } + if (enabled.magicLink) { const { magicLink } = await import('better-auth/plugins/magic-link'); // magic-link reuses the `verification` table — no extra schema mapping needed. plugins.push(magicLink({ sendMagicLink: async ({ email: recipientEmail, url, token }) => { + // #2766 V1.5 — placeholder addresses are never real recipients. + if (isPlaceholderEmail(recipientEmail)) { + throw new Error( + `Magic-link email refused: ${recipientEmail} is a placeholder address (PLACEHOLDER_EMAIL).`, + ); + } const emailService = this.getEmailService(); if (!emailService) { + // #2766 — a magic link IS a session credential; only print it in + // dev, never in production logs. + const dev = (globalThis as any)?.process?.env?.NODE_ENV !== 'production'; console.warn( - `[AuthManager] Magic-link requested for ${recipientEmail} but no email service is wired. URL: ${url}`, + `[AuthManager] Magic-link requested for ${recipientEmail} but no email service is wired.` + + (dev ? ` URL: ${url}` : ' (link suppressed outside dev)'), ); return; } @@ -2314,7 +2376,13 @@ export class AuthManager { // for the env owner / local admin. Driven by `ssoOnlyMode` / `OS_AUTH_SSO_ONLY`. ssoEnforced: ssoOnly, deviceAuthorization: pluginConfig.deviceAuthorization ?? false, - admin: pluginConfig.admin ?? false, + // Mirrors `enabled.admin` in buildPluginList() (SCIM forces the admin + // plugin on, ADR-0071) — previously `?? false`, which advertised the + // admin surface as absent in SCIM-enabled deployments where it was + // actually mounted, hiding the admin sys_user actions (#2766 V1). + admin: pluginConfig.admin ?? (readBooleanEnv('OS_SCIM_ENABLED') ?? (pluginConfig as any).scim ?? false), + // #2766 V1.5 — mirrors `enabled.phoneNumber` in buildPluginList(). + phoneNumber: (pluginConfig as any).phoneNumber ?? false, ...(termsUrl ? { termsUrl } : {}), ...(privacyUrl ? { privacyUrl } : {}), }; @@ -2721,6 +2789,15 @@ export class AuthManager { } } + /** + * #2766 V1 — public seam for the admin user-management routes, which accept + * admin-supplied passwords outside the better-auth endpoint hooks that + * normally run assertPasswordComplexity. + */ + public async checkPasswordComplexity(password: string): Promise { + return this.assertPasswordComplexity(password); + } + /** * ADR-0069 — is any authentication-policy gate enabled? Cheap, synchronous; * lets the transport seams skip session lookups entirely when off (the @@ -2728,15 +2805,63 @@ export class AuthManager { */ public isAuthGateActive(): boolean { // Per-org MFA (no global flag) can still activate the gate — keep the cheap - // sync check honest by consulting a lazily-refreshed cache. + // sync check honest by consulting a lazily-refreshed cache. Same for + // admin-issued must-change-password flags (#2766 V1). this.refreshOrgMfaCacheIfStale(); + this.refreshMustChangeCacheIfStale(); return ( Math.floor(Number(this.config.passwordExpiryDays) || 0) > 0 || this.config.mfaRequired === true || - this._orgMfaCache.value + this._orgMfaCache.value || + this._mustChangeCache.value ); } + /** + * #2766 V1.5 — is the phoneNumber plugin wired? Mirrors `enabled.phoneNumber` + * in buildPluginList() / `features.phoneNumber` in getPublicConfig(). + */ + public isPhoneNumberEnabled(): boolean { + return ((this.config.plugins ?? {}) as any).phoneNumber === true; + } + + /** + * #2766 V1 — flip the "someone must change their password" cache on this + * node the moment an admin issues a flag, so the gate is enforced on the + * flagged user's very next request instead of after the cache TTL. Called by + * the admin create-user / set-user-password routes. + */ + public noteMustChangePasswordIssued(): void { + this._mustChangeCache = { value: true, at: Date.now() }; + } + + /** + * #2766 V1 — refresh the "any user has must_change_password" cache in the + * background when stale (60s TTL). Mirrors refreshOrgMfaCacheIfStale: the + * flag clears itself once no flagged users remain, returning the gate to + * zero overhead. + */ + private refreshMustChangeCacheIfStale(): void { + if (this._mustChangeRefreshing) return; + if (Date.now() - this._mustChangeCache.at < 60_000) return; + const engine = this.getDataEngine(); + if (!engine) return; + this._mustChangeRefreshing = true; + void (async () => { + try { + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + const n = await engine.count('sys_user', { + where: { must_change_password: true }, context: SYSTEM_CTX, + } as any); + this._mustChangeCache = { value: typeof n === 'number' && n > 0, at: Date.now() }; + } catch { + // leave the prior value; try again after the TTL + } finally { + this._mustChangeRefreshing = false; + } + })(); + } + /** * ADR-0069 — refresh the "any org requires MFA" cache in the background when * stale (60s TTL). Fire-and-forget: a brand-new per-org requirement activates @@ -2781,14 +2906,19 @@ export class AuthManager { const mfaGlobal = this.config.mfaRequired === true; // Per-org tightening: an org may require MFA above the global floor. const orgMaybeRequires = !mfaGlobal && !!_activeOrgId && this._orgMfaCache.value; - if (expiryDays <= 0 && !mfaGlobal && !orgMaybeRequires) return undefined; // no gate feature active + // #2766 V1 — an admin-issued must-change-password flag activates the gate + // even with every config feature off (per-user state, cached like org MFA). + const mustChangeMaybe = this._mustChangeCache.value; + if (expiryDays <= 0 && !mfaGlobal && !orgMaybeRequires && !mustChangeMaybe) { + return undefined; // no gate feature active + } const engine = this.getDataEngine(); if (!engine || !userId) return undefined; try { const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; const u = await engine.findOne('sys_user', { where: { id: userId }, - fields: ['password_changed_at', 'two_factor_enabled', 'mfa_required_at'], + fields: ['password_changed_at', 'two_factor_enabled', 'mfa_required_at', 'must_change_password'], context: SYSTEM_CTX, } as any); @@ -2801,6 +2931,17 @@ export class AuthManager { mfaRequired = org?.require_mfa === true || org?.require_mfa === 1; } + // ── Admin-issued force change (#2766 V1) ────────────────────────── + // Reuses the PASSWORD_EXPIRED code so the existing transport-seam 403 + // handling and the Console change-password redirect apply unchanged; + // the semantic is identical ("must change password to continue"). + if (u?.must_change_password === true || u?.must_change_password === 1) { + return { + code: 'PASSWORD_EXPIRED', + message: 'You must change your password before continuing.', + }; + } + // ── Password expiry ─────────────────────────────────────────────── if (expiryDays > 0) { const changed = u?.password_changed_at; @@ -2852,9 +2993,11 @@ export class AuthManager { if (!engine || !userId) return; try { const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + // #2766 V1 — a completed password change also satisfies any pending + // admin-issued force-change flag, so clear it in the same write. await engine.update( 'sys_user', - { id: userId, password_changed_at: new Date() }, + { id: userId, password_changed_at: new Date(), must_change_password: false }, { context: SYSTEM_CTX } as any, ); } catch { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 6e658496b0..1b4bbdcad2 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1166,6 +1166,86 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // #2766 V1 — admin direct user management. `sys_user` CRUD is suppressed + // (managedBy better-auth), and until now the only add-a-teammate path was + // the email-dependent invite flow. These routes let a platform admin + // create a login-capable account (better-auth pipeline: scrypt hash + + // credential sys_account) and (re)set passwords, with an optional + // generated temporary password + a must-change-on-first-login flag. + // + // Both routes run the same ADR-0068 platform-admin gate as unlock-user + // above, then call trusted server-side better-auth surfaces — see + // admin-user-endpoints.ts for why the stock role check doesn't fit. + // + // NOTE: /admin/set-user-password intentionally SHADOWS better-auth's + // native route (registered before the catch-all below). The native + // handler only accepts the legacy `role === 'admin'` scalar, which + // ADR-0068 platform admins may not carry; this wrapper accepts the + // canonical platform-admin signals and adds the force-change stamp. + { + const adminUserDeps = (): import('./admin-user-endpoints.js').AdminUserEndpointDeps => ({ + getAuthApi: () => this.authManager!.getApi() as any, + getAuthContext: () => this.authManager!.getAuthContext(), + getDataEngine: () => this.authManager!.getDataEngine(), + assertPasswordComplexity: (pw: string) => this.authManager!.checkPasswordComplexity(pw), + noteMustChangePasswordIssued: () => this.authManager!.noteMustChangePasswordIssued(), + phoneNumberEnabled: () => this.authManager!.isPhoneNumberEnabled(), + logger: ctx.logger, + }); + const gateAdmin = async (c: any): Promise<{ id: string; email?: string } | Response> => { + const authApi = await this.authManager!.getApi(); + const session = await (authApi as any).getSession({ headers: c.req.raw.headers }); + if (!session?.user?.id) { + return c.json({ success: false, error: { code: 'unauthorized', message: 'Sign in first' } }, 401); + } + const u: any = session.user; + const isAdmin = + u?.isPlatformAdmin === true || + (Array.isArray(u?.positions) && u.positions.includes('platform_admin')) || + u?.role === 'admin'; + if (!isAdmin) { + return c.json({ success: false, error: { code: 'forbidden', message: 'Admin role required' } }, 403); + } + return { id: String(u.id), email: typeof u.email === 'string' ? u.email : undefined }; + }; + + rawApp.post(`${basePath}/admin/create-user`, async (c: any) => { + try { + const actor = await gateAdmin(c); + if (actor instanceof Response) return actor; + const authApi: any = await this.authManager!.getApi(); + if (typeof authApi.createUser !== 'function') { + return c.json( + { success: false, error: { code: 'not_supported', message: 'The better-auth admin plugin is not enabled (auth.plugins.admin)' } }, + 501, + ); + } + const { runAdminCreateUser } = await import('./admin-user-endpoints.js'); + const { status, body } = await runAdminCreateUser(adminUserDeps(), c.req.raw, actor); + return c.json(body, status as any); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] admin/create-user failed', err); + return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500); + } + }); + + rawApp.post(`${basePath}/admin/set-user-password`, async (c: any) => { + try { + const actor = await gateAdmin(c); + if (actor instanceof Response) return actor; + const { runAdminSetUserPassword } = await import('./admin-user-endpoints.js'); + const { status, body } = await runAdminSetUserPassword(adminUserDeps(), c.req.raw, actor); + return c.json(body, status as any); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] admin/set-user-password failed', err); + return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500); + } + }); + } + // ──────────────────────────────────────────────────────────────────── // ADR-0069 P3 — register a SAML 2.0 IdP. Mirrors the OIDC bridge above: // the metadata `register_saml_provider` action posts FLAT fields; the shared diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index db982ba0db..a3f51b1e1f 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -326,6 +326,23 @@ export const AUTH_ADMIN_SESSION_FIELDS = { impersonatedBy: 'impersonated_by', } as const; +// --------------------------------------------------------------------------- +// Phone-number plugin – user field additions (#2766 V1.5) +// --------------------------------------------------------------------------- + +/** + * Phone-number plugin adds sign-in-identifier fields to the `user` model. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | phoneNumber | phone_number | + * | phoneNumberVerified | phone_number_verified | + */ +export const AUTH_PHONE_NUMBER_USER_FIELDS = { + phoneNumber: 'phone_number', + phoneNumberVerified: 'phone_number_verified', +} as const; + // --------------------------------------------------------------------------- // OAuth Provider plugin – oauthClient table // --------------------------------------------------------------------------- @@ -552,6 +569,20 @@ export function buildAdminPluginSchema() { }; } +/** + * Builds the `schema` option for better-auth's `phoneNumber()` plugin + * (#2766 V1.5). The plugin extends the user model with `phoneNumber` / + * `phoneNumberVerified`; both differ from their snake_case column names and + * are mapped explicitly. + */ +export function buildPhoneNumberPluginSchema() { + return { + user: { + fields: AUTH_PHONE_NUMBER_USER_FIELDS, + }, + }; +} + // --------------------------------------------------------------------------- // Helper: build organization plugin schema option // --------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index d349b2258a..b1d3b1c42c 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -12,6 +12,8 @@ export * from './auth-plugin.js'; export * from './auth-manager.js'; export * from './ensure-default-organization.js'; export * from './set-initial-password.js'; +export * from './admin-user-endpoints.js'; +export * from './placeholder-email.js'; export * from './register-sso-provider.js'; export * from './objectql-adapter.js'; export * from './auth-schema-config.js'; diff --git a/packages/plugins/plugin-auth/src/placeholder-email.test.ts b/packages/plugins/plugin-auth/src/placeholder-email.test.ts new file mode 100644 index 0000000000..733af0b5cf --- /dev/null +++ b/packages/plugins/plugin-auth/src/placeholder-email.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + generatePlaceholderEmail, + isPlaceholderEmail, + PLACEHOLDER_EMAIL_DOMAIN, +} from './placeholder-email.js'; + +describe('placeholder-email (#2766 V1.5)', () => { + it('generates addresses under the reserved .invalid domain', () => { + const email = generatePlaceholderEmail(); + expect(email).toMatch(/^u-[a-z2-7]{20}@placeholder\.invalid$/); + expect(PLACEHOLDER_EMAIL_DOMAIN.endsWith('.invalid')).toBe(true); + }); + + it('round-trips through isPlaceholderEmail', () => { + expect(isPlaceholderEmail(generatePlaceholderEmail())).toBe(true); + }); + + it('never derives the local part from PII: real phone numbers cannot appear', () => { + // The local part is random base32 (alphabet a-z + 2-7): the digits 0/1/8/9 + // can never occur, so no real-world phone number (they all contain at + // least one of those in practice — and critically, the token is NOT + // derived from the phone at all). + for (let i = 0; i < 100; i++) { + const local = generatePlaceholderEmail().split('@')[0]; + expect(/[0189]/.test(local)).toBe(false); + expect(local).not.toContain('13800000000'); + } + }); + + it('generates distinct values', () => { + const seen = new Set(Array.from({ length: 100 }, () => generatePlaceholderEmail())); + expect(seen.size).toBe(100); + }); + + it('rejects real addresses and junk', () => { + expect(isPlaceholderEmail('alice@example.com')).toBe(false); + expect(isPlaceholderEmail('u-abc@placeholder.invalid.example.com')).toBe(false); + expect(isPlaceholderEmail(null)).toBe(false); + expect(isPlaceholderEmail(undefined)).toBe(false); + expect(isPlaceholderEmail(42)).toBe(false); + }); + + it('recognition is case-insensitive on the domain', () => { + expect(isPlaceholderEmail('u-abc@PLACEHOLDER.INVALID')).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-auth/src/placeholder-email.ts b/packages/plugins/plugin-auth/src/placeholder-email.ts new file mode 100644 index 0000000000..5847f86c34 --- /dev/null +++ b/packages/plugins/plugin-auth/src/placeholder-email.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Placeholder emails for users without a real address (#2766 V1.5). + * + * better-auth's user model requires a unique email, so employees who sign in + * with a phone number (or, later, an imported username) still need one. The + * placeholder is designed to be safe on three axes: + * + * 1. **Never deliverable.** The domain is under `.invalid` — an RFC 2606 + * reserved TLD that can never resolve — so even if a send-guard is missed, + * no mail can physically leave for a real mailbox. + * 2. **Never leaks the phone number.** `sys_user` has `exportCsv` enabled and + * the email column shows up in Console lists, exports, and logs. The local + * part is a random opaque token, NOT derived from the phone number (or any + * other PII), so nothing spreads with it. + * 3. **Cheaply recognizable.** Every auth email callback (reset password, + * invitation, magic link) checks `isPlaceholderEmail()` and refuses with an + * explicit error instead of attempting delivery. + */ + +export const PLACEHOLDER_EMAIL_DOMAIN = 'placeholder.invalid'; + +const TOKEN_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567'; // base32, lowercase +const TOKEN_LENGTH = 20; + +/** + * Mint a fresh placeholder address: `u-@placeholder.invalid`. + * The token is random (not user-derived) — see the module doc for why. + */ +export function generatePlaceholderEmail(): string { + const bytes = new Uint32Array(TOKEN_LENGTH); + globalThis.crypto.getRandomValues(bytes); + let token = ''; + for (let i = 0; i < TOKEN_LENGTH; i++) { + token += TOKEN_ALPHABET[bytes[i] % TOKEN_ALPHABET.length]; + } + return `u-${token}@${PLACEHOLDER_EMAIL_DOMAIN}`; +} + +/** Is this address one of ours (or anything else that can never deliver)? */ +export function isPlaceholderEmail(email: unknown): boolean { + return ( + typeof email === 'string' && + email.toLowerCase().endsWith(`@${PLACEHOLDER_EMAIL_DOMAIN}`) + ); +} diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index 7984caecfb..d4dee3904a 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -100,6 +100,23 @@ export const AuthPluginConfigSchema = lazySchema(() => z.object({ admin: z.boolean().default(false).describe( 'Enable platform admin operations (ban/unban, set-password, impersonate, set-role)', ), + /** + * Enable better-auth's `phone-number` plugin so a phone number is a + * first-class sign-in identifier (#2766 V1.5). Scope note: only the + * phone+password surface is wired — `POST /sign-in/phone-number` — because + * the OTP flows (`/phone-number/send-otp`, `/phone-number/verify`, + * OTP-based reset) require an SMS delivery infrastructure that is tracked + * separately. Employees without an email address are created with a + * generated placeholder address (never a real recipient — see + * placeholder-email.ts) and sign in with phone + password. + * + * The plugin augments `sys_user` with `phone_number` (unique) and + * `phone_number_verified`, mapped to snake_case by + * `buildPhoneNumberPluginSchema()`. + */ + phoneNumber: z.boolean().default(false).describe( + 'Enable phone-number sign-in (phone + password; OTP flows require separate SMS infrastructure)', + ), })); /** From d0fefbebc350268138200f12972d24c7a903b24c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 10:08:18 +0000 Subject: [PATCH 2/5] refactor(rest): extract prepareImportRequest + CSV/xlsx parsers to import-prepare.ts (#2766 V2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbatim move out of rest-server.ts (module-private → exported) so the identity import endpoint in plugin-auth can accept payloads byte-identical to the generic /data/:object/import routes without re-implementing the parsers. rest-server.ts imports the extracted module; the two call sites and behavior are unchanged (import-integration tests as the regression gate). Also exports the import building blocks (runImport, coerceRow, buildFieldMetaMap, types) from the package index for cross-package reuse. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o --- packages/rest/src/import-prepare.ts | 337 ++++++++++++++++++++++++++++ packages/rest/src/index.ts | 25 +++ packages/rest/src/rest-server.ts | 323 +------------------------- 3 files changed, 363 insertions(+), 322 deletions(-) create mode 100644 packages/rest/src/import-prepare.ts diff --git a/packages/rest/src/import-prepare.ts b/packages/rest/src/import-prepare.ts new file mode 100644 index 0000000000..4e6ce72066 --- /dev/null +++ b/packages/rest/src/import-prepare.ts @@ -0,0 +1,337 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Bulk-import request parsing — extracted verbatim from rest-server.ts + * (#2766 V2) so consumers outside the generic `/data/:object/import` routes + * (the identity import endpoint in plugin-auth) can accept byte-identical + * payloads (rows[]/csv/xlsxBase64, mapping in either shape, writeMode + + * matchFields) without re-implementing the parsers. + */ + +import { + buildFieldMetaMap, + type ExportFieldMeta, +} from './export-format.js'; +import { resolveNamedMapping, applyMappingToRows, type MappingArtifactLike } from './import-mapping.js'; + +/** + * Detect the `getMetaItem` response envelope (`{ type, name, item, lock, … }`) + * whose translatable metadata document is nested at `.item`. The cached read + * path and `getMetaItems` element shape hand back the already-unwrapped + * document instead, so translation helpers must distinguish the two: an + * envelope carries a nested `item` object alongside its own `type`/`name`, + * which a bare metadata document never does. + */ +export function isMetaEnvelope(value: any): boolean { + return !!value + && typeof value === 'object' + && typeof value.type === 'string' + && typeof value.name === 'string' + && value.item != null + && typeof value.item === 'object' + && !Array.isArray(value.item); +} + +/** + * Minimal RFC-4180-style CSV parser used by the bulk-import endpoint + * (M10.9). Handles quoted fields (including embedded quotes via "" and + * embedded commas/newlines) and both CRLF and LF line endings. + * + * The first non-empty line is treated as the header row. Header names + * can be re-mapped to canonical field names via the optional `mapping` + * argument (e.g. `{ "First Name": "first_name" }`); unmapped headers + * pass through unchanged. Empty cells become empty strings. + * + * Kept dependency-free so REST stays runtime-portable (Hono / Express + * adapters both consume this without pulling a CSV lib transitively). + */ +export function parseCsvToRows(csv: string, mapping: Record = {}): Array> { + const text = csv.replace(/^\uFEFF/, ''); // strip BOM + const cells: string[][] = []; + let cur = ''; + let row: string[] = []; + let inQuotes = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inQuotes) { + if (ch === '"') { + if (text[i + 1] === '"') { cur += '"'; i++; } + else { inQuotes = false; } + } else { + cur += ch; + } + continue; + } + if (ch === '"') { inQuotes = true; continue; } + if (ch === ',') { row.push(cur); cur = ''; continue; } + if (ch === '\r') { continue; } + if (ch === '\n') { + row.push(cur); cur = ''; + cells.push(row); row = []; + continue; + } + cur += ch; + } + if (cur.length > 0 || row.length > 0) { row.push(cur); cells.push(row); } + + // Drop fully-empty trailing rows so a stray newline at EOF doesn't + // produce a phantom empty record. + while (cells.length > 0 && cells[cells.length - 1].every(c => c === '')) cells.pop(); + if (cells.length < 2) return []; + + const header = cells[0].map(h => h.trim()); + const fields = header.map(h => mapping[h] ?? h); + const out: Array> = []; + for (let r = 1; r < cells.length; r++) { + const row = cells[r]; + const obj: Record = {}; + for (let c = 0; c < fields.length; c++) { + const key = fields[c]; + if (!key) continue; + const raw = row[c] ?? ''; + obj[key] = raw; + } + out.push(obj); + } + return out; +} + +/** + * Flatten one ExcelJS cell value to the raw string the coercion layer expects. + * ExcelJS hands back rich objects for formulas / hyperlinks / rich text / dates; + * we reduce each to the human-visible text so a server-parsed xlsx yields the + * same cells a CSV export would (dates → ISO, so parseDateCell can re-read them). + */ +function xlsxCellToString(value: any): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value); + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'object') { + // Formula cell → prefer its computed result. + if ('result' in value && value.result !== undefined && value.result !== null) return xlsxCellToString(value.result); + // Hyperlink cell → the visible text, not the target. + if ('text' in value && typeof value.text === 'string') return value.text; + if ('hyperlink' in value && typeof value.hyperlink === 'string') return value.hyperlink; + // Rich text → concatenate runs. + if (Array.isArray(value.richText)) return value.richText.map((r: any) => r?.text ?? '').join(''); + if ('error' in value && value.error) return String(value.error); + } + try { return String(value); } catch { return ''; } +} + +/** + * Parse an .xlsx workbook (raw bytes) into row objects, mirroring + * {@link parseCsvToRows}: first non-empty row is the header, each subsequent row + * becomes `{ header→cell }` with the optional `mapping` renaming columns. Reads + * the named/indexed `sheet` when given, else the first worksheet. Dynamically + * imports ExcelJS (already a dependency of the export path) so CSV/JSON imports + * don't pay for it. + */ +export async function parseXlsxToRows( + buffer: Buffer | ArrayBuffer, + mapping: Record = {}, + sheet?: string | number, +): Promise>> { + const ExcelJS: any = (await import('exceljs')).default ?? (await import('exceljs')); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(buffer); + const ws = sheet !== undefined ? wb.getWorksheet(sheet as any) : wb.worksheets[0]; + if (!ws) return []; + + const cells: string[][] = []; + ws.eachRow({ includeEmpty: false }, (row: any) => { + const values = row.values as any[]; // 1-based; index 0 is unused + const line: string[] = []; + for (let c = 1; c < values.length; c++) line.push(xlsxCellToString(values[c])); + cells.push(line); + }); + while (cells.length > 0 && cells[cells.length - 1].every(c => c === '')) cells.pop(); + if (cells.length < 2) return []; + + const header = cells[0].map(h => h.trim()); + const fields = header.map(h => mapping[h] ?? h); + const out: Array> = []; + for (let r = 1; r < cells.length; r++) { + const line = cells[r]; + const obj: Record = {}; + for (let c = 0; c < fields.length; c++) { + const key = fields[c]; + if (!key) continue; + obj[key] = line[c] ?? ''; + } + out.push(obj); + } + return out; +} + +/** Everything the import runner needs, parsed & validated from a request body. */ +export interface PreparedImport { + rows: Array>; + metaMap: Map; + writeMode: 'insert' | 'update' | 'upsert'; + matchFields: string[]; + dryRun: boolean; + runAutomations: boolean; + trimWhitespace: boolean; + nullValues?: string[]; + createMissingOptions: boolean; + skipBlankMatchKey: boolean; +} + +export type PrepareImportResult = + | { ok: true; prepared: PreparedImport } + | { ok: false; status: number; code: string; error: string }; + +/** + * Parse & validate a bulk-import request body into a {@link PreparedImport}. + * + * Shared by the synchronous `POST /data/:object/import` route and the async + * import-job create route so both accept byte-identical payloads (writeMode + + * matchFields, mapping in either shape, rows[]/csv/xlsxBase64) and resolve the + * same field metadata. The only knob that differs is `maxRows` (5k sync vs + * 50k async). Returns a discriminated result; the caller maps `!ok` to an HTTP + * error using the returned status/code/error. + */ +export async function prepareImportRequest( + body: any, + opts: { p: any; objectName: string; environmentId?: string; maxRows: number }, +): Promise { + const { p, objectName, environmentId, maxRows } = opts; + const dryRun = body?.dryRun === true; + + let writeMode: 'insert' | 'update' | 'upsert' = + body?.writeMode === 'update' || body?.writeMode === 'upsert' ? body.writeMode : 'insert'; + let matchFields: string[] = Array.isArray(body?.matchFields) + ? body.matchFields.filter((f: any) => typeof f === 'string' && f.length > 0) + : []; + const runAutomations = body?.runAutomations === true; + const trimWhitespace = body?.trimWhitespace !== false; + const nullValues: string[] | undefined = Array.isArray(body?.nullValues) + ? body.nullValues.filter((v: any) => typeof v === 'string') + : undefined; + const createMissingOptions = body?.createMissingOptions === true; + const skipBlankMatchKey = body?.skipBlankMatchKey === true; + + // ── Named mapping artifact (#2611) ──────────────────────────────── + // `mappingName` references a registered `mapping` artifact + // (defineMapping / stack `mappings:`) — the reusable, governed form for + // recurring & programmatic imports. Mutually exclusive with the inline + // `mapping` rename: one mapping source of truth per request. + const mappingName: string | undefined = + typeof body?.mappingName === 'string' && body.mappingName.length > 0 ? body.mappingName : undefined; + const hasInlineMapping = + (Array.isArray(body?.mapping) && body.mapping.length > 0) || + (!Array.isArray(body?.mapping) && body?.mapping && typeof body.mapping === 'object' && Object.keys(body.mapping).length > 0); + if (mappingName && hasInlineMapping) { + return { ok: false, status: 400, code: 'CONFLICTING_MAPPING', error: 'Provide either mappingName or an inline mapping, not both' }; + } + let mappingArtifact: MappingArtifactLike | undefined; + if (mappingName) { + const detectedFormat: 'csv' | 'json' | 'xlsx' | undefined = + (body?.format === 'json' && Array.isArray(body?.rows)) || Array.isArray(body) ? 'json' + : typeof body?.csv === 'string' ? 'csv' + : typeof body?.xlsxBase64 === 'string' ? 'xlsx' + : undefined; + if (!detectedFormat) { + return { ok: false, status: 400, code: 'INVALID_REQUEST', error: 'Provide format:"csv" with csv text, format:"json" with rows[], or format:"xlsx" with xlsxBase64' }; + } + const resolved = await resolveNamedMapping(p, { mappingName, objectName, detectedFormat }); + if (!resolved.ok) return resolved; + mappingArtifact = resolved.artifact; + // Artifact-declared write semantics apply as DEFAULTS: an explicit + // request writeMode/matchFields wins; absent ones fall back to the + // artifact's mode/upsertKey. + if (body?.writeMode === undefined && (mappingArtifact.mode === 'update' || mappingArtifact.mode === 'upsert')) { + writeMode = mappingArtifact.mode; + } + if (matchFields.length === 0 && Array.isArray(mappingArtifact.upsertKey)) { + matchFields = mappingArtifact.upsertKey.filter((f) => typeof f === 'string' && f.length > 0); + } + } + + if (writeMode !== 'insert' && matchFields.length === 0) { + return { ok: false, status: 400, code: 'INVALID_REQUEST', error: `writeMode "${writeMode}" requires a non-empty matchFields[]` }; + } + + // Normalize `mapping` to a `{ sourceColumn: targetField }` record. Accepts + // either that compact form or a FieldMappingEntry[]. + const mapping: Record = {}; + if (Array.isArray(body?.mapping)) { + for (const e of body.mapping) { + if (e && typeof e.sourceField === 'string' && typeof e.targetField === 'string') { + mapping[e.sourceField] = e.targetField; + } + } + } else if (body?.mapping && typeof body.mapping === 'object') { + for (const [k, v] of Object.entries(body.mapping)) { + if (typeof v === 'string') mapping[k] = v; + } + } + const applyMapping = (row: Record): Record => { + if (Object.keys(mapping).length === 0) return row; + const out: Record = {}; + for (const [k, val] of Object.entries(row)) out[mapping[k] ?? k] = val; + return out; + }; + + // Build rows[] from JSON array, CSV text, or a base64 xlsx. + let rows: Array> = []; + if (body?.format === 'json' && Array.isArray(body.rows)) { + rows = (body.rows as Array>).map(applyMapping); + } else if ((body?.format === 'csv' || typeof body?.csv === 'string') && typeof body?.csv === 'string') { + rows = parseCsvToRows(body.csv, mapping); + } else if ((body?.format === 'xlsx' || typeof body?.xlsxBase64 === 'string') && typeof body?.xlsxBase64 === 'string') { + // Native server-side xlsx parse — the client uploads raw workbook bytes + // (base64) instead of pre-flattening to CSV. + try { + const buf = Buffer.from(body.xlsxBase64, 'base64'); + rows = await parseXlsxToRows(buf, mapping, body.sheet); + } catch (e: any) { + return { ok: false, status: 400, code: 'INVALID_REQUEST', error: `Failed to parse xlsx: ${e?.message ?? String(e)}` }; + } + } else if (Array.isArray(body)) { + // Permissive: a bare JSON array at the top level. + rows = (body as Array>).map(applyMapping); + } else { + return { ok: false, status: 400, code: 'INVALID_REQUEST', error: 'Provide format:"csv" with csv text, format:"json" with rows[], or format:"xlsx" with xlsxBase64' }; + } + + if (rows.length > maxRows) { + return { ok: false, status: 413, code: 'PAYLOAD_TOO_LARGE', error: `Import limit is ${maxRows} rows per request (got ${rows.length}).` }; + } + + // Apply the named mapping's fieldMapping pipeline (rename + transforms; + // strict projection — only mapped targets reach the write path). Inline + // `mapping` was empty in this branch, so rows still carry raw headers. + if (mappingArtifact) { + const applied = applyMappingToRows(rows, mappingArtifact); + if (!applied.ok) return applied; + rows = applied.rows; + } + + // Resolve the object's field metadata so cells coerce to storage values + // (booleans, numbers, dates→ISO, select label→code) and lookup names resolve + // to record ids. Best-effort: a failed lookup leaves `metaMap` empty and + // every value passes through untouched. + let metaMap = new Map(); + try { + let schema: any = undefined; + if (typeof p.getMetaItem === 'function') { + const r = await p.getMetaItem({ type: 'object', name: objectName }); + schema = isMetaEnvelope(r) ? r.item : r; + } + if (!schema && typeof p.getObjectSchema === 'function') { + schema = await p.getObjectSchema(objectName, environmentId); + } + metaMap = buildFieldMetaMap(schema); + } catch { /* pass-through coercion */ } + + return { + ok: true, + prepared: { + rows, metaMap, writeMode, matchFields, dryRun, runAutomations, + trimWhitespace, nullValues, createMissingOptions, skipBlankMatchKey, + }, + }; +} diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 2450aca6e8..02a5b54848 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -10,3 +10,28 @@ export type { RouteEntry } from './route-manager.js'; // REST API Plugin export { createRestApiPlugin } from './rest-api-plugin.js'; export type { RestApiPluginConfig } from './rest-api-plugin.js'; + +// Bulk-import building blocks (#2766 V2) — shared with the identity import +// endpoint in plugin-auth so it accepts payloads byte-identical to the +// generic /data/:object/import routes and reuses the same row engine. +export { + prepareImportRequest, + parseCsvToRows, + parseXlsxToRows, + isMetaEnvelope, +} from './import-prepare.js'; +export type { PreparedImport, PrepareImportResult } from './import-prepare.js'; +export { runImport } from './import-runner.js'; +export type { + ImportAction, + ImportRowResult, + ImportProgress, + ImportRunSummary, + ImportUndoLog, + ImportProtocolLike, + RunImportOptions, +} from './import-runner.js'; +export { coerceRow } from './import-coerce.js'; +export type { CoerceContext, RefResolver } from './import-coerce.js'; +export { buildFieldMetaMap } from './export-format.js'; +export type { ExportFieldMeta } from './export-format.js'; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5034b68a72..473a28e272 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -14,7 +14,7 @@ import { type ExportFieldMeta, } from './export-format.js'; import { runImport } from './import-runner.js'; -import { resolveNamedMapping, applyMappingToRows, type MappingArtifactLike } from './import-mapping.js'; +import { prepareImportRequest, isMetaEnvelope } from './import-prepare.js'; // Node-safe logger — avoids importing 'console' which is absent from ES2020 lib typings. const logError = (...args: unknown[]) => (globalThis as any).console?.error(...args); @@ -26,23 +26,6 @@ const logError = (...args: unknown[]) => (globalThis as any).console?.error(...a */ const TRANSLATABLE_META_TYPES = new Set(['view', 'action', 'object', 'app', 'dashboard']); -/** - * Detect the `getMetaItem` response envelope (`{ type, name, item, lock, … }`) - * whose translatable metadata document is nested at `.item`. The cached read - * path and `getMetaItems` element shape hand back the already-unwrapped - * document instead, so translation helpers must distinguish the two: an - * envelope carries a nested `item` object alongside its own `type`/`name`, - * which a bare metadata document never does. - */ -function isMetaEnvelope(value: any): boolean { - return !!value - && typeof value === 'object' - && typeof value.type === 'string' - && typeof value.name === 'string' - && value.item != null - && typeof value.item === 'object' - && !Array.isArray(value.item); -} /** * Map a data-layer error to a clean HTTP response. Unknown-object errors @@ -337,310 +320,6 @@ function isExpectedDataStatus(status: number): boolean { return status === 403 || status === 404 || status === 409 || status === 502 || status === 503; } -/** - * Minimal RFC-4180-style CSV parser used by the bulk-import endpoint - * (M10.9). Handles quoted fields (including embedded quotes via "" and - * embedded commas/newlines) and both CRLF and LF line endings. - * - * The first non-empty line is treated as the header row. Header names - * can be re-mapped to canonical field names via the optional `mapping` - * argument (e.g. `{ "First Name": "first_name" }`); unmapped headers - * pass through unchanged. Empty cells become empty strings. - * - * Kept dependency-free so REST stays runtime-portable (Hono / Express - * adapters both consume this without pulling a CSV lib transitively). - */ -function parseCsvToRows(csv: string, mapping: Record = {}): Array> { - const text = csv.replace(/^\uFEFF/, ''); // strip BOM - const cells: string[][] = []; - let cur = ''; - let row: string[] = []; - let inQuotes = false; - for (let i = 0; i < text.length; i++) { - const ch = text[i]; - if (inQuotes) { - if (ch === '"') { - if (text[i + 1] === '"') { cur += '"'; i++; } - else { inQuotes = false; } - } else { - cur += ch; - } - continue; - } - if (ch === '"') { inQuotes = true; continue; } - if (ch === ',') { row.push(cur); cur = ''; continue; } - if (ch === '\r') { continue; } - if (ch === '\n') { - row.push(cur); cur = ''; - cells.push(row); row = []; - continue; - } - cur += ch; - } - if (cur.length > 0 || row.length > 0) { row.push(cur); cells.push(row); } - - // Drop fully-empty trailing rows so a stray newline at EOF doesn't - // produce a phantom empty record. - while (cells.length > 0 && cells[cells.length - 1].every(c => c === '')) cells.pop(); - if (cells.length < 2) return []; - - const header = cells[0].map(h => h.trim()); - const fields = header.map(h => mapping[h] ?? h); - const out: Array> = []; - for (let r = 1; r < cells.length; r++) { - const row = cells[r]; - const obj: Record = {}; - for (let c = 0; c < fields.length; c++) { - const key = fields[c]; - if (!key) continue; - const raw = row[c] ?? ''; - obj[key] = raw; - } - out.push(obj); - } - return out; -} - -/** - * Flatten one ExcelJS cell value to the raw string the coercion layer expects. - * ExcelJS hands back rich objects for formulas / hyperlinks / rich text / dates; - * we reduce each to the human-visible text so a server-parsed xlsx yields the - * same cells a CSV export would (dates → ISO, so parseDateCell can re-read them). - */ -function xlsxCellToString(value: any): string { - if (value === null || value === undefined) return ''; - if (typeof value === 'string') return value; - if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value); - if (value instanceof Date) return value.toISOString(); - if (typeof value === 'object') { - // Formula cell → prefer its computed result. - if ('result' in value && value.result !== undefined && value.result !== null) return xlsxCellToString(value.result); - // Hyperlink cell → the visible text, not the target. - if ('text' in value && typeof value.text === 'string') return value.text; - if ('hyperlink' in value && typeof value.hyperlink === 'string') return value.hyperlink; - // Rich text → concatenate runs. - if (Array.isArray(value.richText)) return value.richText.map((r: any) => r?.text ?? '').join(''); - if ('error' in value && value.error) return String(value.error); - } - try { return String(value); } catch { return ''; } -} - -/** - * Parse an .xlsx workbook (raw bytes) into row objects, mirroring - * {@link parseCsvToRows}: first non-empty row is the header, each subsequent row - * becomes `{ header→cell }` with the optional `mapping` renaming columns. Reads - * the named/indexed `sheet` when given, else the first worksheet. Dynamically - * imports ExcelJS (already a dependency of the export path) so CSV/JSON imports - * don't pay for it. - */ -async function parseXlsxToRows( - buffer: Buffer | ArrayBuffer, - mapping: Record = {}, - sheet?: string | number, -): Promise>> { - const ExcelJS: any = (await import('exceljs')).default ?? (await import('exceljs')); - const wb = new ExcelJS.Workbook(); - await wb.xlsx.load(buffer); - const ws = sheet !== undefined ? wb.getWorksheet(sheet as any) : wb.worksheets[0]; - if (!ws) return []; - - const cells: string[][] = []; - ws.eachRow({ includeEmpty: false }, (row: any) => { - const values = row.values as any[]; // 1-based; index 0 is unused - const line: string[] = []; - for (let c = 1; c < values.length; c++) line.push(xlsxCellToString(values[c])); - cells.push(line); - }); - while (cells.length > 0 && cells[cells.length - 1].every(c => c === '')) cells.pop(); - if (cells.length < 2) return []; - - const header = cells[0].map(h => h.trim()); - const fields = header.map(h => mapping[h] ?? h); - const out: Array> = []; - for (let r = 1; r < cells.length; r++) { - const line = cells[r]; - const obj: Record = {}; - for (let c = 0; c < fields.length; c++) { - const key = fields[c]; - if (!key) continue; - obj[key] = line[c] ?? ''; - } - out.push(obj); - } - return out; -} - -/** Everything the import runner needs, parsed & validated from a request body. */ -interface PreparedImport { - rows: Array>; - metaMap: Map; - writeMode: 'insert' | 'update' | 'upsert'; - matchFields: string[]; - dryRun: boolean; - runAutomations: boolean; - trimWhitespace: boolean; - nullValues?: string[]; - createMissingOptions: boolean; - skipBlankMatchKey: boolean; -} - -type PrepareImportResult = - | { ok: true; prepared: PreparedImport } - | { ok: false; status: number; code: string; error: string }; - -/** - * Parse & validate a bulk-import request body into a {@link PreparedImport}. - * - * Shared by the synchronous `POST /data/:object/import` route and the async - * import-job create route so both accept byte-identical payloads (writeMode + - * matchFields, mapping in either shape, rows[]/csv/xlsxBase64) and resolve the - * same field metadata. The only knob that differs is `maxRows` (5k sync vs - * 50k async). Returns a discriminated result; the caller maps `!ok` to an HTTP - * error using the returned status/code/error. - */ -async function prepareImportRequest( - body: any, - opts: { p: any; objectName: string; environmentId?: string; maxRows: number }, -): Promise { - const { p, objectName, environmentId, maxRows } = opts; - const dryRun = body?.dryRun === true; - - let writeMode: 'insert' | 'update' | 'upsert' = - body?.writeMode === 'update' || body?.writeMode === 'upsert' ? body.writeMode : 'insert'; - let matchFields: string[] = Array.isArray(body?.matchFields) - ? body.matchFields.filter((f: any) => typeof f === 'string' && f.length > 0) - : []; - const runAutomations = body?.runAutomations === true; - const trimWhitespace = body?.trimWhitespace !== false; - const nullValues: string[] | undefined = Array.isArray(body?.nullValues) - ? body.nullValues.filter((v: any) => typeof v === 'string') - : undefined; - const createMissingOptions = body?.createMissingOptions === true; - const skipBlankMatchKey = body?.skipBlankMatchKey === true; - - // ── Named mapping artifact (#2611) ──────────────────────────────── - // `mappingName` references a registered `mapping` artifact - // (defineMapping / stack `mappings:`) — the reusable, governed form for - // recurring & programmatic imports. Mutually exclusive with the inline - // `mapping` rename: one mapping source of truth per request. - const mappingName: string | undefined = - typeof body?.mappingName === 'string' && body.mappingName.length > 0 ? body.mappingName : undefined; - const hasInlineMapping = - (Array.isArray(body?.mapping) && body.mapping.length > 0) || - (!Array.isArray(body?.mapping) && body?.mapping && typeof body.mapping === 'object' && Object.keys(body.mapping).length > 0); - if (mappingName && hasInlineMapping) { - return { ok: false, status: 400, code: 'CONFLICTING_MAPPING', error: 'Provide either mappingName or an inline mapping, not both' }; - } - let mappingArtifact: MappingArtifactLike | undefined; - if (mappingName) { - const detectedFormat: 'csv' | 'json' | 'xlsx' | undefined = - (body?.format === 'json' && Array.isArray(body?.rows)) || Array.isArray(body) ? 'json' - : typeof body?.csv === 'string' ? 'csv' - : typeof body?.xlsxBase64 === 'string' ? 'xlsx' - : undefined; - if (!detectedFormat) { - return { ok: false, status: 400, code: 'INVALID_REQUEST', error: 'Provide format:"csv" with csv text, format:"json" with rows[], or format:"xlsx" with xlsxBase64' }; - } - const resolved = await resolveNamedMapping(p, { mappingName, objectName, detectedFormat }); - if (!resolved.ok) return resolved; - mappingArtifact = resolved.artifact; - // Artifact-declared write semantics apply as DEFAULTS: an explicit - // request writeMode/matchFields wins; absent ones fall back to the - // artifact's mode/upsertKey. - if (body?.writeMode === undefined && (mappingArtifact.mode === 'update' || mappingArtifact.mode === 'upsert')) { - writeMode = mappingArtifact.mode; - } - if (matchFields.length === 0 && Array.isArray(mappingArtifact.upsertKey)) { - matchFields = mappingArtifact.upsertKey.filter((f) => typeof f === 'string' && f.length > 0); - } - } - - if (writeMode !== 'insert' && matchFields.length === 0) { - return { ok: false, status: 400, code: 'INVALID_REQUEST', error: `writeMode "${writeMode}" requires a non-empty matchFields[]` }; - } - - // Normalize `mapping` to a `{ sourceColumn: targetField }` record. Accepts - // either that compact form or a FieldMappingEntry[]. - const mapping: Record = {}; - if (Array.isArray(body?.mapping)) { - for (const e of body.mapping) { - if (e && typeof e.sourceField === 'string' && typeof e.targetField === 'string') { - mapping[e.sourceField] = e.targetField; - } - } - } else if (body?.mapping && typeof body.mapping === 'object') { - for (const [k, v] of Object.entries(body.mapping)) { - if (typeof v === 'string') mapping[k] = v; - } - } - const applyMapping = (row: Record): Record => { - if (Object.keys(mapping).length === 0) return row; - const out: Record = {}; - for (const [k, val] of Object.entries(row)) out[mapping[k] ?? k] = val; - return out; - }; - - // Build rows[] from JSON array, CSV text, or a base64 xlsx. - let rows: Array> = []; - if (body?.format === 'json' && Array.isArray(body.rows)) { - rows = (body.rows as Array>).map(applyMapping); - } else if ((body?.format === 'csv' || typeof body?.csv === 'string') && typeof body?.csv === 'string') { - rows = parseCsvToRows(body.csv, mapping); - } else if ((body?.format === 'xlsx' || typeof body?.xlsxBase64 === 'string') && typeof body?.xlsxBase64 === 'string') { - // Native server-side xlsx parse — the client uploads raw workbook bytes - // (base64) instead of pre-flattening to CSV. - try { - const buf = Buffer.from(body.xlsxBase64, 'base64'); - rows = await parseXlsxToRows(buf, mapping, body.sheet); - } catch (e: any) { - return { ok: false, status: 400, code: 'INVALID_REQUEST', error: `Failed to parse xlsx: ${e?.message ?? String(e)}` }; - } - } else if (Array.isArray(body)) { - // Permissive: a bare JSON array at the top level. - rows = (body as Array>).map(applyMapping); - } else { - return { ok: false, status: 400, code: 'INVALID_REQUEST', error: 'Provide format:"csv" with csv text, format:"json" with rows[], or format:"xlsx" with xlsxBase64' }; - } - - if (rows.length > maxRows) { - return { ok: false, status: 413, code: 'PAYLOAD_TOO_LARGE', error: `Import limit is ${maxRows} rows per request (got ${rows.length}).` }; - } - - // Apply the named mapping's fieldMapping pipeline (rename + transforms; - // strict projection — only mapped targets reach the write path). Inline - // `mapping` was empty in this branch, so rows still carry raw headers. - if (mappingArtifact) { - const applied = applyMappingToRows(rows, mappingArtifact); - if (!applied.ok) return applied; - rows = applied.rows; - } - - // Resolve the object's field metadata so cells coerce to storage values - // (booleans, numbers, dates→ISO, select label→code) and lookup names resolve - // to record ids. Best-effort: a failed lookup leaves `metaMap` empty and - // every value passes through untouched. - let metaMap = new Map(); - try { - let schema: any = undefined; - if (typeof p.getMetaItem === 'function') { - const r = await p.getMetaItem({ type: 'object', name: objectName }); - schema = isMetaEnvelope(r) ? r.item : r; - } - if (!schema && typeof p.getObjectSchema === 'function') { - schema = await p.getObjectSchema(objectName, environmentId); - } - metaMap = buildFieldMetaMap(schema); - } catch { /* pass-through coercion */ } - - return { - ok: true, - prepared: { - rows, metaMap, writeMode, matchFields, dryRun, runAutomations, - trimWhitespace, nullValues, createMissingOptions, skipBlankMatchKey, - }, - }; -} - /** Platform object backing async import jobs (see sys-import-job.object.ts). */ const IMPORT_JOB_OBJECT = 'sys_import_job'; /** Hard ceiling on rows per async import job (mirrors spec IMPORT_JOB_MAX_ROWS). */ From 1a51ee2c067229c9f0476cecc40bf384c3f5af8c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 10:22:23 +0000 Subject: [PATCH 3/5] feat(auth): identity bulk import endpoint POST /admin/import-users (#2766 V2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedicated sys_user import (re-scoped #2758) — the generic /data import path writes through ObjectQL, bypassing better-auth hashing and never creating the sys_account credential row, so it stays closed for identity. This endpoint reuses the generic import FRAMEWORK (prepareImportRequest payload parsing: rows[]/csv/xlsxBase64 + mapping; runImport row engine: dryRun, upsert matching, row results) with an identity write protocol: - createData drives auth.api.createUser per row (scrypt + credential account); no bulk primitive on purpose. - passwordPolicy 'invite': rows must carry a real email (row-level INVITE_REQUIRES_EMAIL at validation/dryRun time; request-level 400 when no EmailService is wired); accounts are created with a throwaway password and a set-your-password reset email is requested per created row. Email failure marks the row INVITE_EMAIL_FAILED — created, not rolled back. - passwordPolicy 'temporary': generated password per row, returned ONCE in the response; must_change_password stamped (403 PASSWORD_EXPIRED gate from V1). Red-line tests assert no password reaches audit rows, logs, or any persisted surface. - mode insert|upsert, matchBy email|phone (phone requires the V1.5 phoneNumber plugin); upsert updates touch profile fields only (name/phone_number/role) — never email, never credentials, so a re-imported CSV can't silently reset existing users' passwords; blank match keys skip instead of creating duplicates. - Phone-only rows (temporary policy) get placeholder emails; imported password columns are ignored (pre-hashed migration is out of scope). - Sync only, <=500 rows/request (scrypt ~100ms/row); async:true returns ASYNC_NOT_SUPPORTED with batching guidance — an async job is a persistence surface and cannot carry temporary passwords; tracked as a follow-up. No undo by design (undo == bulk-deleting users). - Run-level sys_audit_log row (better-auth writes bypass the ObjectQL hooks plugin-audit subscribes to), password-free. plugin-auth now depends on @objectstack/rest (no cycle; rest does not depend on plugin-auth). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o --- packages/plugins/plugin-auth/package.json | 1 + .../src/admin-import-users.test.ts | 343 ++++++++++++++++ .../plugin-auth/src/admin-import-users.ts | 369 ++++++++++++++++++ .../src/admin-user-endpoints.test.ts | 2 +- .../plugin-auth/src/admin-user-endpoints.ts | 4 +- .../plugins/plugin-auth/src/auth-manager.ts | 9 + .../plugins/plugin-auth/src/auth-plugin.ts | 36 ++ packages/plugins/plugin-auth/src/index.ts | 1 + pnpm-lock.yaml | 3 + 9 files changed, 765 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/admin-import-users.test.ts create mode 100644 packages/plugins/plugin-auth/src/admin-import-users.ts diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 1ff3ad46d5..bd6a35a283 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -25,6 +25,7 @@ "@noble/hashes": "^2.2.0", "@objectstack/core": "workspace:*", "@objectstack/platform-objects": "workspace:*", + "@objectstack/rest": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "better-auth": "^1.6.23", diff --git a/packages/plugins/plugin-auth/src/admin-import-users.test.ts b/packages/plugins/plugin-auth/src/admin-import-users.test.ts new file mode 100644 index 0000000000..7695e7a051 --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-import-users.test.ts @@ -0,0 +1,343 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { runAdminImportUsers, IMPORT_USERS_MAX_ROWS, type IdentityImportDeps } from './admin-import-users.js'; +import type { AdminActor } from './admin-user-endpoints.js'; + +const ACTOR: AdminActor = { id: 'admin-1', email: 'admin@example.com' }; + +function makeRequest(body: unknown): Request { + return new Request('http://localhost/api/v1/auth/admin/import-users', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function makeDeps(opts: { + existingUsers?: Array>; + phoneEnabled?: boolean; + emailAvailable?: boolean; + resetFails?: boolean; +} = {}) { + const existing = opts.existingUsers ?? []; + let nextId = 1; + const createUser = vi.fn(async ({ body }: any) => ({ + user: { id: `u-${nextId++}`, email: body.email, name: body.name }, + })); + const requestPasswordReset = vi.fn(async () => { + if (opts.resetFails) throw new Error('smtp down'); + return { status: true }; + }); + const find = vi.fn(async (_obj: string, q: any) => { + const where = q?.where ?? {}; + return existing.filter((u) => Object.entries(where).every(([k, v]) => u[k] === v)); + }); + const update = vi.fn(async () => ({})); + const insert = vi.fn(async () => ({})); + const warn = vi.fn(); + const noteMustChangePasswordIssued = vi.fn(); + const deps: IdentityImportDeps = { + getAuthApi: async () => ({ createUser, requestPasswordReset }), + getDataEngine: () => ({ find, update, insert }), + phoneNumberEnabled: () => opts.phoneEnabled ?? false, + emailServiceAvailable: () => opts.emailAvailable ?? true, + noteMustChangePasswordIssued, + logger: { warn }, + }; + return { deps, createUser, requestPasswordReset, find, update, insert, warn, noteMustChangePasswordIssued }; +} + +/** Red line: no generated password may reach any persistence/log surface. */ +function expectNoPasswordLeak(m: ReturnType, passwords: string[]) { + const surfaces = JSON.stringify([m.insert.mock.calls, m.update.mock.calls, m.warn.mock.calls]); + for (const pw of passwords) { + if (typeof pw === 'string' && pw.length > 0) expect(surfaces).not.toContain(pw); + } +} + +describe('runAdminImportUsers — request validation', () => { + it('rejects a missing/invalid passwordPolicy', async () => { + const m = makeDeps(); + const res = await runAdminImportUsers(m.deps, makeRequest({ format: 'json', rows: [] }), ACTOR); + expect(res.status).toBe(400); + expect(m.createUser).not.toHaveBeenCalled(); + }); + + it('rejects matchBy phone when the phoneNumber plugin is off', async () => { + const m = makeDeps({ phoneEnabled: false }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'temporary', mode: 'upsert', matchBy: 'phone', format: 'json', rows: [] }), + ACTOR, + ); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('PHONE_NOT_ENABLED'); + }); + + it('rejects the invite policy without an email service', async () => { + const m = makeDeps({ emailAvailable: false }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'invite', format: 'json', rows: [{ email: 'a@b.co' }] }), + ACTOR, + ); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('EMAIL_SERVICE_REQUIRED'); + expect(m.createUser).not.toHaveBeenCalled(); + }); + + it('rejects async: true explicitly (not silently)', async () => { + const m = makeDeps(); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'temporary', async: true, format: 'json', rows: [{ email: 'a@b.co' }] }), + ACTOR, + ); + expect(res.status).toBe(400); + expect(res.body.error?.code).toBe('ASYNC_NOT_SUPPORTED'); + }); + + it('rejects payloads above the row cap with 413', async () => { + const m = makeDeps(); + const rows = Array.from({ length: IMPORT_USERS_MAX_ROWS + 1 }, (_, i) => ({ email: `u${i}@x.co` })); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'temporary', format: 'json', rows }), + ACTOR, + ); + expect(res.status).toBe(413); + expect(m.createUser).not.toHaveBeenCalled(); + }); +}); + +describe('runAdminImportUsers — row validation (also on dryRun)', () => { + it('flags invite rows without a real email as INVITE_REQUIRES_EMAIL', async () => { + const m = makeDeps({ phoneEnabled: true }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'invite', dryRun: true, format: 'json', + rows: [ + { email: 'ok@x.co', name: 'OK' }, + { phone_number: '+8613800000000', name: 'PhoneOnly' }, + { name: 'Nobody' }, + { email: 'u-aaaaaaaaaaaaaaaaaaaa@placeholder.invalid' }, + { email: 'not-an-email' }, + ], + }), + ACTOR, + ); + expect(res.status).toBe(200); + const rows = (res.body.data as any).rows; + expect(rows[0].action).toBe('created'); // dryRun projection + expect(rows[1].code).toBe('INVITE_REQUIRES_EMAIL'); + expect(rows[2].code).toBe('NO_IDENTITY'); + expect(rows[3].code).toBe('INVALID_EMAIL'); + expect(rows[4].code).toBe('INVALID_EMAIL'); + expect((res.body.data as any).summary.errors).toBe(4); + // dryRun writes nothing + expect(m.createUser).not.toHaveBeenCalled(); + expect(m.update).not.toHaveBeenCalled(); + expect(m.insert).not.toHaveBeenCalled(); + }); + + it('flags phone columns when the plugin is off, and bad phone formats', async () => { + const m = makeDeps({ phoneEnabled: false }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'temporary', dryRun: true, format: 'json', + rows: [{ phone_number: '+8613800000000' }], + }), + ACTOR, + ); + expect((res.body.data as any).rows[0].code).toBe('PHONE_NOT_ENABLED'); + + const m2 = makeDeps({ phoneEnabled: true }); + const res2 = await runAdminImportUsers( + m2.deps, + makeRequest({ + passwordPolicy: 'temporary', dryRun: true, format: 'json', + rows: [{ phone_number: 'junk' }], + }), + ACTOR, + ); + expect((res2.body.data as any).rows[0].code).toBe('INVALID_PHONE'); + }); +}); + +describe('runAdminImportUsers — temporary policy', () => { + it('creates each row through better-auth, stamps must_change_password, returns per-row temp passwords once', async () => { + const m = makeDeps({ phoneEnabled: true }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'temporary', format: 'json', + rows: [ + { email: 'a@x.co', name: 'A' }, + { phone_number: '+8613800000001', name: 'B' }, + ], + }), + ACTOR, + ); + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.summary.created).toBe(2); + expect(m.createUser).toHaveBeenCalledTimes(2); + + // Row 1: real email; row 2: placeholder that never contains the phone. + const sent1 = m.createUser.mock.calls[0][0].body; + const sent2 = m.createUser.mock.calls[1][0].body; + expect(sent1.email).toBe('a@x.co'); + expect(typeof sent1.password).toBe('string'); + expect(sent2.email).toMatch(/@placeholder\.invalid$/); + expect(sent2.email).not.toContain('138'); + expect(sent2.data.phoneNumber).toBe('+8613800000001'); + + // must_change_password stamped per created user + gate cache primed. + const stamps = m.update.mock.calls.filter((c) => c[1]?.must_change_password === true); + expect(stamps.length).toBe(2); + expect(m.noteMustChangePasswordIssued).toHaveBeenCalled(); + + // Temp passwords: response-only, one per row, and they are the ones sent + // to better-auth for hashing. + const pw1 = data.rows[0].temporaryPassword; + const pw2 = data.rows[1].temporaryPassword; + expect(pw1).toBe(sent1.password); + expect(pw2).toBe(sent2.password); + expectNoPasswordLeak(m, [pw1, pw2]); + + // No invitation emails under the temporary policy. + expect(m.requestPasswordReset).not.toHaveBeenCalled(); + + // Run-level audit row without password material. + const audit = m.insert.mock.calls.find((c) => c[0] === 'sys_audit_log'); + expect(audit).toBeTruthy(); + const meta = JSON.parse(audit![1].metadata); + expect(meta.event).toBe('user.import_run'); + expect(meta.created).toBe(2); + }); +}); + +describe('runAdminImportUsers — invite policy', () => { + it('creates with a throwaway password and requests a reset email per created row', async () => { + const m = makeDeps(); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'invite', format: 'json', + rows: [{ email: 'a@x.co' }, { email: 'b@x.co' }], + }), + ACTOR, + ); + const data = res.body.data as any; + expect(data.summary.created).toBe(2); + expect(m.requestPasswordReset).toHaveBeenCalledTimes(2); + expect(m.requestPasswordReset.mock.calls.map((c) => c[0].body.email).sort()).toEqual(['a@x.co', 'b@x.co']); + // The throwaway password is not returned to the caller. + expect(data.rows[0].temporaryPassword).toBeUndefined(); + // No must-change stamp for invite (users set their own password). + expect(m.update.mock.calls.filter((c) => c[1]?.must_change_password === true).length).toBe(0); + expectNoPasswordLeak(m, m.createUser.mock.calls.map((c) => c[0].body.password)); + }); + + it('keeps the row created (with INVITE_EMAIL_FAILED) when the email fails — no rollback', async () => { + const m = makeDeps({ resetFails: true }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'invite', format: 'json', rows: [{ email: 'a@x.co' }] }), + ACTOR, + ); + const row = (res.body.data as any).rows[0]; + expect(row.ok).toBe(true); + expect(row.action).toBe('created'); + expect(row.code).toBe('INVITE_EMAIL_FAILED'); + expect((res.body.data as any).summary.created).toBe(1); + }); +}); + +describe('runAdminImportUsers — upsert', () => { + it('matches by email: updates profile fields only, never credentials or email', async () => { + const m = makeDeps({ + existingUsers: [{ id: 'u-old', email: 'a@x.co', name: 'Old Name' }], + }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'temporary', mode: 'upsert', matchBy: 'email', format: 'json', + rows: [ + { email: 'a@x.co', name: 'New Name', password: 'Injected1!' }, // password column must be ignored + { email: 'new@x.co', name: 'Fresh' }, + ], + }), + ACTOR, + ); + const data = res.body.data as any; + expect(data.summary.updated).toBe(1); + expect(data.summary.created).toBe(1); + + // The existing user was patched with name only — no email, no password. + const patch = m.update.mock.calls.find((c) => c[1]?.id === 'u-old'); + expect(patch).toBeTruthy(); + expect(patch![1]).toEqual({ id: 'u-old', name: 'New Name' }); + + // The updated row never went through createUser; the new row did. + expect(m.createUser).toHaveBeenCalledTimes(1); + expect(m.createUser.mock.calls[0][0].body.email).toBe('new@x.co'); + + // Updated (existing) users never get a temporary password back. + expect(data.rows[0].temporaryPassword).toBeUndefined(); + expect(typeof data.rows[1].temporaryPassword).toBe('string'); + }); + + it('matches by phone_number when enabled', async () => { + const m = makeDeps({ + phoneEnabled: true, + existingUsers: [{ id: 'u-p', email: 'p@x.co', phone_number: '+8613800000009', name: 'P' }], + }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'temporary', mode: 'upsert', matchBy: 'phone', format: 'json', + rows: [{ phone_number: '+86 138 0000 0009', name: 'P2' }], + }), + ACTOR, + ); + const data = res.body.data as any; + expect(data.summary.updated).toBe(1); + expect(m.find).toHaveBeenCalledWith('sys_user', expect.objectContaining({ + where: { phone_number: '+8613800000009' }, + })); + }); + + it('skips upsert rows whose match key is blank instead of creating duplicates', async () => { + const m = makeDeps({ phoneEnabled: true }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'temporary', mode: 'upsert', matchBy: 'phone', format: 'json', + rows: [{ email: 'email-only@x.co', name: 'NoPhone' }], + }), + ACTOR, + ); + const row = (res.body.data as any).rows[0]; + expect(row.action).toBe('skipped'); + expect(m.createUser).not.toHaveBeenCalled(); + }); +}); + +describe('runAdminImportUsers — CSV payloads', () => { + it('accepts the same CSV shape as the generic import route', async () => { + const m = makeDeps(); + const csv = 'email,name\nc1@x.co,C One\nc2@x.co,C Two\n'; + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'temporary', csv }), + ACTOR, + ); + const data = res.body.data as any; + expect(data.summary.created).toBe(2); + expect(m.createUser.mock.calls.map((c) => c[0].body.email).sort()).toEqual(['c1@x.co', 'c2@x.co']); + }); +}); diff --git a/packages/plugins/plugin-auth/src/admin-import-users.ts b/packages/plugins/plugin-auth/src/admin-import-users.ts new file mode 100644 index 0000000000..876be08bcb --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-import-users.ts @@ -0,0 +1,369 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Identity bulk import — `POST /api/v1/auth/admin/import-users` (#2766 V2, + * re-scoped from #2758). + * + * Why a dedicated endpoint instead of opening sys_user's generic import + * affordance: the generic `/data/:object/import` path writes rows straight + * through ObjectQL, which would bypass better-auth's password hashing and + * never create the `sys_account` credential row — every "imported user" would + * be a dead row that can never sign in. This endpoint reuses the generic + * import *framework* (payload parsing via `prepareImportRequest`, the row + * engine via `runImport`) but swaps the write path for an identity-specific + * `ImportProtocolLike` whose `createData` drives `auth.api.createUser`. + * + * Password policies: + * - `invite` — every row needs a REAL email. Accounts are created with a + * random throwaway password the user never sees, then a + * reset-password email ("set your password") is requested + * for each created account. Requires a wired EmailService. + * - `temporary` — each created account gets a generated temporary password, + * `must_change_password` is stamped (403 PASSWORD_EXPIRED + * until changed), and the passwords are returned ONCE in the + * HTTP response, one per row. Never persisted, never logged. + * + * Deliberate limits: + * - Synchronous only, ≤ IMPORT_USERS_MAX_ROWS rows per request. scrypt + * hashing costs ~100ms/row, so bigger batches belong in an async job — + * which can't carry temporary passwords anyway (a job result is a + * persistence surface; red line). Callers split large files into batches. + * - No undo. Undoing an identity import would bulk-delete users and their + * credentials — the risk dwarfs the convenience. + * - Upsert updates only touch profile fields (UPDATE_ALLOWED_FIELDS); + * credentials and the email identity are never modified on update, so a + * re-imported CSV can never silently reset an existing user's password. + */ + +import type { + PreparedImport, + ImportProtocolLike, + ImportRowResult, +} from '@objectstack/rest'; +import { prepareImportRequest, runImport } from '@objectstack/rest'; +import { generatePlaceholderEmail, isPlaceholderEmail } from './placeholder-email.js'; +import { generateTemporaryPassword, normalizePhoneNumber, type AdminActor, type EndpointResult } from './admin-user-endpoints.js'; + +export const IMPORT_USERS_MAX_ROWS = 500; + +/** Profile fields an upsert row may modify on an EXISTING user. */ +const UPDATE_ALLOWED_FIELDS = new Set(['name', 'phone_number', 'role']); + +export interface IdentityImportEngine { + find(objectName: string, query?: any): Promise; + update(objectName: string, data: any, options?: any): Promise; + insert(objectName: string, data: any, options?: any): Promise; +} + +export interface IdentityImportDeps { + /** better-auth server api (createUser + requestPasswordReset). */ + getAuthApi(): Promise; + getDataEngine(): IdentityImportEngine | undefined; + /** Optional metadata reader for field coercion (best-effort). */ + getMetaItem?(ref: { type: string; name: string }): Promise; + phoneNumberEnabled(): boolean; + emailServiceAvailable(): boolean; + noteMustChangePasswordIssued(): void; + logger?: { warn(msg: string): void }; +} + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +interface RowIdentity { + email?: string; + phone?: string; + invalid?: { code: string; error: string }; +} + +/** + * Resolve a raw row's identity columns. Accepts `email` plus any of + * `phone_number` / `phoneNumber` / `phone` for the phone column (normalized + * in place to `phone_number`). + */ +function resolveRowIdentity( + row: Record, + opts: { policy: 'invite' | 'temporary'; phoneEnabled: boolean }, +): RowIdentity { + const rawEmail = typeof row.email === 'string' ? row.email.trim() : ''; + const hasEmail = rawEmail.length > 0; + if (hasEmail && !/^\S+@\S+\.\S+$/.test(rawEmail)) { + return { invalid: { code: 'INVALID_EMAIL', error: `"${rawEmail}" is not a valid email` } }; + } + if (hasEmail && isPlaceholderEmail(rawEmail)) { + return { invalid: { code: 'INVALID_EMAIL', error: 'Placeholder addresses cannot be imported as emails' } }; + } + + const rawPhone = row.phone_number ?? row.phoneNumber ?? row.phone; + const hasPhone = typeof rawPhone === 'string' && rawPhone.trim().length > 0; + let phone: string | undefined; + if (hasPhone) { + if (!opts.phoneEnabled) { + return { invalid: { code: 'PHONE_NOT_ENABLED', error: 'Phone columns require the phoneNumber auth plugin (auth.plugins.phoneNumber)' } }; + } + phone = normalizePhoneNumber(String(rawPhone)); + if (!phone) { + return { invalid: { code: 'INVALID_PHONE', error: `"${rawPhone}" is not a valid phone number (E.164 recommended)` } }; + } + } + + if (!hasEmail && !phone) { + return { invalid: { code: 'NO_IDENTITY', error: 'Row needs an email or a phone_number' } }; + } + if (opts.policy === 'invite' && !hasEmail) { + return { + invalid: { + code: 'INVITE_REQUIRES_EMAIL', + error: 'The invite policy needs a real email for this row — use the temporary policy for phone-only users', + }, + }; + } + return { email: hasEmail ? rawEmail.toLowerCase() : undefined, phone }; +} + +export async function runAdminImportUsers( + deps: IdentityImportDeps, + request: Request, + actor: AdminActor, +): Promise { + let body: any = {}; + try { body = await request.json(); } catch { body = {}; } + const fail = (status: number, code: string, message: string): EndpointResult => ({ + status, body: { success: false, error: { code, message } }, + }); + + // ── Request-level validation ───────────────────────────────────────── + const policy = body?.passwordPolicy; + if (policy !== 'invite' && policy !== 'temporary') { + return fail(400, 'invalid_request', 'passwordPolicy must be "invite" or "temporary"'); + } + const mode = body?.mode === 'upsert' ? 'upsert' : body?.mode === 'insert' || body?.mode === undefined ? 'insert' : undefined; + if (!mode) return fail(400, 'invalid_request', 'mode must be "insert" or "upsert"'); + const matchBy = body?.matchBy === 'phone' ? 'phone' : body?.matchBy === 'email' || body?.matchBy === undefined ? 'email' : undefined; + if (!matchBy) return fail(400, 'invalid_request', 'matchBy must be "email" or "phone"'); + if (matchBy === 'phone' && !deps.phoneNumberEnabled()) { + return fail(400, 'PHONE_NOT_ENABLED', 'matchBy "phone" requires the phoneNumber auth plugin (auth.plugins.phoneNumber)'); + } + if (policy === 'invite' && !deps.emailServiceAvailable()) { + // Reject up front — otherwise N accounts get created whose invitation + // emails all fail (and outside dev we refuse to log the reset links). + return fail(400, 'EMAIL_SERVICE_REQUIRED', 'The invite policy requires a configured email service. Use the temporary policy or wire an EmailService.'); + } + if (body?.async === true) { + // Async jobs are a persistence surface — they can't carry temporary + // passwords (red line), and the invite flow at job scale needs the shared + // job worker. Tracked as a follow-up; batches of ≤500 cover the V2 need. + return fail(400, 'ASYNC_NOT_SUPPORTED', `Async identity import is not supported yet — split the file into batches of at most ${IMPORT_USERS_MAX_ROWS} rows.`); + } + + const engine = deps.getDataEngine(); + if (!engine) return fail(503, 'unavailable', 'Data engine unavailable'); + + // ── Payload parsing (shared generic-import parser) ─────────────────── + const prep = await prepareImportRequest( + { + ...body, + writeMode: mode, + matchFields: [matchBy === 'phone' ? 'phone_number' : 'email'], + runAutomations: false, + // Identity semantics: a row whose match key is blank must never + // fall through to "create a second account" on upsert. + skipBlankMatchKey: true, + }, + { + p: { ...(deps.getMetaItem ? { getMetaItem: deps.getMetaItem } : {}) }, + objectName: 'sys_user', + maxRows: IMPORT_USERS_MAX_ROWS, + }, + ); + if (!prep.ok) return fail(prep.status, prep.code, prep.error); + const prepared: PreparedImport = prep.prepared; + + // ── Identity pre-validation (runs for dryRun too) ──────────────────── + const phoneEnabled = deps.phoneNumberEnabled(); + const results: Array = new Array(prepared.rows.length); + const validRows: Array> = []; + const validIndex: number[] = []; + for (let i = 0; i < prepared.rows.length; i++) { + const row = { ...prepared.rows[i] }; + const identity = resolveRowIdentity(row, { policy, phoneEnabled }); + if (identity.invalid) { + results[i] = { row: i + 1, ok: false, action: 'failed', code: identity.invalid.code, error: identity.invalid.error }; + continue; + } + // Canonicalize the identity columns for coercion + match lookups. + delete row.phoneNumber; delete row.phone; + if (identity.email) row.email = identity.email; else delete row.email; + if (identity.phone) row.phone_number = identity.phone; else delete row.phone_number; + validRows.push(row); + validIndex.push(i); + } + + // ── Identity write protocol (the part generic import must NOT do) ──── + const temporaryPasswords = new Map(); // record id → temp password + const createdEmails = new Map(); + const authApi = await deps.getAuthApi(); + if (typeof authApi.createUser !== 'function') { + return fail(501, 'not_supported', 'The better-auth admin plugin is not enabled (auth.plugins.admin)'); + } + + const protocol: ImportProtocolLike = { + // findExisting path: `{ $filter, $top }` against sys_user. + async findData(args: any) { + const where = args?.query?.$filter ?? {}; + const limit = args?.query?.$top ?? 2; + return engine.find(args.object, { where, limit, context: SYSTEM_CTX } as any); + }, + + // One better-auth create per row — hashing + credential sys_account. + // Deliberately NO createManyData: there is no safe bulk primitive for + // identities, and scrypt dominates the cost anyway. + async createData(args: any) { + const data: Record = args?.data ?? {}; + const email: string = typeof data.email === 'string' && data.email.length > 0 + ? data.email + : generatePlaceholderEmail(); // temporary policy only — invite rows were validated to carry one + const placeholder = !(typeof data.email === 'string' && data.email.length > 0); + const phone: string | undefined = typeof data.phone_number === 'string' && data.phone_number.length > 0 ? data.phone_number : undefined; + const name: string = typeof data.name === 'string' && data.name.trim().length > 0 + ? data.name.trim() + : placeholder ? (phone as string) : email.split('@')[0]; + const role: string | undefined = typeof data.role === 'string' && data.role.length > 0 ? data.role : undefined; + + // Both policies create with a generated password: `temporary` hands it + // to the caller; `invite` throws it away (the user sets their own via + // the reset link) — the account is never password-less. + const password = generateTemporaryPassword(); + + const created = await authApi.createUser({ + body: { + email, + name, + password, + ...(role ? { role } : {}), + ...(phone ? { data: { phoneNumber: phone } } : {}), + }, + }); + const id = created?.user?.id != null ? String(created.user.id) : undefined; + if (!id) throw Object.assign(new Error('better-auth returned no user id'), { code: 'CREATE_FAILED' }); + + if (policy === 'temporary') { + temporaryPasswords.set(id, password); + try { + await engine.update('sys_user', { id, must_change_password: true }, { context: SYSTEM_CTX } as any); + deps.noteMustChangePasswordIssued(); + } catch (e) { + deps.logger?.warn(`[AuthPlugin] import-users: failed to stamp must_change_password for ${id}: ${(e as Error)?.message ?? e}`); + } + } else { + createdEmails.set(id, { email, placeholder }); + } + return { id }; + }, + + // Upsert updates touch PROFILE fields only — never email, never anything + // credential- or system-managed. An empty filtered patch is a no-op. + async updateData(args: any) { + const patch: Record = {}; + for (const [k, v] of Object.entries(args?.data ?? {})) { + if (UPDATE_ALLOWED_FIELDS.has(k) && v !== undefined && v !== null && v !== '') patch[k] = v; + } + if (Object.keys(patch).length === 0) return { id: args.id }; + await engine.update('sys_user', { id: args.id, ...patch }, { context: SYSTEM_CTX } as any); + return { id: args.id }; + }, + }; + + // ── Run the shared row engine over the valid rows ───────────────────── + const summary = await runImport({ + p: protocol, + objectName: 'sys_user', + rows: validRows, + metaMap: prepared.metaMap, + writeMode: prepared.writeMode, + matchFields: prepared.matchFields, + dryRun: prepared.dryRun, + runAutomations: false, + trimWhitespace: prepared.trimWhitespace, + nullValues: prepared.nullValues, + createMissingOptions: false, + skipBlankMatchKey: true, + captureUndo: false, // undo = bulk-deleting users; deliberately unsupported + }); + + // Re-map engine results onto the original row numbering. + let preErrors = 0; + for (const r of results) if (r) preErrors++; + for (let j = 0; j < summary.results.length; j++) { + const original = validIndex[j]; + const r = summary.results[j]; + results[original] = { ...r, row: original + 1 }; + } + + // ── Post-write phases (skipped on dryRun) ───────────────────────────── + if (!prepared.dryRun) { + // invite: request a set-your-password email per created account. + if (policy === 'invite') { + for (const r of results) { + if (!r || r.action !== 'created' || !r.id) continue; + const target = createdEmails.get(r.id); + if (!target || target.placeholder) continue; + try { + await authApi.requestPasswordReset({ body: { email: target.email } }); + } catch (e) { + // The account exists; only the email failed. Not a rollback — + // remediation is re-sending or an admin set-user-password. + r.code = 'INVITE_EMAIL_FAILED'; + r.error = `User created, but the invitation email failed: ${((e as Error)?.message ?? String(e)).slice(0, 200)}`; + } + } + } + // temporary: attach each password to its row — response body ONLY. + if (policy === 'temporary') { + for (const r of results) { + if (r?.action === 'created' && r.id && temporaryPasswords.has(r.id)) { + r.temporaryPassword = temporaryPasswords.get(r.id); + } + } + } + + // Run-level audit (better-auth writes bypass the ObjectQL hooks that + // plugin-audit subscribes to). Best-effort; NO password material. + try { + await engine.insert('sys_audit_log', { + action: 'import', + user_id: actor.id, + actor: actor.id, + object_name: 'sys_user', + metadata: JSON.stringify({ + event: 'user.import_run', + mode, matchBy, passwordPolicy: policy, + total: prepared.rows.length, + created: summary.created, updated: summary.updated, + skipped: summary.skipped, errors: summary.errors + preErrors, + }), + }, { context: SYSTEM_CTX } as any); + } catch { /* audit table may not exist — never fail the import */ } + } + + const errors = summary.errors + preErrors; + return { + status: 200, + body: { + success: true, + data: { + summary: { + total: prepared.rows.length, + created: summary.created, + updated: summary.updated, + skipped: summary.skipped, + errors, + dryRun: prepared.dryRun, + passwordPolicy: policy, + mode, + matchBy, + }, + rows: results, + }, + }, + }; +} diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index a16e5f4036..e0f5df7cc5 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -42,7 +42,7 @@ function makeDeps(overrides: Partial> = {}) { const deps: AdminUserEndpointDeps = { getAuthApi: async () => ({ createUser }) as any, getAuthContext: async () => authCtx as any, - getDataEngine: () => ({ update: engineUpdate, create: engineCreate }), + getDataEngine: () => ({ update: engineUpdate, insert: engineCreate }), assertPasswordComplexity: vi.fn(async () => undefined), noteMustChangePasswordIssued, logger: { warn }, diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index 271b8b988a..9189f6d5ad 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -87,7 +87,7 @@ export interface AdminUserEndpointDeps { export interface AdminUserDataEngine { update(object: string, doc: Record, opts?: unknown): Promise; - create(object: string, doc: Record, opts?: unknown): Promise; + insert(object: string, doc: Record, opts?: unknown): Promise; } /** The gated caller, passed by the route after its ADR-0068 check. */ @@ -242,7 +242,7 @@ async function writeAdminAudit( const engine = deps.getDataEngine(); if (!engine) return; try { - await engine.create( + await engine.insert( 'sys_audit_log', { action: entry.action, diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 7881ed765c..fd03cf7df3 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2825,6 +2825,15 @@ export class AuthManager { return ((this.config.plugins ?? {}) as any).phoneNumber === true; } + /** + * #2766 V2 — is an email transport wired? The identity import endpoint + * rejects the invite password policy up front when it isn't, instead of + * creating N accounts whose invitation emails all silently fail. + */ + public isEmailServiceAvailable(): boolean { + return !!this.getEmailService(); + } + /** * #2766 V1 — flip the "someone must change their password" cache on this * node the moment an admin issues a flag, so the gate is enforced on the diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 1b4bbdcad2..e47f425a0b 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1244,6 +1244,42 @@ export class AuthPlugin implements Plugin { return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500); } }); + + // #2766 V2 — identity bulk import (re-scoped #2758). Reuses the generic + // import framework's parsing + row engine but writes every row through + // better-auth (hash + credential account) — see admin-import-users.ts + // for the password policies (invite / temporary) and deliberate limits + // (sync ≤500 rows, no undo, profile-only upsert updates). + rawApp.post(`${basePath}/admin/import-users`, async (c: any) => { + try { + const actor = await gateAdmin(c); + if (actor instanceof Response) return actor; + const { runAdminImportUsers } = await import('./admin-import-users.js'); + const metadataService: any = (() => { + try { return ctx.getService?.('metadata'); } catch { return undefined; } + })(); + const { status, body } = await runAdminImportUsers( + { + getAuthApi: () => this.authManager!.getApi() as any, + getDataEngine: () => this.authManager!.getDataEngine(), + ...(metadataService?.getMetaItem + ? { getMetaItem: (ref: { type: string; name: string }) => metadataService.getMetaItem(ref) } + : {}), + phoneNumberEnabled: () => this.authManager!.isPhoneNumberEnabled(), + emailServiceAvailable: () => this.authManager!.isEmailServiceAvailable(), + noteMustChangePasswordIssued: () => this.authManager!.noteMustChangePasswordIssued(), + logger: ctx.logger, + }, + c.req.raw, + actor, + ); + return c.json(body, status as any); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] admin/import-users failed', err); + return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500); + } + }); } // ──────────────────────────────────────────────────────────────────── diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index b1d3b1c42c..1e739d715e 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -14,6 +14,7 @@ export * from './ensure-default-organization.js'; export * from './set-initial-password.js'; export * from './admin-user-endpoints.js'; export * from './placeholder-email.js'; +export * from './admin-import-users.js'; export * from './register-sso-provider.js'; export * from './objectql-adapter.js'; export * from './auth-schema-config.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaa89c0f82..660cff6d6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1307,6 +1307,9 @@ importers: '@objectstack/platform-objects': specifier: workspace:* version: link:../../platform-objects + '@objectstack/rest': + specifier: workspace:* + version: link:../../rest '@objectstack/spec': specifier: workspace:* version: link:../../spec From c1297d23d1252c5d1a59aa9bc505771d51d0ea27 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 10:25:31 +0000 Subject: [PATCH 4/5] chore: add changeset for #2766 V1/V1.5/V2 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o --- .../admin-user-management-and-import.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/admin-user-management-and-import.md diff --git a/.changeset/admin-user-management-and-import.md b/.changeset/admin-user-management-and-import.md new file mode 100644 index 0000000000..1867a14410 --- /dev/null +++ b/.changeset/admin-user-management-and-import.md @@ -0,0 +1,37 @@ +--- +"@objectstack/plugin-auth": minor +"@objectstack/platform-objects": minor +"@objectstack/rest": minor +"@objectstack/spec": minor +--- + +feat(auth): admin direct user management, phone sign-in, and identity bulk import (#2766, re-scoped #2758) + +`sys_user` is managed by better-auth and its generic CRUD is suppressed, so +until now the only way to add a teammate was the email-dependent invite flow. +This ships three staged capabilities: + +- **Admin direct user management** — `POST /api/v1/auth/admin/create-user` + and a wrapped `POST /api/v1/auth/admin/set-user-password` (ADR-0068 + platform-admin gate; better-auth pipeline so credentials are real). Optional + generated temporary password (returned once, never persisted or logged) and + a new `sys_user.must_change_password` flag enforced through the ADR-0069 + authGate (`403 PASSWORD_EXPIRED` until the user changes it). New + `create_user` action and upgraded `set_user_password` action on the Users + list — pure schema, no frontend changes. +- **Phone sign-in (opt-in `auth.plugins.phoneNumber`)** — better-auth + phoneNumber plugin, phone+password only (`POST /sign-in/phone-number`); + OTP flows stay off until SMS infrastructure exists. Adds + `sys_user.phone_number` (unique) / `phone_number_verified`. Phone-only + accounts get an undeliverable placeholder email + (`u-@placeholder.invalid`, never derived from the phone number); + all auth mail callbacks refuse placeholder recipients. +- **Identity bulk import** — `POST /api/v1/auth/admin/import-users` accepts + the same payloads as the generic import routes (rows/csv/xlsx, dryRun, + upsert by email or phone) but writes every row through better-auth. + Password policies: `invite` (reset-link email per created user; requires an + EmailService) and `temporary` (per-row one-time passwords + forced change). + Sync only, ≤500 rows per request; no undo; upsert updates touch profile + fields only and can never reset an existing user's password. + `prepareImportRequest` and the CSV/xlsx parsers moved from rest-server.ts + to an exported `import-prepare.ts` module (behavior unchanged). From 25c9b0706983a67cb61b61e113795ce0aba96738 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 10:31:15 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(auth):=20address=20CodeQL=20alerts=20?= =?UTF-8?q?=E2=80=94=20linear-time=20email=20check,=20drop=20unused=20impo?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the /^\S+@\S+\.\S+$/ plausibility regex (polynomially backtrackable on adversarial input — js/polynomial-redos, alert 843) with a linear indexOf-based isLikelyEmail() shared by the create-user and import-users endpoints, with an adversarial-input regression test. Removes the unused beforeEach import (alert 844). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o --- .../plugin-auth/src/admin-import-users.ts | 4 +-- .../src/admin-user-endpoints.test.ts | 27 ++++++++++++++++++- .../plugin-auth/src/admin-user-endpoints.ts | 18 ++++++++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-auth/src/admin-import-users.ts b/packages/plugins/plugin-auth/src/admin-import-users.ts index 876be08bcb..642c63ad64 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users.ts @@ -42,7 +42,7 @@ import type { } from '@objectstack/rest'; import { prepareImportRequest, runImport } from '@objectstack/rest'; import { generatePlaceholderEmail, isPlaceholderEmail } from './placeholder-email.js'; -import { generateTemporaryPassword, normalizePhoneNumber, type AdminActor, type EndpointResult } from './admin-user-endpoints.js'; +import { generateTemporaryPassword, normalizePhoneNumber, isLikelyEmail, type AdminActor, type EndpointResult } from './admin-user-endpoints.js'; export const IMPORT_USERS_MAX_ROWS = 500; @@ -86,7 +86,7 @@ function resolveRowIdentity( ): RowIdentity { const rawEmail = typeof row.email === 'string' ? row.email.trim() : ''; const hasEmail = rawEmail.length > 0; - if (hasEmail && !/^\S+@\S+\.\S+$/.test(rawEmail)) { + if (hasEmail && !isLikelyEmail(rawEmail)) { return { invalid: { code: 'INVALID_EMAIL', error: `"${rawEmail}" is not a valid email` } }; } if (hasEmail && isPlaceholderEmail(rawEmail)) { diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts index e0f5df7cc5..dc1e65ab1d 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { runAdminCreateUser, runAdminSetUserPassword, @@ -69,6 +69,31 @@ function expectNoPasswordLeak(mocks: ReturnType, password: stri } } +describe('isLikelyEmail (linear-time, no regex backtracking)', () => { + it('accepts normal addresses and rejects junk', async () => { + const { isLikelyEmail } = await import('./admin-user-endpoints.js'); + expect(isLikelyEmail('a@b.co')).toBe(true); + expect(isLikelyEmail('first.last@sub.example.com')).toBe(true); + expect(isLikelyEmail('')).toBe(false); + expect(isLikelyEmail('no-at.example.com')).toBe(false); + expect(isLikelyEmail('a@b')).toBe(false); + expect(isLikelyEmail('a@@b.co')).toBe(false); + expect(isLikelyEmail('a@.co')).toBe(false); + expect(isLikelyEmail('a@b.')).toBe(false); + expect(isLikelyEmail('has space@b.co')).toBe(false); + }); + + it('is fast on the CodeQL adversarial inputs', async () => { + const { isLikelyEmail } = await import('./admin-user-endpoints.js'); + const attack = '!@'.repeat(100_000); + const attack2 = '!@!.' + '!.'.repeat(100_000); + const t0 = Date.now(); + expect(isLikelyEmail(attack)).toBe(false); + expect(isLikelyEmail(attack2)).toBe(false); + expect(Date.now() - t0).toBeLessThan(200); + }); +}); + describe('generateTemporaryPassword', () => { it('meets the 4-class complexity policy and min length', () => { for (let i = 0; i < 50; i++) { diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index 9189f6d5ad..cff93cfe65 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -159,6 +159,22 @@ function badRequest(message: string): EndpointResult { return { status: 400, body: { success: false, error: { code: 'invalid_request', message } } }; } +/** + * Linear-time email plausibility check (local@domain with a dotted domain). + * Deliberately not a regex: the obvious `\S+@\S+\.\S+` is polynomially + * backtrackable on adversarial input (CodeQL js/polynomial-redos) since `\S` + * overlaps with `@` and `.`. better-auth revalidates on its own side; this + * only gates obviously-broken rows early. + */ +export function isLikelyEmail(value: string): boolean { + if (value.length === 0 || value.length > 254 || /\s/.test(value)) return false; + const at = value.indexOf('@'); + if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return false; + const domain = value.slice(at + 1); + const dot = domain.lastIndexOf('.'); + return dot > 0 && dot < domain.length - 1; +} + /** * Resolve the password for a create/set request: an explicit `password` / * `newPassword`, or a generated temporary one when `generatePassword` is true. @@ -289,7 +305,7 @@ export async function runAdminCreateUser( // email) that every mail callback refuses to send to. const rawEmail = body.email; const hasEmail = typeof rawEmail === 'string' && rawEmail.trim().length > 0; - if (hasEmail && !/^\S+@\S+\.\S+$/.test((rawEmail as string).trim())) { + if (hasEmail && !isLikelyEmail((rawEmail as string).trim())) { return badRequest('A valid email is required'); } const rawPhone = body.phoneNumber ?? body.phone_number;