Skip to content

feat(register): ask for an email and a password only; derive the handle (TASK-290 A) - #2307

Merged
lilyshen0722 merged 2 commits into
mainfrom
lily/register-derived-username
Oct 21, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
lily/register-derived-username

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Draft, held. This is option A for TASK-290 (signup to a live hosted agent in under 60 s). It presses only if walk 6, run after the Wednesday 10-21 deploy of #2297, #2300 and #2305, comes in over 60 s. If B makes it alone, this waits, and whether the field stays becomes a product call.

Why

Walk 5 spent 44.1 s of its 1:10.5 on the register form. Applying walk 5's numbers to #2297's flow gives about 62–63 s, which would miss. That figure is derived, not measured. The username field costs 1 s of pointing plus 7.2 s of typing for walk 5's 24-character handle, plus its label in the reading time. Without it, the same projection comes to about 54 s.

What changes

  • /v2/register asks for an email and a password (plus the optional invitation code). There is no username field, and the request carries no username. The now-unused auth.fields.username string is removed from en and zh-CN.
  • POST /api/auth/register, when the request has no username. The handle is derived from the email (services/derivedUsername.ts):
    • Take the local part and drop any plus-tag. Lowercase it, turn each run of other characters into one hyphen, cut it to 30 characters, and trim hyphens from both ends. Sam.Lee+news@… becomes sam-lee.
    • Add a short suffix (sam-lee-1a2b) when the name is held by another account in any case, is reserved for an agent install (the same check register runs on a typed name), or is shorter than 2 characters. A local part with nothing left becomes user-1a2b.
    • Every derived handle fits all three @-mention readers: agentMentionService (2 or more of [a-z0-9_-]), agentMessageService ([a-z0-9][a-z0-9-]{0,39}) and the v2 renderer. That is why dots and underscores become hyphens.
    • If a concurrent signup takes the derived name between the check and the save, it is derived again instead of answering "Username already exists" to someone who typed none.

What does not change

  • A request that carries a username keeps it, with every check exactly as before, so the CLI, the legacy Register page and other clients are unaffected. The only other API difference is the missing-field message: "Email and password are required."
  • A blank username counts as none. A request whose username is empty or only whitespace now gets a derived handle; on main it got a 400. vera measured this on the gate. Only a client that sends a blank field sees the difference, and it now gets an account instead of an error.
  • The Google path (oauthController.generateUsername) is not touched.
  • Existing accounts are not touched.
  • Settings still shows the username read-only. A validated rename is a separate, later task.

Where a person learns their handle

  • the account menu (its name label);
  • the author name on their own messages in a pod;
  • Settings: @handle under their name, and the Username row;
  • their hosted agent's default name, <handle>-agent (the welcome's one-click start and the Bring your own page both suggest it).

An observation, not changed here

models/User.ts declares the username both unique: true and in a strength-2 collation index. The two get the same auto-generated name, so the collation index never builds. Production has only the case-sensitive unique index (checked 2026-10-17), and User.init() rejects in tests. The derivation compares case-insensitively through the query's collation, so it doesn't rely on that index.

Tests

  • authController.derivedUsername.test.js (new, 10 tests, on real models and the in-memory Mongo): derivation with the plus-tag dropped; a typed username kept verbatim; a case-insensitive collision; reserved names (an agent type name and an AgentRegistry claim); short and empty local parts; mention-safe output; the save race; an already-registered email; the required fields.
  • Mutation-checked: each of these fails exactly one test when broken: no collation, no reserved check, plus-tag kept, a 1-character base allowed, no length cap, a trailing hyphen kept, no race retry, and a typed username ignored.
  • service/auth.test.js: a real POST /api/auth/register with no username gets ada-lovelace and its workspace pod. The old "missing username is a 400" arm is removed, because that is the behaviour this PR changes.
  • V2Register.test.tsx: the page has no username field and the request carries no username key. Restoring main's page fails 9 of the 10 tests.
  • e2e/auth.spec.ts: the form test asserts that the Username field is absent, and the signup test registers with an email and a password only, so CI's Playwright run covers the derived handle end to end.
  • Run locally on Node 22: the auth and oauth controller suites (79), the service auth suite (26), the i18n suites, and V2Register and V2Login. Backend tsc and eslint show nothing on the changed files.
  • Browser check on a local production build (/v2/register, mocked API): at 1440×900 the fields are Email, Password and the optional invitation code; at 390 there is no horizontal scroll.

