Skip to content

feat: allow signup via the cli-auth browser flow - #5686

Merged
lukemelia merged 7 commits into
mainfrom
cs-12426-allow-signup-via-cli-auth-flow
Aug 5, 2026
Merged

feat: allow signup via the cli-auth browser flow#5686
lukemelia merged 7 commits into
mainfrom
cs-12426-allow-signup-via-cli-auth-flow

Conversation

@lukemelia

Copy link
Copy Markdown
Contributor

Extends the browser-based CLI auth flow (CS-12370, #5661) so a brand-new user can sign up, not just log in. Today /cli-auth is login-only, so the only way a new user completes boxel profile add is Google SSO auto-provisioning — email/password signup is unreachable from the CLI.

Resolves CS-12426.

What this does

  • cli-auth register mode (packages/host/app/components/matrix/cli-auth.gts): adds a register mode plus a "Don't have an account? Create a new Boxel account" affordance (and a path back to login), rendering the reused <RegisterUser> flow. port/state persist across the mode switch (they stay in the URL, same as the forgot-password resume).
  • @onComplete hook (register-user.gts): <RegisterUser> gains an optional @onComplete(session). After initializeNewUser runs the full web-parity bootstrap (personal realm + realm auth), the minted session is handed to onComplete. The default operator-mode path is unchanged when the arg is absent — one registration flow, no bespoke CLI form.
  • Device nuance: registration mints exactly one device. onRegisterComplete POSTs it to the CLI's loopback listener, then calls the new MatrixService.forgetPersistedSession() — a local-only session clear (no server-side logout, which would revoke the device) — so the CLI is the device's sole owner. Account-level bootstrap side effects (personal realm, realm auth) persist regardless.
  • Standalone-route readiness fix: the registration entry points (requestRegisterEmailToken, registerRequest, isUsernameAvailable) now await this.ready. The lightweight /cli-auth route can reach registration before the Matrix SDK has finished loading (operator mode always boots first, so the web register form is only reached once the SDK is ready). The e2e below surfaced this.
  • CLI (boxel-cli/src/lib/sso-login.ts): the session loopback branch already accepts a freshly-minted session generically, so no protocol change. The loopback timeout is extended 15→30 min to cover the interactive email round-trip, and the browser prompts now say "sign in or create an account".

Testing

New matrix e2e packages/matrix/tests/cli-signup.spec.ts: real browser register → email verify (smtp4dev) → loopback receives the session → whoami on the delivered device, and asserts the personal realm was bootstrapped. Passing locally — this exercises the standalone bootstrap end-to-end (the main verification risk).

Also: host ember-tsc clean, boxel-cli tsc clean, boxel-cli sso-login unit tests updated and passing (23/23), eslint + prettier clean on all touched files.

Follow-up / not in this PR

reCAPTCHA is an unimplemented TODO in register-user.gts. Invite-only is handled — the reused form already collects the registration token — but if prod Synapse enforces reCAPTCHA, cli-auth signup hits the same wall as the web app and needs an infra decision (parallels CS-12370's infra PR for the SSO whitelist). Overlaps with CS-12427 (default boxel profile add env to production).

🤖 Generated with Claude Code

boxel-cli's browser authorization page was login-only, so the only way a
brand-new user could complete `boxel profile add` was Google SSO
auto-provisioning — email/password signup was unreachable from the CLI.

Add a register mode to the /cli-auth page that reuses the web app's
<RegisterUser> flow (email verification, invite token, and the full
post-signup bootstrap that gives the user a personal realm). Registration
mints exactly one device; that device is POSTed to the CLI's loopback
listener and then forgotten from this browser's persisted session, so the
CLI is its sole owner (a later browser-side logout can't revoke it). The
account-level bootstrap side effects persist regardless of device.

The registration entry points now await the SDK load, since the standalone
/cli-auth route can reach registration before the SDK has finished loading
(operator mode always boots first). The CLI's loopback timeout is extended
to cover the interactive email round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   2h 55m 59s ⏱️
3 839 tests 3 825 ✅ 14 💤 0 ❌
3 858 runs  3 844 ✅ 14 💤 0 ❌

Results for commit e10c0c0.

Realm Server Test Results

    1 files      1 suites   13m 56s ⏱️
2 067 tests 2 067 ✅ 0 💤 0 ❌
2 146 runs  2 146 ✅ 0 💤 0 ❌

Results for commit e10c0c0.

The cli-auth page offers registration to a browser that already holds a
session — it prefills that account's username on the password form — but
registration bootstraps a new account onto the same page, and the session
already there belongs to someone else.

The realm-server token is what carries the old identity across: it persists
in localStorage apart from the Matrix session and its payload names a
session room, and the realm-auth handshake adopts that room. The new account
was never invited to it, so the join is forbidden and the bootstrap fails
there — before the personal realm exists, and before the device can be
handed to the CLI, which then waits on a hand-off that never comes. The
failing bootstrap also logs out, revoking the device it just minted.

So forgetting a persisted session now forgets all three of its keys rather
than the Matrix one alone, and entering register mode does that and reloads.
The reload sheds the in-memory half — requests already in flight under the
old identity otherwise land afterwards and persist a token again — and
clearing once more on the far side sweeps anything that did. The port and
nonce stay in the URL, so the CLI is still waiting on the other side of it.
The account itself keeps every device: this is local to the browser, and the
page says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lukemelia

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Pushed 8a05d29, which fixes a defect manual testing turned up: registering from a browser that already holds a session never reached the hand-off, so the CLI waited forever.

The page offers registration to a signed-in browser — it prefills that account's username on the password form — but registration bootstraps a new account onto the same page. What carries the old identity across is the realm-server token: it persists in localStorage apart from the Matrix session, its payload names a session room, and the realm-auth handshake adopts that room. The new account was never invited to it, so the join is forbidden and the bootstrap dies there — before the personal realm exists and before onComplete runs. Worse, the failing bootstrap logs out, which revokes the device registration had just minted, leaving an account that exists with no personal realm and no usable session.

forgetPersistedSession() now clears all three persisted keys rather than the Matrix one alone, and entering register mode does that and reloads. The reload sheds the in-memory half — requests already in flight under the old identity otherwise land afterwards and persist a token again — and clearing once more on the far side sweeps anything that did. Port and nonce stay in the URL, so the CLI is still waiting across the reload. Nothing is revoked server-side, and the page says so where the person can see it.

Also in this push: onRegisterComplete reads the callback captured at page load instead of re-reading the URL, since the bootstrap refreshes routes on its way out and a transition would strip the port and nonce before the hand-off got to them.

cli-signup.spec.ts gains a second test covering the signed-in case, asserting the reload keeps port and nonce, that all three keys are cleared, that the hand-off is the new account's, that the bootstrap completed, and that the previously signed-in account keeps its session. Note that I could not run the matrix suite locally, so CI is the first real run of it — worth watching.

Verified by hand against the local stack, both paths end to end: a never-signed-in browser and a browser signed in as another account. Each delivered a working session to the loopback, with exactly one device on the new account, the personal realm bootstrapped, and boxel realm list working through the delivered session. The signed-in account kept every device.

One caveat on reviewing this branch: signup cannot be exercised in a browser until #5691 lands (CS-12436) — an unrelated pre-existing bug blocks the email step everywhere, including production.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR extends the browser-based CLI authorization page (/cli-auth) to support account creation in addition to login, so the CLI can complete boxel profile add for brand-new users via the same registration flow the web app uses.

Changes:

  • Add a register mode to the /cli-auth UI and hand the newly minted registration session back to the CLI loopback listener.
  • Add an optional @onComplete(session) hook to <RegisterUser> so callers can consume the bootstrapped session without forcing an operator-mode transition.
  • Increase the CLI loopback wait window and add a new end-to-end Matrix Playwright test covering signup → email verification → session handoff → personal realm bootstrap.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/matrix/tests/cli-signup.spec.ts Adds an end-to-end Playwright test that signs up via /cli-auth, verifies email, receives the session via loopback, and asserts bootstrap results.
packages/host/app/utils/local-storage-keys.ts Introduces a shared constant for the realm-server session localStorage key.
packages/host/app/services/realm-server.ts Uses the shared realm-server session localStorage key constant instead of an inline string.
packages/host/app/services/matrix-service.ts Awaits ready for registration entry points and adds forgetPersistedSession() for local-only session clearing used by the CLI auth flow.
packages/host/app/components/matrix/register-user.gts Adds optional @onComplete(session) callback invoked after bootstrap completes.
packages/host/app/components/matrix/cli-auth.gts Adds registration UI/mode, hands off registration session to CLI, and clears persisted browser session locally to avoid device ownership conflicts.
packages/boxel-cli/tests/lib/sso-login.test.ts Updates unit expectations to match the new, longer browser wait timeout.
packages/boxel-cli/src/lib/sso-login.ts Extends loopback wait timeout and updates user messaging to include account creation.
Suppressed comments (1)

packages/matrix/tests/cli-signup.spec.ts:199

  • [Claude Code 🤖] This comment also uses temporal phrasing (“used to prevent”) rather than stating the contract the test asserts. Consider rewriting to describe the invariant: the bootstrap completes and a personal realm exists even when starting from a browser with persisted realm-server tokens.
    // The bootstrap ran to completion — this is what the stale token used to
    // prevent.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/host/app/components/matrix/cli-auth.gts
Comment thread packages/host/app/services/matrix-service.ts
Comment thread packages/matrix/tests/cli-signup.spec.ts Outdated
The authorization page tells the person how long the CLI keeps its listener
open, and the CLI enforces that window. Two copies of the number drift: the
page quoted a quarter of an hour while the listener waited half of one.

Both sides now read CLI_AUTH_TIMEOUT_MS from runtime-common, which both
packages already depend on, and the page renders it through the same
describeDuration the CLI prints — so the sentence a person reads and the
deadline they are racing cannot disagree.

Also states what the page does to the browser's own session accurately, now
that one of its three paths ends that session locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lukemelia
lukemelia marked this pull request as ready for review August 4, 2026 20:59
@lukemelia
lukemelia requested review from a team and backspace August 4, 2026 20:59
@backspace

backspace commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

I’m getting a CORS error trying to exercise this locally, will keep at it

ETA: it’s #5691 of course

@backspace backspace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This worked well for me; nothing blocking but some suggestions:

Image

I’d guess it wouldn’t be too much to make this match the rest of the site design?

Image

I have a baroque development browser setup so I had to copy this URL to have it run in the right place, Claude Code in the terminal has a press c shortcut that copies the URL for you, could be nice to steal

Image

I got an error after creating the account but it’s surely an environment mode thing

lukemelia and others added 3 commits August 5, 2026 18:42
The pages the loopback listener serves after sign-in finishes were bare
system-font HTML. They now carry the same dark shell, Boxel mark, and
type treatment as the host app's auth screens, inlined because nothing
else is served from that address. The error page also HTML-escapes the
message it interpolates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While the CLI waits on the browser, pressing c copies the authorization
URL through the platform's clipboard tool — for anyone whose default
browser isn't the one they want to finish the flow in. Raw mode is what
makes the keypress visible and it also swallows Ctrl-C, so Ctrl-C is
re-implemented; piped stdin gets no watcher and the wait message only
promises the keys that work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lukemelia

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] The pages the CLI's loopback listener serves at the end of the auth flow now match the host app's auth screens (previously bare white system-font HTML). Also added a c-to-copy keypress in the terminal while the CLI waits, for finishing the flow in a browser other than the one that auto-opened.

Success page

success page

Error page

error page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

heh what happened here

I tried it out again, looks good!

@lukemelia
lukemelia merged commit 4cd2e35 into main Aug 5, 2026
74 of 75 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.

3 participants