Skip to content

feat(profiles): create a profile from the web UI - #794

Draft
Nowaker wants to merge 2 commits into
rynfar:mainfrom
Nowaker:feat/profile-add-from-web-ui
Draft

feat(profiles): create a profile from the web UI#794
Nowaker wants to merge 2 commits into
rynfar:mainfrom
Nowaker:feat/profile-add-from-web-ui

Conversation

@Nowaker

@Nowaker Nowaker commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

DRAFT - please do not review or merge yet

Stacked on #792. This branch is cut from feat/profile-login-from-web-ui, not
from main, because it reuses the PKCE login machinery that PR introduces. GitHub
cannot express that across a fork, so the diff below carries two commits — take
be4c279 as 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/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

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 a regex that had been 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; the profiles.json entry only once it
succeeds. 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, 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
burn 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 inherit requireAuth, so they are behind
MERIDIAN_API_KEY when one is set. When it is not — the default — the only thing
between the page and a new profile is whatever can reach the port.
Measured on the
author's box: GET /profiles answers 200 with no auth header, on loopback and
through 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. loadProfileIds returns ids rather than profiles so a
collision check can never reach an apiKey.

The ~/.claude import stays CLI-only

meridian profile add on a host whose default config dir is already signed in offers
to 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 test green and bun run typecheck clean on the branch.


This PR is AI generated, but under direct supervision and on request of Nowaker.

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.
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