Walk harness note

Walk 6's harness types into input[autocomplete="username"]. A walk after this PR has to skip that field.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security gate — #2307 at 8d160580

Draft, base main, merge-base 8eaac7e5 = origin/main (base unmoved), 10 files. CI green: 15 success, and the four cancelled entries (Source changed ⇒ version bumped, ADR numbers, Stale-base merge guard, Rule numbers) each have a success of the same name. Backend authController.derivedUsername + service/auth 36/36; frontend V2Register + Register + AuthContext 18/18; tsc --noEmit -p tsconfig.typescheck.json 0 errors; eslint 0 errors.

Property 1 — the derived handle. Confirmed, and it is stricter than the typed path

I ran 14 adversarial local parts through the real register (in-memory Mongo, real unique index) rather than reading the regex:

..foo..@x.com    → foo            -lead@x.com    → lead           a@x.com      → a-181a
中文@x.com        → user-eec4      UPPER@x.com    → upper          ---@x.com    → user-73ce
foo+tag+more@…   → foo-30f4       f×60@x.com     → f×30 (len 30)  1@x.com      → 1-117b
foo_bar@x.com    → foo-bar        foo.bar@x.com  → foo-bar-3bd7   a.b-c_d+e@…  → a-b-c-d
admin@x.com      → admin          null@x.com     → null

All 14 match ^[a-z0-9][a-z0-9-]{1,39}$ and sit at ≤30 characters, so they clear all three mention readers at once. Leading hyphens are stripped before the slice and trailing ones after it, non-alnum runs collapse to a single hyphen (no --), a non-ASCII local part falls back to user-xxxx, and the worst case is 30 + 1 + 8 = 39.

Free in any case is enforced by the query's collation (strength: 2), which is stricter than register's own User.findOne({$or:[{email},{username}]}) — that one carries no collation, so the typed path would accept Sam beside sam while a derived handle will not.

Never a name register would refuse holds because I enumerated what register actually refuses for a typed username: the agent reservation (resolveAccountNameConflict, which isFreeUsername calls — the same function), and the existing-user check (which the collation check subsumes). Register applies no charset or length rule to a typed username, so the derived path's rules are an addition, not a parity requirement. Worth stating plainly: the mention-safety guarantee is a property of derived handles; a client that types username: "a b@c" still gets it stored, exactly as before this PR.

The signup race is handled, which was my first worry: a duplicate-key error on username re-derives and saves again, gated on usernameDerived so a typed name's collision is still reported to its sender (if (!usernameDerived || code !== 11000 || !keyPattern?.username) throw).

Property 3 — oauthController untouched. Confirmed

git diff --name-only over the merge-base matches nothing on oauth; the Google path keeps its own generateUsername.

Property 2 — one deviation, measured

"A request that carries a username behaves exactly as on main." It does, with one exception: a whitespace-only username.

username "   "   → 201, handle derived (ws-20-20-20)      [main: 400 "Username, email, and password are required."]
username "\t\n"  → 201, handle derived                    [main: 400]

On main, String(username || '').trim() was the value the emptiness check read, so a blank-but-present username was refused. At this head the same trim decides usernameDerived = !typedUsername, so a request carrying only whitespace is treated as a request carrying nothing. Nothing unsafe follows — the derived handle is as safe as any other, and the new outcome is friendlier than the old 400 — but the stated property is falsified, and a client that today gets a 400 for an untrimmed empty field will silently receive an account with a handle it never asked for. If the intent is "a username that is sent is honoured or refused, never ignored", the fix is to branch on username !== undefined and refuse a blank one as before; if the intent is "blank means absent", say so in the property.

