feat(profiles): create a profile from the web UI - #794
Draft
Nowaker wants to merge 2 commits into
Draft
Conversation
Re-authenticating an account no longer needs a terminal on the box Meridian runs on. Each claude-max profile card on /profiles grows a "Log in from browser" button: the server mints the PKCE login and returns the authorize URL, the page opens Claude's sign-in in a new tab, and the user pastes back what Claude shows. The paste is not a shortcut taken over a redirect; it is the only route available. Claude Code's OAuth client registers exactly one redirect URI, https://platform.claude.com/oauth/code/callback, a page Anthropic hosts. The bundled claude binary (@anthropic-ai/claude-code 2.1.198) contains that URI and no http://localhost:<port>/callback variant, and a redirect URI belonging to somebody's Meridian instance cannot be registered with Anthropic, so an automatic hand-off back into the UI would be rejected at the authorize step. What does exist: parseAuthorizationCodeInput already accepts a whole URL as well as a bare code, so the user can paste the callback page's address bar instead of hunting for the code. The UI says so, and the CLI prompt now mentions it too. The PKCE verifier never leaves the server. /profiles/login/start hands the browser an opaque login id and keeps the verifier, the state and the target config dir server-side: sending the verifier to the browser would put both halves of the exchange in one place that is not the one that started the flow. state is validated exactly as the CLI validates it, and two logins for different profiles are two entries that cannot interfere. A login is single-use and expires after ten minutes, but it is consumed by the exchange rather than by the attempt. A paste rejected locally -- no code in it, or state from a different tab -- never reached Anthropic, so nothing was spent and the login stays open for another paste. Only once a code has actually been sent is the id burned. The refusal carries `retryable` to say which of the two happened, so the page keeps its box open on the cheap failures and stops offering a retry on a spent code without re-deriving that judgement from the error-code list. One implementation of the exchange, not two. completeManualOAuthLogin did prompt, exchange and write in one function; the exchange-and-write half is now exchangeAuthorizationCodeForCredentials, called by both the CLI (which keeps its prompt) and the new route. Two copies of a token exchange drift, and the one that drifts is the one nobody runs. MERIDIAN_CREDENTIALS_READONLY refuses at /start, before the sign-in tab opens, and names where the login can be completed instead. The ordering is the point: an instance sharing another's credential files still serves this page, so the button is there to be clicked, and a user who signs in and is only then refused has burned a one-time code for nothing. Unknown profile ids are refused rather than created, deliberately unlike `meridian profile login` on the CLI. Creating an account slot is a different act from re-authenticating one that exists, and this surface is reachable by anyone who can reach the page, where the CLI is reachable by someone who already has a shell on the host. `meridian profile add` stays the one place a profile comes into existence. api and oauth-token profiles are refused with the reason -- neither has an OAuth flow, so neither gets a button either. Nothing from the code or the token response is logged. The refusal log records a reason code and nothing else, and the token endpoint's error body is not forwarded to the page. Both routes sit under /profiles/* and inherit requireAuth; a test pins that rather than trusting the prefix. Tests: 34 across two files -- login creation, state mismatch and the login surviving it, expiry, single-use, readonly refusal, wrong profile type, unknown id, both paste forms, API-key enforcement, and the exchange itself against a stubbed fetch. Also driven in a real browser against two throwaway instances, which is where two defects were found and fixed: a state mismatch used to consume the login, and the ten-second poll used to wipe an open panel mid-typing.
Adding an account no longer needs a shell on the box Meridian runs on. The Profiles page grows an "Add a profile" box: name it, sign in to Claude in the tab that opens, paste back what Claude shows -- the bare code or the whole callback URL, as for a re-login. The profile appears with its own config directory, ready to use with no restart. This is its own route, not a loosened guard on the login flow. /profiles/login/start refuses unknown ids deliberately, and that refusal is what stops "re-authenticate enrique-corp" from silently creating "enrique-crop" on a typo. Creating an account slot is a different act from re-authenticating one, so it gets its own button, its own routes and its own refusals. One definition of what a profile is made of, not two. profileAdd welded id validation, a collision check, mkdir and the profiles.json entry into the middle of an interactive CLI flow; the creation half is now createProfileSlot, called by the CLI at each of its three success paths and by the new route. isValidProfileId is lifted out of the regex that was written inline twice -- the id becomes a directory name, so having one answer to "what is a legal id" is the whole point. Nothing is written until the credentials are in hand. The exchange with Anthropic happens first and the profiles.json entry only once it succeeds, so a sign-in that is abandoned, rejected or never finished leaves no profile behind at all: there is no half-made slot stuck at "not logged in" for someone to notice and clean up, and the residue of a failure is at most an empty directory the next attempt reuses. A name created elsewhere during the sign-in is reported plainly rather than papered over, because the account just authorized went into that profile's directory. Every refusal happens at /start, before a sign-in tab opens: a name that is not a legal directory name, a name that already exists (pointing at "Log in from browser", which is what that button is for), and MERIDIAN_CREDENTIALS_READONLY. Refusing after sign-in would have burned a one-time code for nothing. A second sign-in for the same name supersedes the first rather than being refused. Refusing was the first implementation and driving it in a browser proved it wrong: cancelling the panel, reloading the page and closing the tab all abandon a sign-in without telling the server, so the name was locked out for the full ten minutes by a message telling the user to cancel something they had no way to cancel. One entry per name still holds, which is what stops two sign-ins for one new name from both completing; superseding only decides which of them survives. The ~/.claude import offer stays CLI-only. `meridian profile add` on a host whose default config dir is already signed in offers to adopt those credentials as the new profile; the UI always signs in fresh. Silently claiming the account you happen to be logged in as on that machine is not something a button press should be able to do, and the docs say so. MERIDIAN_CONFIG_DIR now relocates profiles.json and the per-profile config dirs, not just settings.json. The route writes where profiles.ts reads by construction instead of both hardcoding ~/.config/meridian: a writer and a reader that disagree produce a profile that is written and never seen. meridianConfigDir() is the single definition, and the oauth-token isolation dir is built from it too, so it stays the directory profileRemove deletes. What actually guards this is worth stating. Both routes sit under /profiles/* and inherit requireAuth, so they are behind MERIDIAN_API_KEY when one is set -- and when it is not, which is the default, the only thing between the page and a new profile is whatever can reach the port. Adding a profile is the most privileged thing the Profiles page does, so it is kept to exactly that: no delete, no rename, no edit. The docs say this rather than implying the routes are protected on their own. Nothing from the code or the token response is logged; the refusal log records a reason code and nothing else. loadProfileIds returns ids rather than profiles so a collision check can never reach an apiKey. Tests: 43 across two files -- id validation including traversal-shaped names, collisions against both the effective profile list and profiles.json, readonly refusal, supersede, expiry, single use, both paste forms, the slot NOT being written when the exchange fails, and API-key enforcement on both routes. Four assertions in profiles-unit moved from a hardcoded ~/.config/meridian to meridianConfigDir(), which is the path the code now resolves. Also driven in a real browser against two throwaway instances, one of them with MERIDIAN_CREDENTIALS_READONLY, which is where the lockout was found.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DRAFT - please do not review or merge yet
Stacked on #792. This branch is cut from
feat/profile-login-from-web-ui, notfrom
main, because it reuses the PKCE login machinery that PR introduces. GitHubcannot express that across a fork, so the diff below carries two commits — take
be4c279as the change under review here and #792 as its prerequisite.Adding an account currently needs a shell on the machine Meridian runs on. This
finishes what #792 started: the Profiles page grows an "Add a profile" box — name
it, sign in to Claude in the tab that opens, paste back what Claude shows (the bare
code or the whole callback URL). The profile appears with its own config directory,
ready to use with no restart.
It is its own route, not a loosened guard
/profiles/login/startrefuses unknown ids deliberately, and that refusal iswhat stops "re-authenticate
enrique-corp" from silently creatingenrique-cropon a typo. Creating an account slot is a different act from re-authenticating one,
so it gets its own button, its own routes and its own refusals.
One definition of what a profile is made of
profileAddwelded id validation, a collision check,mkdirand theprofiles.jsonentry into the middle of an interactive CLI flow. The creation halfis now
createProfileSlot, called by the CLI at each of its three success paths andby the new route.
isValidProfileIdis lifted out of a regex that had been writteninline twice — the id becomes a directory name, so having one answer to "what is a
legal id" is the whole point.
Nothing is written until the credentials are in hand
The exchange with Anthropic happens first; the
profiles.jsonentry only once itsucceeds. A sign-in that is abandoned, rejected or never finished leaves no
profile behind at all — no half-made slot stuck at "not logged in" for someone to
find and clean up. The residue of a failure is at most an empty directory the next
attempt reuses.
Every refusal happens at
/start, before a sign-in tab opens: an illegal name, aname that already exists (pointing at "Log in from browser", which is what that
button is for), and
MERIDIAN_CREDENTIALS_READONLY. Refusing after sign-in wouldburn a one-time code for nothing.
Superseding, not refusing, a second sign-in for the same name
Refusing was the first implementation, and driving it in a browser proved it wrong:
cancelling the panel, reloading the page and closing the tab all abandon a sign-in
without telling the server, so the name was locked out for the full ten minutes by a
message telling the user to cancel something they had no way to cancel. One entry per
name still holds — that is what stops two sign-ins for one new name from both
completing; superseding only decides which survives.
What actually guards this, stated rather than implied
Both routes sit under
/profiles/*and inheritrequireAuth, so they are behindMERIDIAN_API_KEYwhen one is set. When it is not — the default — the only thingbetween the page and a new profile is whatever can reach the port. Measured on the
author's box:
GET /profilesanswers200with no auth header, on loopback andthrough a reverse proxy alike. Adding a profile is the most privileged thing that
page does, so it is kept to exactly that: no delete, no rename, no edit. The docs say
this rather than implying the routes are protected on their own.
Nothing from the code or the token response is logged; the refusal log records a
reason code and nothing else.
loadProfileIdsreturns ids rather than profiles so acollision check can never reach an
apiKey.The
~/.claudeimport stays CLI-onlymeridian profile addon a host whose default config dir is already signed in offersto adopt those credentials. The UI always signs in fresh: silently claiming whichever
account you happen to be logged in as on that machine is not something a button press
should be able to do. The docs say so.
Tests
43 across two new files — id validation including traversal-shaped names, collisions
against both the effective profile list and
profiles.json, readonly refusal,supersede, expiry, single use, both paste forms, API-key enforcement on both routes,
and the slot not being written when the exchange fails. Also driven in a real
browser against two throwaway instances, one with
MERIDIAN_CREDENTIALS_READONLY,which is where the lockout above was found.
bun run testgreen andbun run typecheckclean on the branch.This PR is AI generated, but under direct supervision and on request of Nowaker.