feat(meeting-intelligence): add per-meeting access control
Restrict MI data so only meeting attendees can access their meetings. Each meeting gets a unique access_token generated at creation time. Attendees discover their token via conference_id (room name) lookup. Backend: - New auth.py with Bearer token validation and multi-token extraction - Token generation in create_meeting(), backfill on startup - All endpoints gated: list_meetings filters by X-MI-Tokens header, per-meeting endpoints require Authorization: Bearer <token> - New GET /meetings/token?conference_id=<room> discovery endpoint Frontend: - tokenStorage.ts manages tokens in localStorage keyed by room name - middleware.ts auto-fetches token on CONFERENCE_JOINED - All API calls in actions.ts now include auth headers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4efcdfbf36
commit
2fe77055a9
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""
|
||||||
|
Authentication helpers for per-meeting access control.
|
||||||
|
|
||||||
|
Each meeting has a unique access_token. Knowing the conference_id (room name)
|
||||||
|
lets a client discover the token via GET /meetings/token?conference_id=<room>.
|
||||||
|
All other endpoints require a valid token to return data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def get_bearer_token(request: Request) -> Optional[str]:
|
||||||
|
"""Extract Bearer token from Authorization header."""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if auth.startswith("Bearer "):
|
||||||
|
return auth[7:]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_multi_tokens(request: Request) -> List[str]:
|
||||||
|
"""Extract comma-separated tokens from X-MI-Tokens header."""
|
||||||
|
raw = request.headers.get("X-MI-Tokens", "")
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_meeting_access(
|
||||||
|
request: Request,
|
||||||
|
meeting_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Verify the Bearer token matches the meeting's access_token.
|
||||||
|
|
||||||
|
Raises 403 if the token is missing or doesn't match.
|
||||||
|
"""
|
||||||
|
token = get_bearer_token(request)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=403, detail="Missing access token")
|
||||||
|
|
||||||
|
db = request.app.state.db
|
||||||
|
stored_token = await db.get_meeting_access_token(meeting_id)
|
||||||
|
|
||||||
|
if not stored_token or token != stored_token:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid access token")
|
||||||
|
|
@ -3,6 +3,7 @@ Database operations for the Meeting Intelligence API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
@ -103,16 +104,18 @@ class Database:
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a new meeting record."""
|
"""Create a new meeting record."""
|
||||||
meeting_id = str(uuid.uuid4())
|
meeting_id = str(uuid.uuid4())
|
||||||
|
access_token = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
async with self.pool.acquire() as conn:
|
async with self.pool.acquire() as conn:
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
INSERT INTO meetings (
|
INSERT INTO meetings (
|
||||||
id, conference_id, conference_name, title,
|
id, conference_id, conference_name, title,
|
||||||
recording_path, started_at, status, metadata
|
recording_path, started_at, status, access_token, metadata
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 'recording', $7::jsonb)
|
VALUES ($1, $2, $3, $4, $5, $6, 'recording', $7, $8::jsonb)
|
||||||
""", meeting_id, conference_id, conference_name, title,
|
""", meeting_id, conference_id, conference_name, title,
|
||||||
recording_path, started_at or datetime.utcnow(), json.dumps(metadata or {}))
|
recording_path, started_at or datetime.utcnow(),
|
||||||
|
access_token, json.dumps(metadata or {}))
|
||||||
|
|
||||||
return meeting_id
|
return meeting_id
|
||||||
|
|
||||||
|
|
@ -148,6 +151,91 @@ class Database:
|
||||||
WHERE id = ${i}::uuid
|
WHERE id = ${i}::uuid
|
||||||
""", *values)
|
""", *values)
|
||||||
|
|
||||||
|
async def list_meetings_by_tokens(
|
||||||
|
self,
|
||||||
|
tokens: List[str],
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
status: Optional[str] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""List meetings filtered by access tokens."""
|
||||||
|
if not tokens:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
if status:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT id, conference_id, conference_name, title,
|
||||||
|
started_at, ended_at, duration_seconds,
|
||||||
|
status, created_at
|
||||||
|
FROM meetings
|
||||||
|
WHERE access_token = ANY($1) AND status = $2
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
""", tokens, status, limit, offset)
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT id, conference_id, conference_name, title,
|
||||||
|
started_at, ended_at, duration_seconds,
|
||||||
|
status, created_at
|
||||||
|
FROM meetings
|
||||||
|
WHERE access_token = ANY($1)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
""", tokens, limit, offset)
|
||||||
|
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
async def get_meeting_tokens_by_conference(
|
||||||
|
self,
|
||||||
|
conference_id: str
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get access tokens for all meetings with a given conference_id."""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT id, conference_id, access_token
|
||||||
|
FROM meetings
|
||||||
|
WHERE conference_id = $1 AND access_token IS NOT NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""", conference_id)
|
||||||
|
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
async def get_meeting_access_token(
|
||||||
|
self,
|
||||||
|
meeting_id: str
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Get the access token for a specific meeting."""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow("""
|
||||||
|
SELECT access_token FROM meetings WHERE id = $1::uuid
|
||||||
|
""", meeting_id)
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return row["access_token"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def backfill_tokens(self):
|
||||||
|
"""Generate access tokens for meetings that don't have one."""
|
||||||
|
async with self.pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT id FROM meetings WHERE access_token IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for row in rows:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE meetings SET access_token = $1 WHERE id = $2
|
||||||
|
""", token, row["id"])
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
log.info("Backfilled access tokens", count=count)
|
||||||
|
return count
|
||||||
|
|
||||||
# ==================== Transcripts ====================
|
# ==================== Transcripts ====================
|
||||||
|
|
||||||
async def get_transcript(
|
async def get_transcript(
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ async def lifespan(app: FastAPI):
|
||||||
# Make database available to routes
|
# Make database available to routes
|
||||||
app.state.db = state.db
|
app.state.db = state.db
|
||||||
|
|
||||||
|
# Backfill access tokens for any meetings created before auth was added
|
||||||
|
try:
|
||||||
|
await state.db.backfill_tokens()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Token backfill failed (non-fatal)", error=str(e))
|
||||||
|
|
||||||
log.info("Meeting Intelligence API started successfully")
|
log.info("Meeting Intelligence API started successfully")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import validate_meeting_access
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
|
|
@ -35,7 +37,9 @@ async def export_meeting(
|
||||||
include_transcript: bool = True,
|
include_transcript: bool = True,
|
||||||
include_summary: bool = True
|
include_summary: bool = True
|
||||||
):
|
):
|
||||||
"""Export meeting data in various formats."""
|
"""Export meeting data in various formats. Requires Bearer token."""
|
||||||
|
await validate_meeting_access(request, meeting_id)
|
||||||
|
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
|
|
||||||
# Get meeting data
|
# Get meeting data
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ from typing import Optional, List
|
||||||
from fastapi import APIRouter, HTTPException, Request, Query
|
from fastapi import APIRouter, HTTPException, Request, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import get_bearer_token, get_multi_tokens, validate_meeting_access
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
|
|
@ -36,6 +38,39 @@ class MeetingListResponse(BaseModel):
|
||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class MeetingTokenResponse(BaseModel):
|
||||||
|
conference_id: str
|
||||||
|
meeting_id: str
|
||||||
|
access_token: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/token", response_model=List[MeetingTokenResponse])
|
||||||
|
async def get_meeting_token(
|
||||||
|
request: Request,
|
||||||
|
conference_id: str = Query(..., description="Room name to look up tokens for")
|
||||||
|
):
|
||||||
|
"""Get access tokens for meetings by conference_id (room name).
|
||||||
|
|
||||||
|
This is the only unauthenticated endpoint (besides /health).
|
||||||
|
Knowing the room name is proof of attendance.
|
||||||
|
"""
|
||||||
|
db = request.app.state.db
|
||||||
|
|
||||||
|
meetings = await db.get_meeting_tokens_by_conference(conference_id)
|
||||||
|
|
||||||
|
if not meetings:
|
||||||
|
raise HTTPException(status_code=404, detail="No meetings found for this conference")
|
||||||
|
|
||||||
|
return [
|
||||||
|
MeetingTokenResponse(
|
||||||
|
conference_id=m["conference_id"],
|
||||||
|
meeting_id=str(m["id"]),
|
||||||
|
access_token=m["access_token"]
|
||||||
|
)
|
||||||
|
for m in meetings
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=MeetingListResponse)
|
@router.get("", response_model=MeetingListResponse)
|
||||||
async def list_meetings(
|
async def list_meetings(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -43,10 +78,20 @@ async def list_meetings(
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
status: Optional[str] = Query(default=None)
|
status: Optional[str] = Query(default=None)
|
||||||
):
|
):
|
||||||
"""List all meetings with pagination."""
|
"""List meetings the caller has access to.
|
||||||
db = request.app.state.db
|
|
||||||
|
|
||||||
meetings = await db.list_meetings(limit=limit, offset=offset, status=status)
|
Requires X-MI-Tokens header with comma-separated access tokens.
|
||||||
|
Returns only meetings matching the provided tokens.
|
||||||
|
"""
|
||||||
|
db = request.app.state.db
|
||||||
|
tokens = get_multi_tokens(request)
|
||||||
|
|
||||||
|
if not tokens:
|
||||||
|
return MeetingListResponse(meetings=[], total=0, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
meetings = await db.list_meetings_by_tokens(
|
||||||
|
tokens=tokens, limit=limit, offset=offset, status=status
|
||||||
|
)
|
||||||
|
|
||||||
return MeetingListResponse(
|
return MeetingListResponse(
|
||||||
meetings=[
|
meetings=[
|
||||||
|
|
@ -63,7 +108,7 @@ async def list_meetings(
|
||||||
)
|
)
|
||||||
for m in meetings
|
for m in meetings
|
||||||
],
|
],
|
||||||
total=len(meetings), # TODO: Add total count query
|
total=len(meetings),
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset
|
offset=offset
|
||||||
)
|
)
|
||||||
|
|
@ -71,9 +116,10 @@ async def list_meetings(
|
||||||
|
|
||||||
@router.get("/{meeting_id}", response_model=MeetingResponse)
|
@router.get("/{meeting_id}", response_model=MeetingResponse)
|
||||||
async def get_meeting(request: Request, meeting_id: str):
|
async def get_meeting(request: Request, meeting_id: str):
|
||||||
"""Get meeting details."""
|
"""Get meeting details. Requires Bearer token."""
|
||||||
db = request.app.state.db
|
await validate_meeting_access(request, meeting_id)
|
||||||
|
|
||||||
|
db = request.app.state.db
|
||||||
meeting = await db.get_meeting(meeting_id)
|
meeting = await db.get_meeting(meeting_id)
|
||||||
|
|
||||||
if not meeting:
|
if not meeting:
|
||||||
|
|
@ -97,16 +143,15 @@ async def get_meeting(request: Request, meeting_id: str):
|
||||||
|
|
||||||
@router.delete("/{meeting_id}")
|
@router.delete("/{meeting_id}")
|
||||||
async def delete_meeting(request: Request, meeting_id: str):
|
async def delete_meeting(request: Request, meeting_id: str):
|
||||||
"""Delete a meeting and all associated data."""
|
"""Delete a meeting and all associated data. Requires Bearer token."""
|
||||||
db = request.app.state.db
|
await validate_meeting_access(request, meeting_id)
|
||||||
|
|
||||||
|
db = request.app.state.db
|
||||||
meeting = await db.get_meeting(meeting_id)
|
meeting = await db.get_meeting(meeting_id)
|
||||||
|
|
||||||
if not meeting:
|
if not meeting:
|
||||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||||
|
|
||||||
# TODO: Implement cascade delete
|
|
||||||
# For now, just mark as deleted
|
|
||||||
await db.update_meeting(meeting_id, status="deleted")
|
await db.update_meeting(meeting_id, status="deleted")
|
||||||
|
|
||||||
return {"status": "deleted", "meeting_id": meeting_id}
|
return {"status": "deleted", "meeting_id": meeting_id}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Request, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sentence_transformers import SentenceTransformer
|
from sentence_transformers import SentenceTransformer
|
||||||
|
|
||||||
|
from ..auth import get_multi_tokens
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
@ -58,6 +59,8 @@ class SearchRequest(BaseModel):
|
||||||
async def search_transcripts(request: Request, body: SearchRequest):
|
async def search_transcripts(request: Request, body: SearchRequest):
|
||||||
"""Search across meeting transcripts.
|
"""Search across meeting transcripts.
|
||||||
|
|
||||||
|
Requires X-MI-Tokens header. Results are filtered to authorized meetings only.
|
||||||
|
|
||||||
Search types:
|
Search types:
|
||||||
- text: Full-text search using PostgreSQL ts_vector
|
- text: Full-text search using PostgreSQL ts_vector
|
||||||
- semantic: Semantic search using vector embeddings
|
- semantic: Semantic search using vector embeddings
|
||||||
|
|
@ -65,6 +68,13 @@ async def search_transcripts(request: Request, body: SearchRequest):
|
||||||
"""
|
"""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
|
|
||||||
|
# Get authorized meeting IDs from tokens
|
||||||
|
tokens = get_multi_tokens(request)
|
||||||
|
authorized_meetings = set()
|
||||||
|
if tokens:
|
||||||
|
meetings = await db.list_meetings_by_tokens(tokens=tokens, limit=1000)
|
||||||
|
authorized_meetings = {str(m["id"]) for m in meetings}
|
||||||
|
|
||||||
if not body.query or len(body.query.strip()) < 2:
|
if not body.query or len(body.query.strip()) < 2:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
@ -124,6 +134,12 @@ async def search_transcripts(request: Request, body: SearchRequest):
|
||||||
detail=f"Semantic search failed: {str(e)}"
|
detail=f"Semantic search failed: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter to authorized meetings only
|
||||||
|
if authorized_meetings:
|
||||||
|
results = [r for r in results if r.meeting_id in authorized_meetings]
|
||||||
|
else:
|
||||||
|
results = []
|
||||||
|
|
||||||
# Deduplicate and sort by score
|
# Deduplicate and sort by score
|
||||||
seen = set()
|
seen = set()
|
||||||
unique_results = []
|
unique_results = []
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Request, BackgroundTasks
|
from fastapi import APIRouter, HTTPException, Request, BackgroundTasks
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import validate_meeting_access
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
@ -116,7 +117,9 @@ def _summary_to_response(meeting_id: str, summary: dict) -> SummaryResponse:
|
||||||
|
|
||||||
@router.get("/{meeting_id}/summary", response_model=SummaryResponse)
|
@router.get("/{meeting_id}/summary", response_model=SummaryResponse)
|
||||||
async def get_summary(request: Request, meeting_id: str):
|
async def get_summary(request: Request, meeting_id: str):
|
||||||
"""Get AI-generated summary for a meeting."""
|
"""Get AI-generated summary for a meeting. Requires Bearer token."""
|
||||||
|
await validate_meeting_access(request, meeting_id)
|
||||||
|
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
|
|
||||||
# Verify meeting exists
|
# Verify meeting exists
|
||||||
|
|
@ -142,7 +145,9 @@ async def generate_summary(
|
||||||
body: GenerateSummaryRequest,
|
body: GenerateSummaryRequest,
|
||||||
background_tasks: BackgroundTasks
|
background_tasks: BackgroundTasks
|
||||||
):
|
):
|
||||||
"""Generate AI summary for a meeting."""
|
"""Generate AI summary for a meeting. Requires Bearer token."""
|
||||||
|
await validate_meeting_access(request, meeting_id)
|
||||||
|
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
|
|
||||||
# Verify meeting exists
|
# Verify meeting exists
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ from typing import Optional, List
|
||||||
from fastapi import APIRouter, HTTPException, Request, Query
|
from fastapi import APIRouter, HTTPException, Request, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..auth import validate_meeting_access
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
|
|
@ -53,7 +55,9 @@ async def get_transcript(
|
||||||
meeting_id: str,
|
meeting_id: str,
|
||||||
speaker: Optional[str] = Query(default=None, description="Filter by speaker ID")
|
speaker: Optional[str] = Query(default=None, description="Filter by speaker ID")
|
||||||
):
|
):
|
||||||
"""Get full transcript for a meeting."""
|
"""Get full transcript for a meeting. Requires Bearer token."""
|
||||||
|
await validate_meeting_access(request, meeting_id)
|
||||||
|
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
|
|
||||||
# Verify meeting exists
|
# Verify meeting exists
|
||||||
|
|
@ -96,7 +100,9 @@ async def get_transcript(
|
||||||
|
|
||||||
@router.get("/{meeting_id}/speakers", response_model=SpeakersResponse)
|
@router.get("/{meeting_id}/speakers", response_model=SpeakersResponse)
|
||||||
async def get_speakers(request: Request, meeting_id: str):
|
async def get_speakers(request: Request, meeting_id: str):
|
||||||
"""Get speaker statistics for a meeting."""
|
"""Get speaker statistics for a meeting. Requires Bearer token."""
|
||||||
|
await validate_meeting_access(request, meeting_id)
|
||||||
|
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
|
|
||||||
# Verify meeting exists
|
# Verify meeting exists
|
||||||
|
|
@ -123,7 +129,9 @@ async def get_speakers(request: Request, meeting_id: str):
|
||||||
|
|
||||||
@router.get("/{meeting_id}/transcript/text")
|
@router.get("/{meeting_id}/transcript/text")
|
||||||
async def get_transcript_text(request: Request, meeting_id: str):
|
async def get_transcript_text(request: Request, meeting_id: str):
|
||||||
"""Get transcript as plain text."""
|
"""Get transcript as plain text. Requires Bearer token."""
|
||||||
|
await validate_meeting_access(request, meeting_id)
|
||||||
|
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
|
|
||||||
# Verify meeting exists
|
# Verify meeting exists
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ CREATE TABLE meetings (
|
||||||
status VARCHAR(50) DEFAULT 'recording',
|
status VARCHAR(50) DEFAULT 'recording',
|
||||||
-- Status: 'recording', 'extracting_audio', 'transcribing', 'diarizing', 'summarizing', 'ready', 'failed'
|
-- Status: 'recording', 'extracting_audio', 'transcribing', 'diarizing', 'summarizing', 'ready', 'failed'
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
|
access_token VARCHAR(64) UNIQUE,
|
||||||
metadata JSONB DEFAULT '{}',
|
metadata JSONB DEFAULT '{}',
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
|
@ -30,6 +31,7 @@ CREATE INDEX idx_meetings_conference_id ON meetings(conference_id);
|
||||||
CREATE INDEX idx_meetings_status ON meetings(status);
|
CREATE INDEX idx_meetings_status ON meetings(status);
|
||||||
CREATE INDEX idx_meetings_started_at ON meetings(started_at DESC);
|
CREATE INDEX idx_meetings_started_at ON meetings(started_at DESC);
|
||||||
CREATE INDEX idx_meetings_created_at ON meetings(created_at DESC);
|
CREATE INDEX idx_meetings_created_at ON meetings(created_at DESC);
|
||||||
|
CREATE INDEX idx_meetings_access_token ON meetings(access_token);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- Meeting Participants
|
-- Meeting Participants
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,6 @@ import '../face-landmarks/middleware';
|
||||||
import '../gifs/middleware';
|
import '../gifs/middleware';
|
||||||
import '../whiteboard/middleware.web';
|
import '../whiteboard/middleware.web';
|
||||||
import '../file-sharing/middleware.web';
|
import '../file-sharing/middleware.web';
|
||||||
|
import '../meeting-intelligence/middleware';
|
||||||
|
|
||||||
import './middlewares.any';
|
import './middlewares.any';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* Action creators for Meeting Intelligence feature.
|
* Action creators for Meeting Intelligence feature.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IStore } from '../app/types';
|
import { IReduxState, IStore } from '../app/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CLEAR_SEARCH,
|
CLEAR_SEARCH,
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
UPDATE_MEETING_STATUS
|
UPDATE_MEETING_STATUS
|
||||||
} from './actionTypes';
|
} from './actionTypes';
|
||||||
import { API_BASE_URL } from './constants';
|
import { API_BASE_URL } from './constants';
|
||||||
|
import { getAllTokens, getTokenForConference, storeToken } from './tokenStorage';
|
||||||
import {
|
import {
|
||||||
IMeeting,
|
IMeeting,
|
||||||
IMeetingSummary,
|
IMeetingSummary,
|
||||||
|
|
@ -42,6 +43,43 @@ import {
|
||||||
ITranscriptSegment
|
ITranscriptSegment
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a Bearer auth header for a meeting, looked up by conference_id from state.
|
||||||
|
*
|
||||||
|
* @param {Function} getState - Redux getState function.
|
||||||
|
* @param {string} meetingId - Optional meeting ID to look up conference_id.
|
||||||
|
* @returns {Record<string, string>} Headers object with Authorization if token found.
|
||||||
|
*/
|
||||||
|
function _getBearerHeaders(getState: () => IReduxState, meetingId?: string): Record<string, string> {
|
||||||
|
const state = getState();
|
||||||
|
const miState = state['features/meeting-intelligence'];
|
||||||
|
let conferenceId: string | undefined;
|
||||||
|
|
||||||
|
// Try to find conference_id from selected meeting or provided meetingId
|
||||||
|
if (miState?.selectedMeeting) {
|
||||||
|
conferenceId = miState.selectedMeeting.conference_id;
|
||||||
|
} else if (meetingId && miState?.meetings) {
|
||||||
|
const meeting = miState.meetings.find((m: IMeeting) => m.id === meetingId);
|
||||||
|
|
||||||
|
conferenceId = meeting?.conference_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to current conference room name
|
||||||
|
if (!conferenceId) {
|
||||||
|
conferenceId = state['features/base/conference']?.room;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conferenceId) {
|
||||||
|
const token = getTokenForConference(conferenceId);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle the meeting intelligence dashboard.
|
* Toggle the meeting intelligence dashboard.
|
||||||
*
|
*
|
||||||
|
|
@ -94,6 +132,37 @@ export function clearSelectedMeeting() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch access tokens for a conference_id and store them locally.
|
||||||
|
*
|
||||||
|
* @param {string} conferenceId - The room name.
|
||||||
|
* @returns {Function} Async thunk action.
|
||||||
|
*/
|
||||||
|
export function fetchMeetingToken(conferenceId: string) {
|
||||||
|
return async (_dispatch: IStore['dispatch']) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/meetings/token?conference_id=${encodeURIComponent(conferenceId)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Store each token keyed by conference_id
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
for (const entry of data) {
|
||||||
|
storeToken(entry.conference_id, entry.access_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Token fetch failures are non-fatal
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the list of meetings.
|
* Fetch the list of meetings.
|
||||||
*
|
*
|
||||||
|
|
@ -104,7 +173,14 @@ export function fetchMeetings() {
|
||||||
dispatch({ type: FETCH_MEETINGS_REQUEST });
|
dispatch({ type: FETCH_MEETINGS_REQUEST });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/meetings`);
|
const tokens = getAllTokens();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
headers['X-MI-Tokens'] = tokens.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/meetings`, { headers });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error: ${response.status}`);
|
throw new Error(`HTTP error: ${response.status}`);
|
||||||
|
|
@ -132,11 +208,13 @@ export function fetchMeetings() {
|
||||||
* @returns {Function} Async thunk action.
|
* @returns {Function} Async thunk action.
|
||||||
*/
|
*/
|
||||||
export function fetchTranscript(meetingId: string) {
|
export function fetchTranscript(meetingId: string) {
|
||||||
return async (dispatch: IStore['dispatch']) => {
|
return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
|
||||||
dispatch({ type: FETCH_TRANSCRIPT_REQUEST });
|
dispatch({ type: FETCH_TRANSCRIPT_REQUEST });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/transcript`);
|
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/transcript`, {
|
||||||
|
headers: _getBearerHeaders(getState, meetingId)
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
|
|
@ -172,11 +250,13 @@ export function fetchTranscript(meetingId: string) {
|
||||||
* @returns {Function} Async thunk action.
|
* @returns {Function} Async thunk action.
|
||||||
*/
|
*/
|
||||||
export function fetchSummary(meetingId: string) {
|
export function fetchSummary(meetingId: string) {
|
||||||
return async (dispatch: IStore['dispatch']) => {
|
return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
|
||||||
dispatch({ type: FETCH_SUMMARY_REQUEST });
|
dispatch({ type: FETCH_SUMMARY_REQUEST });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`);
|
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`, {
|
||||||
|
headers: _getBearerHeaders(getState, meetingId)
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
|
|
@ -212,12 +292,13 @@ export function fetchSummary(meetingId: string) {
|
||||||
* @returns {Function} Async thunk action.
|
* @returns {Function} Async thunk action.
|
||||||
*/
|
*/
|
||||||
export function generateSummary(meetingId: string) {
|
export function generateSummary(meetingId: string) {
|
||||||
return async (dispatch: IStore['dispatch']) => {
|
return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
|
||||||
dispatch({ type: GENERATE_SUMMARY_REQUEST });
|
dispatch({ type: GENERATE_SUMMARY_REQUEST });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`, {
|
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`, {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
headers: _getBearerHeaders(getState, meetingId)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -246,9 +327,11 @@ export function generateSummary(meetingId: string) {
|
||||||
* @returns {Function} Async thunk action.
|
* @returns {Function} Async thunk action.
|
||||||
*/
|
*/
|
||||||
export function fetchSpeakerStats(meetingId: string) {
|
export function fetchSpeakerStats(meetingId: string) {
|
||||||
return async (dispatch: IStore['dispatch']) => {
|
return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/speakers`);
|
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/speakers`, {
|
||||||
|
headers: _getBearerHeaders(getState, meetingId)
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -285,9 +368,18 @@ export function searchTranscripts(query: string) {
|
||||||
dispatch({ type: SEARCH_REQUEST });
|
dispatch({ type: SEARCH_REQUEST });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const tokens = getAllTokens();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
headers['X-MI-Tokens'] = tokens.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/search`, {
|
const response = await fetch(`${API_BASE_URL}/search`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
body: JSON.stringify({ query, limit: 50 })
|
body: JSON.stringify({ query, limit: 50 })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -340,12 +432,13 @@ export function setExportFormat(format: 'pdf' | 'markdown' | 'json') {
|
||||||
* @returns {Function} Async thunk action.
|
* @returns {Function} Async thunk action.
|
||||||
*/
|
*/
|
||||||
export function exportMeeting(meetingId: string, format: string) {
|
export function exportMeeting(meetingId: string, format: string) {
|
||||||
return async (dispatch: IStore['dispatch']) => {
|
return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
|
||||||
dispatch({ type: EXPORT_REQUEST });
|
dispatch({ type: EXPORT_REQUEST });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE_URL}/meetings/${meetingId}/export?format=${format}`
|
`${API_BASE_URL}/meetings/${meetingId}/export?format=${format}`,
|
||||||
|
{ headers: _getBearerHeaders(getState, meetingId) }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ const RecordingsList: React.FC = () => {
|
||||||
width = '48'>
|
width = '48'>
|
||||||
<path d = 'M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z' />
|
<path d = 'M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z' />
|
||||||
</svg>
|
</svg>
|
||||||
<h3>No recordings yet</h3>
|
<h3>No recordings available</h3>
|
||||||
<p>Recordings will appear here after you record a meeting.</p>
|
<p>Recordings will appear here after your meetings are processed.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Middleware for Meeting Intelligence feature.
|
||||||
|
*
|
||||||
|
* Listens for CONFERENCE_JOINED to automatically fetch the access token
|
||||||
|
* for the current room, so it's ready when the user opens the MI dashboard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CONFERENCE_JOINED } from '../base/conference/actionTypes';
|
||||||
|
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||||
|
|
||||||
|
import { fetchMeetingToken } from './actions';
|
||||||
|
|
||||||
|
MiddlewareRegistry.register(({ dispatch, getState }) => (next: Function) => (action: any) => {
|
||||||
|
const result = next(action);
|
||||||
|
|
||||||
|
if (action.type === CONFERENCE_JOINED) {
|
||||||
|
const room = getState()['features/base/conference']?.room;
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
dispatch(fetchMeetingToken(room) as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* LocalStorage-based token storage for Meeting Intelligence access control.
|
||||||
|
*
|
||||||
|
* Tokens are keyed by conference_id (room name) so attendees can access
|
||||||
|
* meeting data across sessions without user accounts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'mi-tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all stored tokens as a map of conferenceId -> token.
|
||||||
|
*
|
||||||
|
* @returns {Record<string, string>} The stored tokens.
|
||||||
|
*/
|
||||||
|
export function getStoredTokens(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a token for a conference.
|
||||||
|
*
|
||||||
|
* @param {string} conferenceId - The conference/room name.
|
||||||
|
* @param {string} token - The access token.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function storeToken(conferenceId: string, token: string): void {
|
||||||
|
try {
|
||||||
|
const tokens = getStoredTokens();
|
||||||
|
|
||||||
|
tokens[conferenceId] = token;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a flat array of all stored token values.
|
||||||
|
*
|
||||||
|
* @returns {string[]} Array of token strings.
|
||||||
|
*/
|
||||||
|
export function getAllTokens(): string[] {
|
||||||
|
return Object.values(getStoredTokens());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the token for a specific conference.
|
||||||
|
*
|
||||||
|
* @param {string} conferenceId - The conference/room name.
|
||||||
|
* @returns {string|undefined} The token or undefined.
|
||||||
|
*/
|
||||||
|
export function getTokenForConference(conferenceId: string): string | undefined {
|
||||||
|
return getStoredTokens()[conferenceId];
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue