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