users.json holds guests (per-project grants view/chat/ship, models,
monthly budget, notification channels); auth.json holds one-time login
links and browser sessions (sha256 of the tokens only). Login is a
magic link mailed over SMTP (SMTP_* env), or copied from the owner's
user admin. The gate now resolves a Caller: a guest session cookie
decides the request outright and may only reach GUEST_ROUTES (default
deny, same-origin for writes); on GUEST_HOSTS nothing is ever the owner;
every existing path (Authelia via Traefik, loopback, token, read-only
key) is the owner exactly as before.
Routes: /api/auth/{request-link,login,logout}, /api/me (GET/PUT prefs),
/api/users CRUD + login-link + revoke, /api/conversation-share.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
2.6 KiB
Python
79 lines
2.6 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. 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 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 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"))
|