Mutations

Six, each alone, both files restored byte-identical to HEAD afterwards. Each reddens exactly one arm:

mutation arm
drop the agent-reservation check never derives a name reserved for an agent install
free check becomes case-sensitive adds a short suffix when another account holds the name in any case
allow one-character handles suffixes a local part with one character
keep leading hyphens derives a handle every mention reader accepts
drop the duplicate-key re-derive derives again when another signup takes the name between the check and the save
ignore the typed username keeps a username the client sends, exactly as before

Two notes (not blocking)

  1. The free check cannot use an index. The unique index on username is case-sensitive and the model's collation index on the same key has never built beside it — the file's own comment says so, which is the right place for it. So User.exists(...).collation(...) is a collection scan on the signup path. Immaterial now (942 user rows on the live instance) and worth a ticket before it isn't.
  2. admin@x.com derives the handle admin. I checked whether any authorization keys on a username literal — nothing in middleware/, controllers/ or services/ compares a username to 'admin' or similar, so this is a cosmetic surprise rather than a privilege one. The v1 register page (frontend/src/components/Register.tsx:57) still sends a typed username from a required field, so the typed path stays live and property 2 is load-bearing rather than theoretical.

Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015

GATE: PASS

lilyshen0722 and others added 2 commits October 19, 2026 21:43
…le (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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
The signup test now registers with an email and a password only, so it
covers the derived handle end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG
@samxu01
samxu01 force-pushed the lily/register-derived-username branch from 8d16058 to 8220880 Compare October 20, 2026 04:44

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-gate — #2307 at 8220880d51dabb070dc6e2cdb1d5c88cba01a715

The rebase carries

bec5d6c7 is an ancestor of this head, the branch is 2 commits, and my own git range-diff against the head I passed (8d160580, base 8eaac7e5) reports both as =. File sets identical, 10 files each.

Because patch identity stops being content identity once the base moves, I blob-compared all 10 files between 8d160580 and this head: 8 identical, 2 moved — frontend/src/i18n/locales/en.json and zh-CN.json, exactly the two the ask names, and nothing else.

The i18n merge, checked at key level rather than by conflict absence

A JSON merge can succeed and still be wrong two ways: a duplicated key (last-wins, silently) or a clobbered neighbour. Flattening both locales and diffing this head against main:

  • added 0, changed 0, removed 1 — auth.fields.username, in both en.json and zh-CN.json. That is the PR's whole i18n contribution, and it is the same single line it removed at the head I passed.
  • Both files parse with a duplicate-detecting hook (object_pairs_hook): no duplicate key at any level, 25 top-level keys in each, matching main.
  • No key main gained from #2339 or #2353 is removed or altered, so those two merges survive intact.

A removed translation key is only safe if nothing still asks for it. git grep at the head for auth.fields.username and fields.username across frontend, e2e and docs: no hits, with auth.fields.email as the positive control (4 consumers — V2Register, V2Login, V2ForgotPassword, RegistrationInviteRequired), so the grep could have hit. No surface can render the bare key.

Suites at this head

  • backend authController.derivedUsername + __tests__/service/auth → 36 passed / 2 suites
  • frontend V2Register + i18n → 23 passed; runtimeClaims + timingClaims → 13 passed

One bookkeeping difference worth stating rather than smoothing over: the ask reports 47 across V2Register, i18n, runtimeClaims and timingClaims; I measure 36 across those four. Only one i18n-named suite exists in the tree (src/i18n/__tests__/i18n.test.ts), so I cannot reproduce 47 from those four files and I am not going to guess what the extra 11 were. Every suite I ran is green, and the per-suite numbers above are what I measured.

Verdict

Content is patch-identical to the head I passed, the only files the rebase moved are the two locales, and the merge is clean at key level with no duplicate, no clobbered neighbour and no orphaned consumer. No defect.

Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015

GATE: PASS

@lilyshen0722
lilyshen0722 marked this pull request as ready for review October 21, 2026 15:24
@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit ea12d61 Oct 21, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant