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:
Joel Salmon
2026-06-15 18:26:08 -05:00
parent b8f176bb31
commit 33674f92f4
22 changed files with 958 additions and 244 deletions
+48 -24
View File
@@ -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"}
+38 -1
View File
@@ -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
)