mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 07:10:37 +00:00
Complete Phase 1: goals, cookie auth, profile editing
Close the remaining Phase 1 DoD gaps and reconcile the browser flow with the auth layer. Goals (5 -> 7 prompts): - Add near-term (6-12mo) and long-term (3-5yr) goal prompts; collect raw text on the conversation and store AI-articulated goal summaries on the profile. Extractor articulates the person's own stated goals (mirror, not compass) and never fabricates. Alembic 003 adds the four columns. Cookie-based browser sessions (fixes frontend<->auth desync): - OAuth callback now sets httpOnly session cookies and redirects into the app instead of returning JSON. get_current_user gains a cookie fallback (X-API-Key -> Bearer -> cookie). refresh/logout read the refresh cookie and set/clear cookies. New shared auth.js (authedFetch) sends cookies and silently refreshes on 401. Static pages drop the bogus user_id and call the correct /me endpoints. Profile editing (read/edit/affirm): - PATCH /discovery/profile/me edits the prose (Ikigai summaries, overlap narrative, goals); owner-scoped, partial update, 409 when locked. Edit mode in profile.html with Save/Cancel. Also: bump default model to claude-sonnet-4-6, align ports to 8011 (OAuth redirect, CORS), add COOKIE_SECURE/POST_LOGIN_REDIRECT config, and refresh the README to match the shipped behavior. Tests: 33 passing (added cookie-auth, profile-edit, goal-extraction cases; factored a shared app_client fixture into conftest.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+48
-24
@@ -1,14 +1,18 @@
|
||||
"""OAuth + JWT auth routes (mounted at /api/auth and /api/me)."""
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from authlib.integrations.base_client import OAuthError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import (
|
||||
REFRESH_COOKIE,
|
||||
clear_auth_cookies,
|
||||
create_access_token,
|
||||
email_domain_allowed,
|
||||
find_or_create_google_user,
|
||||
@@ -16,6 +20,7 @@ from app.auth import (
|
||||
hash_refresh_token,
|
||||
issue_refresh_token,
|
||||
oauth,
|
||||
set_auth_cookies,
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.models import (
|
||||
@@ -50,15 +55,10 @@ class UserOut(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class TokenBundle(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
|
||||
|
||||
class RefreshIn(BaseModel):
|
||||
refresh_token: str
|
||||
# Optional: browser clients send the refresh token via httpOnly cookie and
|
||||
# omit the body entirely; API clients may still post it explicitly.
|
||||
refresh_token: Optional[str] = None
|
||||
|
||||
|
||||
class AccessOut(BaseModel):
|
||||
@@ -122,19 +122,32 @@ async def auth_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
refresh = await issue_refresh_token(
|
||||
db, user, request.headers.get("user-agent")
|
||||
)
|
||||
return TokenBundle(
|
||||
access_token=access,
|
||||
refresh_token=refresh,
|
||||
user=UserOut.from_orm_user(user),
|
||||
)
|
||||
|
||||
# The browser drove this redirect flow, so hand the session back as
|
||||
# httpOnly cookies and bounce into the app rather than dumping JSON.
|
||||
redirect_to = os.getenv("POST_LOGIN_REDIRECT", "/static/discovery.html")
|
||||
response = RedirectResponse(url=redirect_to, status_code=status.HTTP_303_SEE_OTHER)
|
||||
set_auth_cookies(response, access, refresh)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/auth/refresh", response_model=AccessOut)
|
||||
async def refresh_access_token(
|
||||
body: RefreshIn, db: AsyncSession = Depends(get_db)
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: Optional[RefreshIn] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
raw_refresh = (body.refresh_token if body else None) or request.cookies.get(
|
||||
REFRESH_COOKIE
|
||||
)
|
||||
if not raw_refresh:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="No refresh token provided",
|
||||
)
|
||||
stmt = select(RefreshToken).where(
|
||||
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
|
||||
RefreshToken.token_hash == hash_refresh_token(raw_refresh)
|
||||
)
|
||||
row = (await db.execute(stmt)).scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -150,6 +163,8 @@ async def refresh_access_token(
|
||||
detail="User no longer exists",
|
||||
)
|
||||
access = create_access_token(user.id, user.email)
|
||||
# Refresh the access cookie in place; the refresh cookie is untouched.
|
||||
set_auth_cookies(response, access)
|
||||
return AccessOut(access_token=access)
|
||||
|
||||
|
||||
@@ -162,16 +177,25 @@ def _expired(expires_at: datetime, now: datetime) -> bool:
|
||||
|
||||
@router.post("/auth/logout")
|
||||
async def logout(
|
||||
body: RefreshIn, db: AsyncSession = Depends(get_db)
|
||||
request: Request,
|
||||
response: Response,
|
||||
body: Optional[RefreshIn] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Revoke a single refresh token. Idempotent — unknown token returns 200."""
|
||||
stmt = select(RefreshToken).where(
|
||||
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
|
||||
"""Revoke a single refresh token and clear the session cookies. Idempotent
|
||||
— an unknown/missing token still returns 200 with cookies cleared."""
|
||||
raw_refresh = (body.refresh_token if body else None) or request.cookies.get(
|
||||
REFRESH_COOKIE
|
||||
)
|
||||
row = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if row is not None and row.revoked_at is None:
|
||||
row.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
if raw_refresh:
|
||||
stmt = select(RefreshToken).where(
|
||||
RefreshToken.token_hash == hash_refresh_token(raw_refresh)
|
||||
)
|
||||
row = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if row is not None and row.revoked_at is None:
|
||||
row.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
clear_auth_cookies(response)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ def _to_profile_response(
|
||||
mission_summary=profile.mission_summary,
|
||||
vocation_summary=profile.vocation_summary,
|
||||
overlap_narrative=profile.overlap_narrative,
|
||||
short_term_goals=profile.short_term_goals,
|
||||
long_term_goals=profile.long_term_goals,
|
||||
confidence=confidence,
|
||||
locked=profile.locked,
|
||||
extraction_notes=extraction_notes,
|
||||
@@ -116,6 +118,8 @@ async def save_responses(
|
||||
conversation.prompt_pull = payload.prompt_pull
|
||||
conversation.prompt_recognition = payload.prompt_recognition
|
||||
conversation.prompt_future = payload.prompt_future
|
||||
conversation.prompt_goals_short = payload.prompt_goals_short
|
||||
conversation.prompt_goals_long = payload.prompt_goals_long
|
||||
await db.commit()
|
||||
|
||||
return schemas.RespondResponse(
|
||||
@@ -139,6 +143,8 @@ async def complete_conversation(
|
||||
"pull": conversation.prompt_pull or "",
|
||||
"recognition": conversation.prompt_recognition or "",
|
||||
"future": conversation.prompt_future or "",
|
||||
"goals_short": conversation.prompt_goals_short or "",
|
||||
"goals_long": conversation.prompt_goals_long or "",
|
||||
}
|
||||
if not any(text.strip() for text in responses.values()):
|
||||
raise HTTPException(
|
||||
@@ -146,7 +152,7 @@ async def complete_conversation(
|
||||
)
|
||||
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5")
|
||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||
|
||||
try:
|
||||
extractor = DiscoveryExtractor(api_key=api_key, model=model)
|
||||
@@ -169,6 +175,8 @@ async def complete_conversation(
|
||||
mission_summary=data.get("mission_summary"),
|
||||
vocation_summary=data.get("vocation_summary"),
|
||||
overlap_narrative=data.get("overlap_narrative"),
|
||||
short_term_goals=data.get("short_term_goals"),
|
||||
long_term_goals=data.get("long_term_goals"),
|
||||
confidence_json=json.dumps(data.get("confidence", {})),
|
||||
locked=False,
|
||||
)
|
||||
@@ -192,6 +200,35 @@ async def get_my_profile(
|
||||
return _to_profile_response(profile)
|
||||
|
||||
|
||||
@router.patch("/profile/me", response_model=schemas.ProfileResponse)
|
||||
async def update_my_profile(
|
||||
payload: schemas.ProfileUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Edit the prose of the latest profile. The person owns their words, so
|
||||
they can revise any summary, the narrative, or their goals — but only
|
||||
while the profile is unlocked. Affirming (locking) makes it final."""
|
||||
profile = await _latest_profile(db, user.id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||
if profile.locked:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Profile is locked; it can no longer be edited.",
|
||||
)
|
||||
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
for field, value in updates.items():
|
||||
setattr(profile, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(profile)
|
||||
|
||||
return _to_profile_response(profile)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/profile/me/confirm", response_model=schemas.ConfirmResponse
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user