Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions control-plane/mergit_cp/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,))
5 changes: 4 additions & 1 deletion control-plane/mergit_cp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
oauth,
otp,
redeem,
routes_account,
routes_plans,
routes_pr,
routes_reputation,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions control-plane/mergit_cp/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}

Expand Down
11 changes: 10 additions & 1 deletion control-plane/mergit_cp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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)."""
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion control-plane/mergit_cp/routes_plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions control-plane/tests/test_user_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading