Files
ai-agent/backend/users/access.py
Gabriel Vidal 3d741b5b1e feat(backend): per-user notifications, guest spend on the users list, impersonation
Notifications follow the conversation's audience (users/access.audience):
whoever started it and the people it's shared with, plus the owner once they
follow a guest's conversation (writing in it, or PUT /api/conversation-follow),
minus whoever muted it (meta.follow). Pushes and asks fan out to each of them
on their own channels — the owner on the webhooks, a guest in-app + email —
and the feeds (/api/notifications, /api/notify-log, the phone's /unread)
list only what the caller hears, links rebased to the reader's host. Forms
carry forYou so "waiting for feedback" is per user.

/api/users carries spentUsd (this month), totalSpentUsd and conversations per
guest (users/runs.spend_by_user, the same sum the budget is checked against).

POST /api/users/{id}/impersonate opens a 4-hour guest session for the owner's
browser on the admin host (imp on the record: never extended, not counted in
the guest's sessions); /api/me says impersonating; logout ends it. A spawn
while impersonating skips the "guest started" ping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:16:03 +02:00

117 lines
4.2 KiB
Python

"""
Conversation-level access for guests.
A conversation belongs to a guest when its metadata says ``owner: <user id>``
(stamped by the spawn — a conversation without one is the owner's), and is
**shared** with the ids in ``participants``. A guest sees exactly those two
sets; the owner sees everything.
Seeing a conversation is not hearing about it: its pushes, asks and forms
reach its **audience** (:func:`audience`) — whoever started it and the people
it's shared with, plus the owner once they follow a guest's conversation
(writing in it is enough), minus anyone who muted it (``meta.follow``). A conversation id can be a root transcript
(``<dir>/<sid>.jsonl``), a subagent's (``<dir>/<sid>/subagents/agent-….jsonl``)
or a bare session id — :func:`session_of` maps all three to the root session
whose metadata decides.
"""
from __future__ import annotations
import pathlib
from fastapi import HTTPException
from users.caller import OWNER_ID, Caller
def session_of(conv_id: str) -> str:
parts = pathlib.PurePosixPath(conv_id or "").parts
if "subagents" in parts:
i = parts.index("subagents")
if i > 0:
return parts[i - 1]
name = parts[-1] if parts else ""
return name[:-6] if name.endswith(".jsonl") else name
def meta_visible(caller: Caller, meta: dict) -> bool:
if caller.is_owner:
return True
return (meta.get("owner") == caller.id
or caller.id in (meta.get("participants") or ()))
def visible(deps, caller: Caller, conv_id_or_sid: str) -> bool:
if caller.is_owner:
return True
return meta_visible(caller, deps.meta_store.peek(session_of(conv_id_or_sid)))
def ensure_visible(deps, caller: Caller, conv_id_or_sid: str) -> None:
"""404 — not 403 — so a guest can't probe which conversations exist."""
if not visible(deps, caller, conv_id_or_sid):
raise HTTPException(404, "not found")
def project_of(meta: dict) -> str | None:
ps = meta.get("projects") or []
return ps[0] if ps else None
def can_send(deps, caller: Caller, sid: str) -> bool:
"""May ``caller`` add a turn (or stop the run) of conversation ``sid``?
The owner always. A participant always — sharing a conversation *is* the
invitation to talk in it. The guest who started it while they still hold
``chat`` on its project (a revoked grant stops them)."""
if caller.is_owner:
return True
meta = deps.meta_store.peek(sid)
if caller.id in (meta.get("participants") or ()):
return True
return meta.get("owner") == caller.id and caller.can(project_of(meta), "chat")
def filter_cards(caller: Caller, cards: list[dict]) -> list[dict]:
if caller.is_owner:
return cards
return [c for c in cards if meta_visible(caller, c.get("meta") or {})]
def owner_of(meta: dict) -> str:
"""Whose conversation this is: a guest's id, else the owner's."""
return meta.get("owner") or OWNER_ID
def audience(meta: dict) -> list[str]:
"""Who hears about this conversation (pushes, asks, forms; the feed).
By default the person who started it and everyone it's shared with. The
owner sees every conversation but only *hears* about a guest's one once
they follow it — ``meta.follow`` (``{user id: bool}``) adds them, and lets
anyone mute a conversation they'd otherwise hear about. A guest can't
follow their way into a conversation they can't see: only the owner's
``True`` adds a name."""
pref = meta.get("follow") or {}
people = [owner_of(meta), *(meta.get("participants") or ())]
if pref.get(OWNER_ID):
people.append(OWNER_ID)
return [u for u in dict.fromkeys(people) if pref.get(u, True)]
def hears(caller: Caller, meta: dict) -> bool:
return caller.id in audience(meta)
def set_follow(deps, sid: str, uid: str, on: bool) -> None:
"""Record ``uid``'s choice to hear (or not) about conversation ``sid``."""
pref = dict(deps.meta_store.peek(sid).get("follow") or {})
pref[uid] = bool(on)
deps.meta_store.update(sid, {"follow": pref})
deps.hub.publish({"type": "meta", "id": sid})
def is_shared(meta: dict) -> bool:
"""Several people may write here: the thread labels who said what."""
return bool(meta.get("owner") or meta.get("participants"))