Files
ai-agent/backend/users/routes.py
Gabriel Vidal deb01427d5 feat(backend): New projects — owner and guests start a project from a template
newproject/: the policy (guestDefaults.newProjects — quota, templates, the
ones the owner approves first, visibility, level, domain — and a per-guest
createProjects + address bucket), the New project form built from it, and the
flow: POST /api/new-project/form (the run's guesses as defaults) → submit →
shape/policy and host checks (a problem sends a corrected form back) → an
owner ask for a backend template → sidecar /scaffold → grant → /relocate +
resume the same session inside the project with an app-authored turn.
SpawnBody.newProject starts a guest's run in ~/projects/.new-project/<uid>
(staging_scope: /tmp only); /api/me reports newProjects; the thread follows
ConversationDetail.continuedIn.

Fix: a guest's turn into an existing conversation resumed with no cwd, so the
sidecar ran it in the lab checkout (its CLAUDE.md loaded) — it now resumes
where the session lives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:42:07 +02:00

409 lines
17 KiB
Python

"""Built-in login, ``/api/me``, the owner's user admin and conversation sharing."""
from __future__ import annotations
import time
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel
import schemas
from core.http import _r
from core.state import State
from projects.catalog import ROOT_SLUG
from users import access, mailer
from users.caller import (OWNER_ID, OWNER_NAME, SESSION_COOKIE, CallerDep,
OwnerDep, guest_from_record, on_guest_surface,
request_host)
from users.deliver import GUEST_PUBLIC_URL
from users.store import (DEFAULT_MODELS, IMPERSONATE_TTL_S, INVITE_TTL_S,
LEVELS, LINK_TTL_S, NOTIFY_CHANNELS, SESSION_TTL_S,
norm_email, valid_email)
router = APIRouter()
# One login mail per address per this many seconds (the endpoint is public).
_LINK_COOLDOWN_S = 60
def _origin(request: Request) -> str:
"""Where a login link should land: the guest surface's public URL, else
the host this request came in on."""
if GUEST_PUBLIC_URL:
return GUEST_PUBLIC_URL
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
return f"{proto}://{request.headers.get('host') or request_host(request)}"
def _secure(request: Request) -> bool:
return (request.headers.get("x-forwarded-proto") or request.url.scheme) == "https"
def _me(deps, caller) -> dict:
out = {"id": caller.id, "role": caller.role, "name": caller.name,
"email": caller.email, "readonly": caller.readonly,
"projects": [{"slug": s, "level": l} for s, l in sorted(caller.projects.items())],
"models": list(caller.models), "budgetUsd": caller.budget_usd,
"notify": None, "spentUsd": None, "mail": mailer.enabled(),
"impersonating": bool(caller.impersonator)}
from newproject import policy as np_policy
out["newProjects"] = np_policy.access(deps, caller)
if not caller.is_owner:
u = deps.users.get(caller.id) or {}
out["notify"] = u.get("notify") or {"channels": ["in_app"]}
out["defaultShortcuts"] = deps.users.defaults()["promptShortcuts"]
from conversations.catalog import _conversation_cards
from users.runs import month_spend
out["spentUsd"] = round(month_spend(
deps, caller.id, list(_conversation_cards(deps, False))), 4)
return out
# ── login ────────────────────────────────────────────────────────────────────
class LinkRequest(BaseModel):
email: str
@router.post("/api/auth/request-link", responses=_r(schemas.OkResponse))
def request_link(deps: State, request: Request, body: LinkRequest):
"""Mail a one-time sign-in link. Always answers ok — whether the address
is a user is nobody's business but the owner's."""
u = deps.users.by_email(body.email)
if u and not u.get("disabled") and mailer.enabled():
if time.time() - deps.auth.last_link_at(u["id"]) >= _LINK_COOLDOWN_S:
token = deps.auth.mint_link(u["id"])
try:
mailer.login_mail(u["email"], u.get("name") or u["id"],
f"{_origin(request)}/login?token={token}",
LINK_TTL_S // 60)
except mailer.MailError:
pass
return {"ok": True}
class LoginBody(BaseModel):
token: str
@router.post("/api/auth/login", responses=_r(schemas.Me))
def login(deps: State, request: Request, response: Response, body: LoginBody):
uid = deps.auth.consume_link(body.token)
u = deps.users.active(uid) if uid else None
if not u:
raise HTTPException(401, {"message": "this link is invalid or expired",
"kind": "link_invalid"})
# The session this browser already had (if any) gives way to the new one.
deps.auth.close_session(request.cookies.get(SESSION_COOKIE, ""))
token = deps.auth.open_session(u["id"], request.headers.get("user-agent", ""))
deps.users.touch_login(u["id"])
response.set_cookie(SESSION_COOKIE, token, max_age=SESSION_TTL_S,
httponly=True, secure=_secure(request), samesite="lax",
path="/")
return _me(deps, guest_from_record(u))
@router.post("/api/auth/logout", responses=_r(schemas.OkResponse))
def logout(deps: State, request: Request, response: Response):
deps.auth.close_session(request.cookies.get(SESSION_COOKIE, ""))
response.delete_cookie(SESSION_COOKIE, path="/")
return {"ok": True}
# ── me ───────────────────────────────────────────────────────────────────────
@router.get("/api/me", responses=_r(schemas.Me))
def me(deps: State, request: Request, caller: CallerDep):
return {**_me(deps, caller), "surface": "guest" if on_guest_surface(request)
else "admin"}
class MePatch(BaseModel):
name: str | None = None
notify: dict | None = None
@router.put("/api/me", responses=_r(schemas.Me))
def me_update(deps: State, request: Request, caller: CallerDep, body: MePatch):
"""A guest's own preferences: display name and notification channels."""
if caller.is_owner:
raise HTTPException(400, "the owner's settings live in Settings")
deps.users.update(caller.id, body.model_dump(exclude_none=True))
u = deps.users.get(caller.id)
return {**_me(deps, guest_from_record(u)), "surface": "guest"
if on_guest_surface(request) else "admin"}
# ── user admin (owner) ───────────────────────────────────────────────────────
def _spend(deps) -> dict[str, dict]:
from conversations.catalog import _conversation_cards
from users.runs import spend_by_user
return spend_by_user(list(_conversation_cards(deps, False)))
def _public(deps, u: dict, spend: dict | None = None) -> dict:
"""A user as the admin page shows them: the record, their live sessions
and what their conversations cost (this month — what the budget is
checked against — and all time)."""
s = (spend if spend is not None else _spend(deps)).get(u["id"]) or {}
return {"createProjects": False, "bucket": "", **u,
"sessions": len(deps.auth.sessions_of(u["id"])),
"spentUsd": round(s.get("month", 0.0), 4),
"totalSpentUsd": round(s.get("total", 0.0), 4),
"conversations": s.get("conversations", 0),
"createdProjects": deps.new_projects.used(u["id"])}
@router.get("/api/users", responses=_r(schemas.UsersResponse))
def users_list(deps: State, _: OwnerDep):
spend = _spend(deps)
return {"users": [_public(deps, u, spend) for u in
sorted(deps.users.all(), key=lambda u: u.get("name") or "")],
"levels": list(LEVELS), "channels": list(NOTIFY_CHANNELS),
"defaultModels": list(DEFAULT_MODELS), "mail": mailer.enabled(),
"guestUrl": GUEST_PUBLIC_URL or None}
class UserBody(BaseModel):
email: str | None = None
name: str | None = None
projects: dict | None = None
models: list[str] | None = None
budgetUsd: float | None = None
notify: dict | None = None
disabled: bool | None = None
createProjects: bool | None = None # may start projects (newproject/)
bucket: str | None = None # <name>.<bucket>.<domain>; blank ⇒ their id
def _check_grants(projects: dict | None) -> None:
if projects and ROOT_SLUG in projects:
raise HTTPException(400, f"the {ROOT_SLUG!r} repo can't be granted to a guest")
@router.post("/api/users", responses=_r(schemas.GuestUser))
def users_create(deps: State, _: OwnerDep, body: UserBody):
if not body.email or not valid_email(body.email):
raise HTTPException(400, "a valid email is required")
_check_grants(body.projects)
fields = body.model_dump(exclude_none=True)
fields.pop("email", None)
try:
u = deps.users.create(norm_email(body.email), fields.pop("name", None), **fields)
except ValueError as e:
raise HTTPException(400, str(e))
deps.hub.publish({"type": "users"})
return _public(deps, u)
@router.put("/api/users/{uid}", responses=_r(schemas.GuestUser))
def users_update(deps: State, _: OwnerDep, uid: str, body: UserBody):
_check_grants(body.projects)
try:
u = deps.users.update(uid, body.model_dump(exclude_none=True))
except KeyError:
raise HTTPException(404, "no such user")
except ValueError as e:
raise HTTPException(400, str(e))
if u.get("disabled"):
deps.auth.revoke_user(uid)
deps.hub.publish({"type": "users"})
return _public(deps, u)
@router.delete("/api/users/{uid}", responses=_r(schemas.OkResponse))
def users_delete(deps: State, _: OwnerDep, uid: str):
if not deps.users.delete(uid):
raise HTTPException(404, "no such user")
deps.auth.revoke_user(uid)
deps.hub.publish({"type": "users"})
return {"ok": True}
class LinkBody(BaseModel):
send: bool = False
@router.post("/api/users/{uid}/login-link", responses=_r(schemas.LoginLink))
def users_login_link(deps: State, request: Request, _: OwnerDep, uid: str,
body: LinkBody):
"""An invitation link — to copy into a message, or mailed (``send``) with
an invitation note. Lives ``INVITE_TTL_S`` (days), unlike the quarter-hour
link a guest asks for on the login page; still single-use."""
u = deps.users.active(uid)
if not u:
raise HTTPException(404, "no such (enabled) user")
url = f"{_origin(request)}/login?token={deps.auth.mint_link(uid, INVITE_TTL_S)}"
sent = False
if body.send:
try:
mailer.invite_mail(
u["email"], u.get("name") or uid, url, inviter=OWNER_NAME,
projects=sorted((u.get("projects") or {}).keys()),
expires=_human_ttl(INVITE_TTL_S),
home=f"{GUEST_PUBLIC_URL}/login" if GUEST_PUBLIC_URL else None)
sent = True
except mailer.MailError as e:
raise HTTPException(502, str(e))
return {"url": url, "sent": sent, "expiresInS": INVITE_TTL_S}
def _human_ttl(s: int) -> str:
if s >= 2 * 86400:
return f"{s // 86400} days"
if s >= 2 * 3600:
return f"{s // 3600} hours"
return f"{max(1, s // 60)} minutes"
@router.delete("/api/users/{uid}/sessions")
def users_revoke(deps: State, _: OwnerDep, uid: str):
return {"revoked": deps.auth.revoke_user(uid)}
@router.post("/api/users/{uid}/impersonate", responses=_r(schemas.OkResponse))
def users_impersonate(deps: State, request: Request, response: Response,
owner: OwnerDep, uid: str):
"""Look at the app as this guest: a short-lived session for them, set on
*this* (the admin) host. Every request then is the guest's — their
routes, their conversations, their composer — until ``/api/auth/logout``
(the PWA's *Back to admin*) or ``IMPERSONATE_TTL_S``. It only ever
narrows what the browser can do, and the cookie is host-only: the guest
host never sees it."""
u = deps.users.active(uid)
if not u:
raise HTTPException(404, "no such (enabled) user")
deps.auth.close_session(request.cookies.get(SESSION_COOKIE, ""))
token = deps.auth.open_session(uid, request.headers.get("user-agent", ""),
impersonator=owner.id)
response.set_cookie(SESSION_COOKIE, token, max_age=IMPERSONATE_TTL_S,
httponly=True, secure=_secure(request), samesite="lax",
path="/")
return {"ok": True}
# ── what every guest gets ────────────────────────────────────────────────────
class GuestDefaultsBody(BaseModel):
promptShortcuts: list[dict] | None = None
sharedSkills: list[str] | None = None
newProjects: dict | None = None # schemas.NewProjectPolicy
@router.get("/api/guest-defaults", responses=_r(schemas.GuestDefaults))
def guest_defaults_get(deps: State, _: OwnerDep):
return deps.users.defaults()
@router.put("/api/guest-defaults", responses=_r(schemas.GuestDefaults))
def guest_defaults_put(deps: State, _: OwnerDep, body: GuestDefaultsBody):
"""The starting shortcuts a new guest gets and the skills every guest run
is handed (sidecar ``shared_skills``) — a skill toggle in the catalog
sends the whole list."""
out = deps.users.set_defaults(body.model_dump(exclude_none=True))
deps.hub.publish({"type": "users"})
return out
@router.get("/api/guest-skills", responses=_r(schemas.GuestSkillsResponse))
def guest_skills(deps: State, caller: CallerDep):
"""The skills the caller's runs can use, for the composer's mentions: the
owner's shared ones (as ``shared:<name>``, the plugin's namespace) and
the granted projects' own ``.claude/skills``."""
from dashboards import skills as skills_mod
shared = deps.users.defaults()["sharedSkills"]
cat = skills_mod.catalog()
by_ref = {skills_mod.share_ref(s): s for s in cat}
out, seen = [], set()
for ref in shared:
s = by_ref.get(ref)
if s and s["name"] not in seen:
seen.add(s["name"])
out.append({"name": f"shared:{s['name']}", "description": s["description"],
"triggerWords": s["triggerWords"], "source": "shared"})
for s in cat:
if s["sourceKind"] == "project" and caller.can(s["source"], "chat"):
out.append({"name": s["name"], "description": s["description"],
"triggerWords": s["triggerWords"], "source": s["source"]})
return {"skills": out}
# ── conversation sharing ─────────────────────────────────────────────────────
def _name(deps, uid: str) -> str:
if uid == OWNER_ID:
return OWNER_NAME
u = deps.users.get(uid) or {}
return u.get("name") or uid
def _share_state(deps, sid: str, caller) -> dict:
meta = deps.meta_store.peek(sid)
slug = access.project_of(meta)
owner = meta.get("owner")
candidates = [{"id": u["id"], "name": u.get("name") or u["id"],
"level": (u.get("projects") or {}).get(slug)}
for u in deps.users.all()
if not u.get("disabled") and u["id"] != owner]
audience = access.audience(meta)
return {"sessionId": sid, "owner": owner, "project": slug,
"participants": list(meta.get("participants") or []),
"candidates": candidates,
"notifying": caller.id in audience,
"audience": [{"id": u, "name": _name(deps, u)} for u in audience]}
@router.get("/api/conversation-share", responses=_r(schemas.ConversationShare))
def share_get(deps: State, caller: CallerDep, id: str):
sid = access.session_of(id)
access.ensure_visible(deps, caller, sid)
out = _share_state(deps, sid, caller)
if not caller.is_owner:
# A guest only sees who's in it — not the owner's whole guest list.
out["candidates"] = [c for c in out["candidates"] if c["level"]]
return out
class ShareBody(BaseModel):
id: str
participants: list[str]
@router.put("/api/conversation-share", responses=_r(schemas.ConversationShare))
def share_put(deps: State, caller: CallerDep, body: ShareBody):
"""Who else may read and write this conversation. The owner can share
anything with anyone; the guest who started a conversation can share it
with users who already have access to its project."""
sid = access.session_of(body.id)
access.ensure_visible(deps, caller, sid)
meta = deps.meta_store.peek(sid)
slug = access.project_of(meta)
if not caller.is_owner and meta.get("owner") != caller.id:
raise HTTPException(403, "only the conversation's owner can share it")
want = list(dict.fromkeys(p for p in body.participants if p))
for uid in want:
u = deps.users.active(uid)
if not u:
raise HTTPException(400, f"unknown user {uid!r}")
if not caller.is_owner and not (u.get("projects") or {}).get(slug):
raise HTTPException(403, f"{u.get('name') or uid} has no access to {slug!r}")
deps.meta_store.update(sid, {"participants": [u for u in want
if u != meta.get("owner")]})
deps.hub.publish({"type": "meta", "id": sid})
return _share_state(deps, sid, caller)
class FollowBody(BaseModel):
id: str
on: bool
@router.put("/api/conversation-follow", responses=_r(schemas.ConversationShare))
def follow_put(deps: State, caller: CallerDep, body: FollowBody):
"""Turn the caller's notifications for one conversation on or off — the
bell in its header. On for the owner on a guest's conversation means
*follow it*; off for anyone means *mute it* (users/access.audience)."""
sid = access.session_of(body.id)
access.ensure_visible(deps, caller, sid)
access.set_follow(deps, sid, caller.id, body.on)
out = _share_state(deps, sid, caller)
if not caller.is_owner:
out["candidates"] = [c for c in out["candidates"] if c["level"]]
return out