From cbc086e09df5a33177fa91b1f3b5c18316cd0191 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 17 Oct 2026 15:02:22 -0700
Subject: [PATCH 1/2] feat(register): ask for an email and a password only;
derive the handle (TASK-290 A)
The v2 register page drops the username field. With no username in the
request, the server derives one from the email's local part: plus-tag
dropped, runs of other characters made one hyphen, cut to 30, and a short
suffix when the name is taken in any case, reserved for an agent install,
or shorter than 2. Every derived handle fits all three @-mention readers.
A request that carries a username keeps it, with every check as before, so
the CLI and older clients are unchanged. The Google path is not touched.
A derived name a concurrent signup takes before the save is derived again
instead of answering "Username already exists" to someone who typed none.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
---
backend/__tests__/service/auth.test.js | 26 ++--
.../authController.derivedUsername.test.js | 147 ++++++++++++++++++
backend/controllers/authController.ts | 25 ++-
backend/services/derivedUsername.ts | 57 +++++++
docs/development/BACKEND.md | 2 +-
frontend/src/i18n/locales/en.json | 1 -
frontend/src/i18n/locales/zh-CN.json | 1 -
frontend/src/v2/__tests__/V2Register.test.tsx | 20 ++-
frontend/src/v2/components/V2Register.tsx | 15 +-
9 files changed, 257 insertions(+), 37 deletions(-)
create mode 100644 backend/__tests__/unit/controllers/authController.derivedUsername.test.js
create mode 100644 backend/services/derivedUsername.ts
diff --git a/backend/__tests__/service/auth.test.js b/backend/__tests__/service/auth.test.js
index 902930b81c..f764ab8efb 100644
--- a/backend/__tests__/service/auth.test.js
+++ b/backend/__tests__/service/auth.test.js
@@ -74,6 +74,19 @@ describe('Auth Routes Integration Tests', () => {
expect(typeof user.verified).toBe('boolean');
});
+ // TASK-290 A: the register page sends no username; the handle comes from
+ // the email, and the signup gets its workspace like any other.
+ it('registers without a username, deriving one from the email', async () => {
+ await request(app)
+ .post('/api/auth/register')
+ .send({ email: 'Ada.Lovelace@example.com', password: 'Password123!' })
+ .expect(201);
+
+ const user = await User.findOne({ email: 'ada.lovelace@example.com' });
+ expect(user.username).toBe('ada-lovelace');
+ expect(await Pod.countDocuments({ createdBy: user._id })).toBe(1);
+ });
+
it('creates a default private workspace pod owned by the new user', async () => {
const userData = {
username: 'workspaceowner',
@@ -184,18 +197,9 @@ describe('Auth Routes Integration Tests', () => {
expect(response.body.error).toContain('Username already exists');
});
+ // A missing username is no longer one of them: it is derived (TASK-290 A,
+ // the test above).
it('should validate required fields', async () => {
- // Missing username
- const missingUsername = {
- email: 'test@example.com',
- password: 'Password123!',
- };
-
- await request(app)
- .post('/api/auth/register')
- .send(missingUsername)
- .expect(400);
-
// Missing email
const missingEmail = {
username: 'testuser',
diff --git a/backend/__tests__/unit/controllers/authController.derivedUsername.test.js b/backend/__tests__/unit/controllers/authController.derivedUsername.test.js
new file mode 100644
index 0000000000..29cfd95eed
--- /dev/null
+++ b/backend/__tests__/unit/controllers/authController.derivedUsername.test.js
@@ -0,0 +1,147 @@
+// TASK-290 A: the register page asks only for an email and a password, so a
+// signup with no username gets one derived from the email. A client that sends
+// a username keeps it. Real models on the in-memory Mongo, so "taken" and
+// "reserved" are answered by the same rows and indexes production uses.
+const User = require('../../../models/User');
+const { AgentRegistry } = require('../../../models/AgentRegistry');
+
+jest.mock('../../../services/communityPodService', () => ({
+ ensureUserInCommunityPod: jest.fn().mockResolvedValue(undefined),
+}));
+jest.mock('../../../services/emailService', () => ({
+ sendEmail: jest.fn().mockResolvedValue({ data: { succeeded: 1 } }),
+}));
+jest.mock('../../../models/Task', () => ({ create: jest.fn().mockResolvedValue([]) }));
+jest.mock('../../../services/pgPodSyncService', () => ({
+ syncPodFromMongo: jest.fn().mockResolvedValue({}),
+}));
+
+const authController = require('../../../controllers/authController');
+const { usernameBaseFromEmail } = require('../../../services/derivedUsername');
+const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils');
+
+// What every @-mention reader accepts: agentMentionService needs 2+ of
+// [a-z0-9_-], agentMessageService [a-z0-9][a-z0-9-]{0,39}, the v2 renderer
+// [a-zA-Z0-9][a-zA-Z0-9_-]*. A derived handle has to sit inside all three.
+const MENTION_SAFE = /^[a-z0-9][a-z0-9-]{1,39}$/;
+
+const register = async (body) => {
+ const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+ await authController.register({ body: { password: 'Password123!', ...body } }, res);
+ return res;
+};
+
+const usernameOf = async (email) => (await User.findOne({ email }).lean())?.username;
+
+describe('register with no username (TASK-290 A)', () => {
+ beforeAll(async () => {
+ await setupMongoDb();
+ // The unique username index has to exist for the race below to collide.
+ // Not User.init(): the model's collation index on the same key clashes
+ // with it by name and rejects.
+ await User.collection.createIndex({ username: 1 }, { unique: true });
+ });
+ afterAll(async () => { await closeMongoDb(); });
+ afterEach(async () => {
+ await clearMongoDb();
+ jest.restoreAllMocks();
+ });
+
+ it('derives the handle from the email local part, plus-tag dropped', async () => {
+ const res = await register({ email: 'Sam.Lee+news@Example.com' });
+
+ expect(res.status).toHaveBeenCalledWith(201);
+ expect(await usernameOf('sam.lee+news@example.com')).toBe('sam-lee');
+ });
+
+ it('keeps a username the client sends, exactly as before', async () => {
+ const res = await register({ username: 'Typed_Name', email: 'sam.lee@example.com' });
+
+ expect(res.status).toHaveBeenCalledWith(201);
+ expect(await usernameOf('sam.lee@example.com')).toBe('Typed_Name');
+ });
+
+ it('adds a short suffix when another account holds the name in any case', async () => {
+ await User.create({ username: 'Sam-Lee', email: 'first@example.com', password: 'x-password' });
+
+ const res = await register({ email: 'sam.lee@example.com' });
+
+ expect(res.status).toHaveBeenCalledWith(201);
+ expect(await usernameOf('sam.lee@example.com')).toMatch(/^sam-lee-[0-9a-f]{4}$/);
+ });
+
+ it('never derives a name reserved for an agent install (TASK-133 b)', async () => {
+ await AgentRegistry.create({
+ agentName: 'pixel-helper',
+ displayName: 'Pixel Helper',
+ description: 'a pod helper',
+ manifest: { name: 'pixel-helper', version: '1.0.0' },
+ });
+
+ const typeName = await register({ email: 'claude-code@example.com' });
+ const claimed = await register({ email: 'pixel.helper@example.com' });
+
+ expect(typeName.status).toHaveBeenCalledWith(201);
+ expect(claimed.status).toHaveBeenCalledWith(201);
+ expect(await usernameOf('claude-code@example.com')).toMatch(/^claude-code-[0-9a-f]{4}$/);
+ expect(await usernameOf('pixel.helper@example.com')).toMatch(/^pixel-helper-[0-9a-f]{4}$/);
+ });
+
+ it.each([
+ ['one character', 'a@example.com', /^a-[0-9a-f]{4}$/],
+ ['no letters or digits it can keep', '李雷@example.com', /^user-[0-9a-f]{4}$/],
+ ])('suffixes a local part with %s', async (_label, email, expected) => {
+ const res = await register({ email });
+
+ expect(res.status).toHaveBeenCalledWith(201);
+ expect(await usernameOf(email)).toMatch(expected);
+ });
+
+ it('derives a handle every mention reader accepts', () => {
+ const cases = [
+ 'sam.lee', 'sam_lee', '-dash-', 'a..b__c', 'UPPER.Case', '1st', 'x'.repeat(60),
+ `${'ab.'.repeat(14)}z`, // a separator lands on the cut
+ ];
+ cases.forEach((local) => {
+ const base = usernameBaseFromEmail(`${local}@example.com`);
+ expect(base.length).toBeLessThanOrEqual(30);
+ // No hyphen at either end or doubled, so the suffix reads "sam-lee-1a2b".
+ expect(base).not.toMatch(/^-|-$|--/);
+ expect(`${base}-ffff`).toMatch(MENTION_SAFE);
+ if (base.length >= 2) expect(base).toMatch(MENTION_SAFE);
+ });
+ });
+
+ it('derives again when another signup takes the name between the check and the save', async () => {
+ const originalSave = User.prototype.save;
+ let raced = false;
+ jest.spyOn(User.prototype, 'save').mockImplementation(async function save(...args) {
+ if (!raced && this.username === 'racer') {
+ raced = true;
+ await User.create({ username: 'racer', email: 'other@example.com', password: 'x-password' });
+ }
+ return originalSave.apply(this, args);
+ });
+
+ const res = await register({ email: 'racer@example.com' });
+
+ expect(res.status).toHaveBeenCalledWith(201);
+ expect(await usernameOf('racer@example.com')).toMatch(/^racer-[0-9a-f]{4}$/);
+ });
+
+ it('still answers an address that is already registered', async () => {
+ await User.create({ username: 'someone', email: 'taken@example.com', password: 'x-password' });
+
+ const res = await register({ email: 'taken@example.com' });
+
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ error: 'User already exists' });
+ });
+
+ it('requires only an email and a password', async () => {
+ const res = await register({ email: '', password: '' });
+
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Email and password are required.' });
+ });
+});
diff --git a/backend/controllers/authController.ts b/backend/controllers/authController.ts
index 39417a2e39..38b2dc27a8 100644
--- a/backend/controllers/authController.ts
+++ b/backend/controllers/authController.ts
@@ -9,6 +9,7 @@ const { sendEmail } = require('../services/emailService');
const { ensureUserInCommunityPod } = require('../services/communityPodService');
const { normalizeAvatarUrl } = require('../services/avatarService');
const { cleanSignupRef } = require('../utils/signupRef');
+const { deriveUsernameFromEmail } = require('../services/derivedUsername');
// The first-touch ref a signup arrived with (?ref=x), stored once on the new
// account. Anything that is not a short plain code is dropped, not refused.
@@ -395,13 +396,21 @@ exports.register = async (req: any, res: any) => {
} = req.body;
const normalizedEmail = normalizeEmail(email);
- const normalizedUsername = String(username || '').trim();
+ const typedUsername = String(username || '').trim();
const rawPassword = String(password || '');
- if (!normalizedUsername || !normalizedEmail || !rawPassword) {
- return res.status(400).json({ error: 'Username, email, and password are required.' });
+ if (!normalizedEmail || !rawPassword) {
+ return res.status(400).json({ error: 'Email and password are required.' });
}
+ // The register page asks only for an email and a password (TASK-290 A), so
+ // the handle comes from the email. A client that sends a username keeps it,
+ // with every check below as before.
+ const usernameDerived = !typedUsername;
+ const normalizedUsername = usernameDerived
+ ? await deriveUsernameFromEmail(normalizedEmail)
+ : typedUsername;
+
// Reserved for agents (TASK-133 b). A row that takes an agent's derived name
// or its address IS the row an install would adopt, and registration is the
// only moment it can be defended: once it exists, the install's choice is
@@ -492,7 +501,15 @@ exports.register = async (req: any, res: any) => {
});
// Save user to database
- await user.save();
+ try {
+ await user.save();
+ } catch (saveErr: any) {
+ // A derived name another signup took in the meantime: derive again
+ // rather than tell someone who typed no username that theirs is taken.
+ if (!usernameDerived || saveErr?.code !== 11000 || !saveErr.keyPattern?.username) throw saveErr;
+ user.username = await deriveUsernameFromEmail(normalizedEmail);
+ await user.save();
+ }
await createDefaultWorkspacePod(user._id);
diff --git a/backend/services/derivedUsername.ts b/backend/services/derivedUsername.ts
new file mode 100644
index 0000000000..fa78c80eff
--- /dev/null
+++ b/backend/services/derivedUsername.ts
@@ -0,0 +1,57 @@
+// A username for a signup that didn't type one (TASK-290 A): the register page
+// asks only for an email and a password, and the handle comes from the email.
+//
+// The handle has to work wherever a person is @-mentioned, so it keeps to what
+// every mention reader accepts: a letter or digit, then letters, digits and
+// hyphens, at least 2 long (agentMentionService needs 2, agentMessageService
+// stops at 40). A dot or an underscore would cut the mention short.
+//
+// Free means: no other account holds the name in any case ("Sam" holds "sam"),
+// and it isn't reserved for an agent install (TASK-133 b), the check register
+// runs on a typed name. The case-insensitive match is done by the query's
+// collation, not by an index: the unique index on username is case-sensitive,
+// and the User model's collation index on the same key has never built beside
+// it (same auto-generated name; production has only the unique one, checked
+// 2026-10-17). The Google path names its own accounts (oauthController
+// generateUsername) and is not changed here.
+const crypto = require('crypto');
+const User = require('../models/User');
+const AgentIdentityService = require('./agentIdentityService');
+
+// The account-label comparison userController uses.
+const ACCOUNT_LABEL_COLLATION = { locale: 'en', strength: 2 };
+// Leaves room for the "-xxxx" suffix under the 40-character mention limit.
+const MAX_BASE_LENGTH = 30;
+
+/** The email's local part as a handle: plus-tag dropped, runs of anything else made one hyphen. */
+const usernameBaseFromEmail = (email: unknown): string => String(email || '')
+ .split('@')[0]
+ .split('+')[0]
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+/, '')
+ .slice(0, MAX_BASE_LENGTH)
+ .replace(/-+$/, '');
+
+const isFreeUsername = async (candidate: string): Promise => {
+ if (await AgentIdentityService.resolveAccountNameConflict({ username: candidate })) return false;
+ return !await User.exists({ username: candidate }).collation(ACCOUNT_LABEL_COLLATION);
+};
+
+/** A free username from the email: its local part, or that plus a short suffix when taken or too short. */
+const deriveUsernameFromEmail = async (email: unknown): Promise => {
+ const base = usernameBaseFromEmail(email);
+ if (base.length >= 2 && await isFreeUsername(base)) return base;
+ const stem = base || 'user';
+ for (let i = 0; i < 5; i += 1) {
+ const candidate = `${stem}-${crypto.randomBytes(2).toString('hex')}`;
+ // eslint-disable-next-line no-await-in-loop
+ if (await isFreeUsername(candidate)) return candidate;
+ }
+ // 30 + 1 + 8 stays under 40.
+ return `${stem}-${crypto.randomBytes(4).toString('hex')}`;
+};
+
+module.exports = { usernameBaseFromEmail, deriveUsernameFromEmail };
+
+export {};
diff --git a/docs/development/BACKEND.md b/docs/development/BACKEND.md
index 8135564272..9eaa711065 100644
--- a/docs/development/BACKEND.md
+++ b/docs/development/BACKEND.md
@@ -66,7 +66,7 @@ backend/
| Method | Endpoint | Description | Request Body | Response |
|--------|------------------------|-----------------------------|--------------------------------------|---------------------------------|
-| POST | /api/auth/register | Register a new user | `{username, email, password, invitationCode?}` | Registration status message |
+| POST | /api/auth/register | Register a new user; without a `username`, one is derived from the email | `{email, password, username?, invitationCode?}` | Registration status message |
| GET | /api/auth/registration-policy | Public registration mode | - | `{inviteOnly, invitationRequired, hasInvitationCodes, registrationOpen}` |
| POST | /api/auth/login | Login user | `{email, password}` | User object with token |
| GET | /api/auth/user | Get current user | - | User object |
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 0ed63fecb4..e47d007612 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -201,7 +201,6 @@
},
"auth": {
"fields": {
- "username": "Username",
"email": "Email",
"password": "Password"
},
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 623e7e9b7e..cdefc9aee7 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -201,7 +201,6 @@
},
"auth": {
"fields": {
- "username": "用户名",
"email": "邮箱",
"password": "密码"
},
diff --git a/frontend/src/v2/__tests__/V2Register.test.tsx b/frontend/src/v2/__tests__/V2Register.test.tsx
index 150a03cf93..359e2a8aae 100644
--- a/frontend/src/v2/__tests__/V2Register.test.tsx
+++ b/frontend/src/v2/__tests__/V2Register.test.tsx
@@ -77,7 +77,6 @@ describe('V2Register', () => {
const { login } = renderRegister();
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy'));
- fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'new-user' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } });
fireEvent.click(screen.getByRole('button', { name: /create account/i }));
@@ -86,9 +85,23 @@ describe('V2Register', () => {
expect(screen.getByTestId('location')).toHaveTextContent('/v2');
});
+ // TASK-290 A: the page asks for an email and a password only; the server
+ // derives the handle from the email.
+ it('asks for no username and sends none', async () => {
+ renderRegister();
+
+ await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy'));
+ expect(screen.queryByLabelText(/username/i)).toBeNull();
+ fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } });
+ fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } });
+ fireEvent.click(screen.getByRole('button', { name: /create account/i }));
+
+ await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/auth/register', expect.anything()));
+ expect(axios.post.mock.calls[0][1]).not.toHaveProperty('username');
+ });
+
const createAccount = async () => {
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy'));
- fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'new-user' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } });
fireEvent.click(screen.getByRole('button', { name: /create account/i }));
@@ -133,7 +146,6 @@ describe('V2Register', () => {
const { login } = renderRegister(jest.fn().mockRejectedValue(new Error('login unavailable')));
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy'));
- fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'new-user' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } });
fireEvent.click(screen.getByRole('button', { name: /create account/i }));
@@ -158,7 +170,6 @@ describe('V2Register', () => {
renderRegister(jest.fn().mockRejectedValue(new Error('login unavailable')));
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy'));
- fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'new-user' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } });
fireEvent.click(screen.getByRole('button', { name: /create account/i }));
@@ -178,7 +189,6 @@ describe('V2Register', () => {
renderRegister(jest.fn().mockRejectedValue(new Error('login unavailable')));
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/auth/registration-policy'));
- fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'new-user' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'new@example.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'Password123!' } });
fireEvent.click(screen.getByRole('button', { name: /create account/i }));
diff --git a/frontend/src/v2/components/V2Register.tsx b/frontend/src/v2/components/V2Register.tsx
index 896e1d884d..187a8cd30e 100644
--- a/frontend/src/v2/components/V2Register.tsx
+++ b/frontend/src/v2/components/V2Register.tsx
@@ -32,7 +32,6 @@ const V2Register: React.FC = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
- const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [invitationCode, setInvitationCode] = useState(searchParams.get('invite') || '');
@@ -67,8 +66,8 @@ const V2Register: React.FC = () => {
setError(null);
setSubmitting(true);
try {
+ // No username: the server derives the handle from the email (TASK-290 A).
const res = await axios.post('/api/auth/register', {
- username: username.trim(),
email: email.trim(),
password,
invitationCode: invitationCode.trim(),
@@ -151,18 +150,6 @@ const V2Register: React.FC = () => {
{t('auth.register.subtitle')}
-
-