add trips permissions

This commit is contained in:
Logan
2026-06-21 20:00:48 -04:00
parent 981f03ac06
commit 6ae4d398f8
9 changed files with 425 additions and 10 deletions
+2 -1
View File
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links
from app.internal import firestore as fstore
@@ -72,6 +72,7 @@ app.include_router(trips.router, dependencies=[Depends(require_service_or_fi
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
@app.get("/health")
+2
View File
@@ -148,6 +148,8 @@ class TripCreate(BaseModel):
end_date: str # YYYY-MM-DD
available_tags: List[str] = [] # tag labels configured for this trip
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
visibility: str = "public" # "public" | "private"
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
class TripEventCreate(BaseModel):
+128
View File
@@ -0,0 +1,128 @@
import random
import string
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_service_key
from app.internal.logger import logger
router = APIRouter(prefix="/auth", tags=["auth"])
_CODE_TTL_MINUTES = 15
def _gen_code() -> str:
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
# ---------------------------------------------------------------------------
# Web: generate a short-lived linking code
# ---------------------------------------------------------------------------
@router.post("/link/generate")
async def generate_link_code(decoded: dict = Depends(require_firebase_token)):
"""Authenticated Firebase user generates a code to paste into Discord /link."""
firebase_uid = decoded["uid"]
# Check if already linked
existing = await fstore.doc_get("firebase_discord_links", firebase_uid)
if existing and existing.get("discord_user_id"):
return {
"already_linked": True,
"discord_user_id": existing["discord_user_id"],
}
code = _gen_code()
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=_CODE_TTL_MINUTES)).isoformat()
await fstore.doc_set("link_codes", code, {
"firebase_uid": firebase_uid,
"expires_at": expires_at,
}, merge=False)
return {"code": code, "expires_minutes": _CODE_TTL_MINUTES}
# ---------------------------------------------------------------------------
# Discord bot: resolve a code and store the link
# ---------------------------------------------------------------------------
class LinkResolveBody(BaseModel):
code: str
discord_user_id: str
discord_username: str = ""
@router.post("/link")
async def resolve_link_code(body: LinkResolveBody, _: dict = Depends(require_service_key)):
"""Discord bot resolves a linking code and permanently links the accounts."""
doc = await fstore.doc_get("link_codes", body.code.upper().strip())
if not doc:
raise HTTPException(404, "Invalid or expired code.")
expires_at = datetime.fromisoformat(doc["expires_at"])
if datetime.now(timezone.utc) > expires_at:
await fstore.doc_delete("link_codes", body.code)
raise HTTPException(410, "Code has expired. Generate a new one from the web app.")
firebase_uid = doc["firebase_uid"]
# Check if this Discord account is already linked to a different Firebase UID
existing = await fstore.doc_get("discord_links", body.discord_user_id)
if existing and existing.get("firebase_uid") and existing["firebase_uid"] != firebase_uid:
raise HTTPException(409, "This Discord account is already linked to a different account.")
now = datetime.now(timezone.utc).isoformat()
# Store both directions
await fstore.doc_set("discord_links", body.discord_user_id, {
"firebase_uid": firebase_uid,
"discord_username": body.discord_username,
"linked_at": now,
}, merge=False)
await fstore.doc_set("firebase_discord_links", firebase_uid, {
"discord_user_id": body.discord_user_id,
"discord_username": body.discord_username,
"linked_at": now,
}, merge=False)
# Clean up the code
await fstore.doc_delete("link_codes", body.code)
logger.info(f"Linked firebase_uid={firebase_uid} <-> discord_user_id={body.discord_user_id}")
return {"ok": True, "firebase_uid": firebase_uid}
# ---------------------------------------------------------------------------
# Web: check current link status
# ---------------------------------------------------------------------------
@router.get("/link/status")
async def link_status(decoded: dict = Depends(require_firebase_token)):
firebase_uid = decoded["uid"]
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
if link and link.get("discord_user_id"):
return {
"linked": True,
"discord_user_id": link["discord_user_id"],
"discord_username": link.get("discord_username", ""),
"linked_at": link.get("linked_at"),
}
return {"linked": False}
# ---------------------------------------------------------------------------
# Web: unlink
# ---------------------------------------------------------------------------
@router.delete("/link")
async def unlink(decoded: dict = Depends(require_firebase_token)):
firebase_uid = decoded["uid"]
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
if not link or not link.get("discord_user_id"):
raise HTTPException(404, "No linked Discord account.")
discord_user_id = link["discord_user_id"]
await fstore.doc_delete("discord_links", discord_user_id)
await fstore.doc_delete("firebase_discord_links", firebase_uid)
return {"ok": True}
+78 -4
View File
@@ -18,6 +18,32 @@ from app.internal.auth import (
router = APIRouter(prefix="/trips", tags=["trips"])
# ---------------------------------------------------------------------------
# Access control helpers
# ---------------------------------------------------------------------------
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
return (link or {}).get("discord_user_id")
def _trip_is_accessible(trip: dict, *, is_service: bool, firebase_uid: Optional[str], discord_id: Optional[str]) -> bool:
"""Return True if the caller may read this trip."""
if is_service:
return True # bot sees all; it filters client-side per-user
if trip.get("visibility", "public") == "public":
return True
if not firebase_uid:
return False
# attendees keyed by discord_id — check linked discord_id
if discord_id:
if discord_id in trip.get("attendees", {}):
return True
if discord_id in trip.get("invited_discord_ids", []):
return True
return False
# ---------------------------------------------------------------------------
# AI assistant — tool definitions
# ---------------------------------------------------------------------------
@@ -189,8 +215,12 @@ class ChatRequest(BaseModel):
@router.get("")
async def list_trips():
return await fstore.collection_list("trips")
async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)):
trips = await fstore.collection_list("trips")
is_service = bool(decoded.get("service"))
firebase_uid = decoded.get("uid")
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
return [t for t in trips if _trip_is_accessible(t, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id)]
@router.post("")
@@ -209,6 +239,8 @@ async def create_trip(body: TripCreate):
"attendees": {}, # {discord_user_id: discord_username}
"available_tags": body.available_tags,
"overlap_tags": body.overlap_tags,
"visibility": body.visibility if body.visibility in ("public", "private") else "public",
"invited_discord_ids": body.invited_discord_ids,
"created_at": now,
}
await fstore.doc_set("trips", trip_id, doc, merge=False)
@@ -216,12 +248,17 @@ async def create_trip(body: TripCreate):
@router.get("/{trip_id}")
async def get_trip(trip_id: str):
async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
is_service = bool(decoded.get("service"))
firebase_uid = decoded.get("uid")
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
if not _trip_is_accessible(trip, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id):
raise HTTPException(403, "This trip is private.")
events = await fstore.collection_list("trip_events", trip_id=trip_id)
events.sort(key=lambda e: (e["date"], e.get("time") or ""))
events.sort(key=lambda e: (e["date"], e.get("start_time") or ""))
return {**trip, "events": events}
@@ -259,12 +296,49 @@ async def join_trip(
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
if trip.get("visibility", "public") == "private":
invited = trip.get("invited_discord_ids", [])
attendees_existing = trip.get("attendees", {})
if body.discord_user_id not in invited and body.discord_user_id not in attendees_existing:
raise HTTPException(403, "This trip is private. You need an invite to join.")
attendees = trip.get("attendees", {})
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
return {"ok": True, "attendees": attendees}
@router.put("/{trip_id}/visibility")
async def set_visibility(trip_id: str, body: dict, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
visibility = body.get("visibility", "public")
if visibility not in ("public", "private"):
raise HTTPException(400, "visibility must be 'public' or 'private'.")
await fstore.doc_update("trips", trip_id, {"visibility": visibility})
return {"visibility": visibility}
@router.post("/{trip_id}/invite/{discord_user_id}")
async def invite_user(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
invited = list(set(trip.get("invited_discord_ids", []) + [discord_user_id]))
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
return {"ok": True, "invited_discord_ids": invited}
@router.delete("/{trip_id}/invite/{discord_user_id}")
async def revoke_invite(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
invited = [u for u in trip.get("invited_discord_ids", []) if u != discord_user_id]
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
return {"ok": True, "invited_discord_ids": invited}
@router.post("/{trip_id}/leave")
async def leave_trip(
trip_id: str,