From 826983aefbf174f1af5ddb9268708a03398efd31 Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Mon, 2 Nov 2026 11:12:28 +0530 Subject: [PATCH 1/2] control-plane: add the account functions, model_choices and router mount that 49bec0a uses --- control-plane/mergit_cp/accounts.py | 138 ++++++++++++++++++++++++ control-plane/mergit_cp/app.py | 5 +- control-plane/mergit_cp/auth.py | 7 +- control-plane/mergit_cp/config.py | 11 +- control-plane/mergit_cp/routes_plans.py | 2 +- 5 files changed, 158 insertions(+), 5 deletions(-) diff --git a/control-plane/mergit_cp/accounts.py b/control-plane/mergit_cp/accounts.py index 4e146fa..7bb8ba9 100644 --- a/control-plane/mergit_cp/accounts.py +++ b/control-plane/mergit_cp/accounts.py @@ -54,6 +54,18 @@ class OfferNotYours(Exception): """A merge offer made for a different account.""" +class NotFound(Exception): + """The sign-in method or wallet is not on the caller's account.""" + + +class LastMethod(Exception): + """Removing it would leave the account with no way to sign in.""" + + +class Busy(Exception): + """A plan the change would touch is approved, funded or running.""" + + def trial_address(user_id: UUID) -> str: """The on-chain requester of a user's free-credit plans while they have no wallet: lowercase 0x + 40 hex. @@ -368,3 +380,129 @@ async def merge_users(conn: Conn, keep: UUID, absorb: UUID) -> None: await conn.execute("UPDATE merged_users SET into_user_id = %s WHERE into_user_id = %s", (keep, absorb)) await conn.execute("INSERT INTO merged_users (old_user_id, into_user_id) VALUES (%s, %s)", (absorb, keep)) await conn.execute("DELETE FROM users WHERE user_id = %s", (absorb,)) + + +# --- removing sign-in methods, and deleting the account --------------------------------------------------- + +IN_FLIGHT = ("approved", "funded", "running") +REDACTED = "[deleted]" + + +async def _lock_user(conn: Conn, user_id: UUID) -> None: + """Serialises changes to one account's sign-in methods, so two removals at once cannot both see the other + method still there and leave the account with none.""" + await conn.execute("SELECT 1 FROM users WHERE user_id = %s FOR UPDATE", (user_id,)) + + +async def _method_count(conn: Conn, user_id: UUID) -> int: + cur = await conn.execute( + "SELECT (SELECT count(*) FROM identities WHERE user_id = %s)" + " + (SELECT count(*) FROM wallets WHERE user_id = %s)", + (user_id, user_id), + ) + row = await cur.fetchone() + assert row is not None + return int(row[0]) + + +async def _in_flight(conn: Conn, requesters: list[str]) -> bool: + """Whether any plan of these requesters is approved, funded or running. Locks their plans as approval + does (``plans.load(for_update=True)``), so an approval that commits first is seen here.""" + cur = await conn.execute("SELECT state FROM plans WHERE requester = ANY(%s) FOR UPDATE", (requesters,)) + return any(row[0] in IN_FLIGHT for row in await cur.fetchall()) + + +async def unlink_wallet(conn: Conn, user_id: UUID, address: str) -> None: + """Removes one of the caller's own wallets. Sessions acting as it fall back to the account's default + address: its first remaining wallet, else its trial address.""" + await _lock_user(conn, user_id) + cur = await conn.execute("SELECT 1 FROM wallets WHERE address = %s AND user_id = %s", (address, user_id)) + if await cur.fetchone() is None: + raise NotFound + if await _method_count(conn, user_id) <= 1: + raise LastMethod + if await _in_flight(conn, [address]): + raise Busy + await conn.execute("DELETE FROM wallets WHERE address = %s AND user_id = %s", (address, user_id)) + await conn.execute( + "UPDATE sessions SET address = %s WHERE user_id = %s AND address = %s", + (await default_address(conn, user_id), user_id, address), + ) + + +async def unlink_identity(conn: Conn, user_id: UUID, provider: Provider) -> None: + """Removes the caller's sign-in with ``provider`` (every identity of that provider on the account).""" + await _lock_user(conn, user_id) + cur = await conn.execute( + "SELECT count(*) FROM identities WHERE user_id = %s AND provider = %s", (user_id, provider) + ) + row = await cur.fetchone() + assert row is not None + removing = int(row[0]) + if removing == 0: + raise NotFound + if await _method_count(conn, user_id) <= removing: + raise LastMethod + await conn.execute("DELETE FROM identities WHERE user_id = %s AND provider = %s", (user_id, provider)) + + +def _method_hash(settings: Settings, kind: str, value: str) -> bytes: + """A keyed hash of one sign-in method, the only form ``deleted_methods`` keeps. The prefix sets it apart + from every other hash made with the same key (session tokens have no colon; merge offers and emailed + codes have their own prefixes).""" + return token_hash(settings, f"deleted-method:v0:{kind}:{value}") + + +async def _method_hashes(conn: Conn, settings: Settings, user_id: UUID) -> list[bytes]: + cur = await conn.execute("SELECT provider, subject FROM identities WHERE user_id = %s", (user_id,)) + hashes = [_method_hash(settings, r[0], r[1]) for r in await cur.fetchall()] + cur = await conn.execute("SELECT address FROM wallets WHERE user_id = %s", (user_id,)) + hashes.extend(_method_hash(settings, "wallet", r[0]) for r in await cur.fetchall()) + return hashes + + +async def credits_forfeited(conn: Conn, settings: Settings, user_id: UUID) -> bool: + """Whether one of this account's sign-in methods belonged to a deleted account: the free credits are once + per person, and deleting an account does not hand them out again.""" + hashes = await _method_hashes(conn, settings, user_id) + if not hashes: + return False + cur = await conn.execute("SELECT 1 FROM deleted_methods WHERE method_hash = ANY(%s) LIMIT 1", (hashes,)) + return await cur.fetchone() is not None + + +async def delete_account(conn: Conn, settings: Settings, user_id: UUID) -> None: + """Deletes the user with its sign-in methods, wallets, settings and sessions. Refused (``Busy``) while one + of its plans is approved, funded or running. + + What stays: its plans and tasks, with the goal, repository, ref and task text replaced by ``[deleted]``, + since the money ledger (``money_events``) and the dispatches point at them; and a keyed hash of each + sign-in method in ``deleted_methods``, so the same person cannot collect the free credits again. + """ + await _lock_user(conn, user_id) + owned = await owned_addresses(conn, user_id, "") + if await _in_flight(conn, owned): + raise Busy + hashes = await _method_hashes(conn, settings, user_id) + if hashes: + await conn.execute( + "INSERT INTO deleted_methods (method_hash) SELECT unnest(%s::bytea[]) ON CONFLICT DO NOTHING", + (hashes,), + ) + await conn.execute( + "UPDATE tasks SET title = %s, instructions = %s" + " WHERE plan_uuid IN (SELECT plan_uuid FROM plans WHERE requester = ANY(%s))", + (REDACTED, REDACTED, owned), + ) + await conn.execute( + "UPDATE plans SET goal = %s, repo_url = %s, ref = %s, updated_at = now() WHERE requester = ANY(%s)", + (REDACTED, REDACTED, REDACTED, owned), + ) + # A session from before accounts existed has no user but acts as one of this user's wallets. + await conn.execute( + "UPDATE sessions SET revoked_at = now()" + " WHERE user_id IS NULL AND address = ANY(%s) AND revoked_at IS NULL", + (owned,), + ) + # Identities, wallets, sessions, settings and pending merge offers go with the user (ON DELETE CASCADE). + await conn.execute("DELETE FROM users WHERE user_id = %s", (user_id,)) diff --git a/control-plane/mergit_cp/app.py b/control-plane/mergit_cp/app.py index 76a714a..7a3c3ca 100644 --- a/control-plane/mergit_cp/app.py +++ b/control-plane/mergit_cp/app.py @@ -17,6 +17,7 @@ oauth, otp, redeem, + routes_account, routes_plans, routes_pr, routes_reputation, @@ -56,7 +57,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.state.agents = load_agents(settings.agents_file, settings.agents_config) require_schemas() # the plan checks and events are validated against spec/schemas prices = PriceTable.load(Path(settings.prices_file)) - prices.model_call_cost(settings.planner_model, 0, 0, 0) # the planner model must have a price + for model in settings.model_choices: # every planner model a user may choose must have a price + prices.model_call_cost(model, 0, 0, 0) pool = await open_pool(settings.database_url, settings.db_pool_max) async with pool.connection() as conn: await migrate(conn) @@ -110,6 +112,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: ) app.include_router(auth.router) app.include_router(auth.account_router) + app.include_router(routes_account.router) app.include_router(redeem.router) app.include_router(oauth.router) app.include_router(otp.router) diff --git a/control-plane/mergit_cp/auth.py b/control-plane/mergit_cp/auth.py index 9d6e063..dfca033 100644 --- a/control-plane/mergit_cp/auth.py +++ b/control-plane/mergit_cp/auth.py @@ -293,10 +293,13 @@ async def merge_accounts( @account_router.post("/trial") -async def start_trial(principal: PrincipalDep, pool: PoolDep) -> dict[str, str]: - """Chooses the free trial: plans funded from the treasury's free credits, with no wallet.""" +async def start_trial(principal: PrincipalDep, settings: SettingsDep, pool: PoolDep) -> dict[str, str]: + """Chooses the free trial: plans funded from the treasury's free credits, with no wallet. Refused when one + of the account's sign-in methods belonged to a deleted account: the free credits are once per person.""" user_id = _member(principal) async with pool.connection() as conn: + if await accounts.credits_forfeited(conn, settings, user_id): + raise HTTPException(status_code=403, detail="the free credits were already used by this person") started = await accounts.start_trial(conn, user_id) return {"started_at": started.isoformat()} diff --git a/control-plane/mergit_cp/config.py b/control-plane/mergit_cp/config.py index fca0078..c870df2 100644 --- a/control-plane/mergit_cp/config.py +++ b/control-plane/mergit_cp/config.py @@ -33,6 +33,9 @@ class Settings(BaseSettings): planner_model: str """The planner's model (HLD D18 is open, so this is a deployment choice, not a code default).""" + planner_models: Annotated[list[str], NoDecode] = [] + """Other planner models a user may choose in their settings (comma-separated). Each must be priced, like + ``planner_model``. Empty: everyone gets ``planner_model``. See ``model_choices``.""" prices_file: str = "prices.toml" planner_max_tasks: int = Field(default=8, ge=1, le=32) plans_per_hour: int = Field(default=10, ge=1) @@ -115,6 +118,7 @@ def _planner_model_follows_the_provider(self) -> "Settings": # the model that really answers (mergit_worker/providers.py). Unchanged whenever Anthropic is the # provider. self.planner_model = effective_model(self.planner_model) + self.planner_models = [effective_model(m) for m in self.planner_models] return self @model_validator(mode="before") @@ -131,6 +135,11 @@ def sign_in_domains(self) -> list[str]: """Every hostname a wallet may sign in for.""" return [self.app_domain, *self.extra_domains] + @property + def model_choices(self) -> list[str]: + """The planner models a user may choose: ``planner_model``, then ``planner_models``, once each.""" + return list(dict.fromkeys([self.planner_model, *self.planner_models])) + @property def public_origin(self) -> str: """Where the browser reaches the web app and this API (same origin behind Caddy).""" @@ -149,7 +158,7 @@ def google_enabled(self) -> bool: def email_enabled(self) -> bool: return bool(self.smtp_host and self.smtp_from) - @field_validator("cors_origins", "repo_hosts", "extra_domains", mode="before") + @field_validator("cors_origins", "repo_hosts", "extra_domains", "planner_models", mode="before") @classmethod def _split_origins(cls, value: object) -> object: if isinstance(value, str): diff --git a/control-plane/mergit_cp/routes_plans.py b/control-plane/mergit_cp/routes_plans.py index 2315f25..c982233 100644 --- a/control-plane/mergit_cp/routes_plans.py +++ b/control-plane/mergit_cp/routes_plans.py @@ -144,7 +144,7 @@ async def create_plan( async with pool.connection() as conn: chosen = (await user_settings.load(conn, principal.user_id)).planner_model if ( - chosen in settings.model_choices + chosen is not None and chosen in settings.model_choices ): # a model the operator has since withdrawn falls back to the default model = chosen budget = int(body.budget_usdc_units) From a7e89947e2fc63178ef542d28e9148cbf6016647 Mon Sep 17 00:00:00 2001 From: OfficialAbhinavSingh Date: Mon, 2 Nov 2026 11:28:38 +0530 Subject: [PATCH 2/2] tests: a second GitHub sign-in is from a second device, not the same browser session The callback deliberately links rather than opening a new session when the caller is already signed in (oauth.py, since 7ed7c6b) -- the test was asserting two sessions from one browser that was never going to get them. --- control-plane/tests/test_user_settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/control-plane/tests/test_user_settings.py b/control-plane/tests/test_user_settings.py index c892fa8..6fd31f2 100644 --- a/control-plane/tests/test_user_settings.py +++ b/control-plane/tests/test_user_settings.py @@ -295,6 +295,7 @@ def test_sessions_lists_this_one_as_current(client: TestClient) -> None: def test_signing_out_other_devices_keeps_this_one(client: TestClient, configured: Settings) -> None: github_login(client) this = client.cookies.get("mergit_session") + client.cookies.clear() # a second device: signing in again while already signed in links, not re-sessions github_login(client) # a second session of the same user (same GitHub identity) other = client.cookies.get("mergit_session") assert this != other