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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions backend/__tests__/service/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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.' });
});
});
25 changes: 21 additions & 4 deletions backend/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
57 changes: 57 additions & 0 deletions backend/services/derivedUsername.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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<string> => {
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 {};
2 changes: 1 addition & 1 deletion docs/development/BACKEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ const uniqueTag = () => `e2e${Date.now()}`;
test.describe('Authentication', () => {
test('register form renders correctly', async ({ page }) => {
await page.goto('/v2/register');
await expect(page.getByLabel('Username')).toBeVisible();
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: 'Create account' })).toBeVisible();
// TASK-290 A: no username field; the server derives one from the email.
await expect(page.getByLabel('Username')).toHaveCount(0);
});

test('register new user enters the starter workspace', async ({ page }) => {
const tag = uniqueTag();
await page.goto('/v2/register');
await page.getByLabel('Username').fill(`user_${tag}`);
await page.getByLabel('Email').fill(`${tag}@commonly.test`);
await page.getByLabel('Password').fill('TestPass123!');
await page.getByRole('button', { name: 'Create account' }).click();
Expand Down
1 change: 0 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,6 @@
},
"auth": {
"fields": {
"username": "Username",
"email": "Email",
"password": "Password"
},
Expand Down
1 change: 0 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,6 @@
},
"auth": {
"fields": {
"username": "用户名",
"email": "邮箱",
"password": "密码"
},
Expand Down
20 changes: 15 additions & 5 deletions frontend/src/v2/__tests__/V2Register.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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 }));
Expand Down Expand Up @@ -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 }));
Expand All @@ -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 }));
Expand All @@ -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 }));
Expand Down
Loading
Loading