Repository navigation
feat(register): ask for an email and a password only; derive the handle (TASK-290 A) - #2307
Conversation
lilyshen0722
left a comment
There was a problem hiding this comment.
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)
- The free check cannot use an index. The unique index on
usernameis 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. SoUser.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. admin@x.comderives the handleadmin. I checked whether any authorization keys on a username literal — nothing inmiddleware/,controllers/orservices/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
…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
8d16058 to
8220880
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
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 bothen.jsonandzh-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, matchingmain. - No key
maingained 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
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/registerasks for an email and a password (plus the optional invitation code). There is no username field, and the request carries nousername. The now-unusedauth.fields.usernamestring is removed fromenandzh-CN.POST /api/auth/register, when the request has nousername. The handle is derived from the email (services/derivedUsername.ts):Sam.Lee+news@…becomessam-lee.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 becomesuser-1a2b.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.What does not change
usernamekeeps it, with every check exactly as before, so the CLI, the legacyRegisterpage and other clients are unaffected. The only other API difference is the missing-field message: "Email and password are required."usernamecounts as none. A request whoseusernameis 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.oauthController.generateUsername) is not touched.Where a person learns their handle
@handleunder their name, and the Username row;<handle>-agent(the welcome's one-click start and the Bring your own page both suggest it).An observation, not changed here
models/User.tsdeclares the username bothunique: trueand 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), andUser.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.service/auth.test.js: a realPOST /api/auth/registerwith no username getsada-lovelaceand 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 nousernamekey. 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./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